Skip to content

Commit 42291fe

Browse files
committed
feat(posts): added AI generated chapters using custom RAG. also added default R2 caption selections
1 parent 824da26 commit 42291fe

7 files changed

Lines changed: 143 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import aiSearch from '#services/ai_search_service'
2+
import r2Service from '#services/r2_service'
3+
import type { HttpContext } from '@adonisjs/core/http'
4+
5+
export default class AiVideosController {
6+
async chapters({ params }: HttpContext) {
7+
const transcript = await r2Service.getTranscript(params.videoId)
8+
const chapters = await aiSearch.chapters(transcript)
9+
10+
return chapters
11+
}
12+
}

app/services/ai_search_service.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import env from '#start/env'
2+
import { Exception } from '@adonisjs/core/exceptions'
3+
import axios, { AxiosInstance } from 'axios'
4+
5+
class AiSearchService {
6+
#accountId = env.get('CLOUDFLARE_ACCOUNT_ID')
7+
#token = env.get('AI_SEARCH_KEY')
8+
#api: AxiosInstance
9+
10+
constructor() {
11+
if (!this.#accountId || !this.#token) {
12+
throw new Exception('Cloudflare account id or AI Search token is missing.')
13+
}
14+
15+
this.#api = axios.create({
16+
baseURL: `https://api.cloudflare.com/client/v4/accounts/${this.#accountId}/autorag/rags/adocasts-rag/ai-search`,
17+
headers: {
18+
'Content-Type': 'application/json',
19+
'Authorization': `Bearer ${this.#token}`,
20+
},
21+
})
22+
}
23+
24+
async chapters(captions: string) {
25+
const { data } = await this.#api.post('', {
26+
query: `Take the provided SRT subtitles and create as few as possible, high-level, timestamp chapters from them. Between 3 to 8 chapters. The chapters time should only consist of minutes and seconds unless the captions go over an hour. Response should only contain JavaScript parsable JSON in the structure: [{ start: 'mm:ss', end: 'mm:ss', text: 'chapter text' }]. Captions: ${captions}`,
27+
})
28+
29+
return data
30+
}
31+
}
32+
33+
const aiSearch = new AiSearchService()
34+
export default aiSearch

app/services/r2_service.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import axios, { AxiosInstance } from 'axios'
2+
3+
class R2Service {
4+
#api = axios.create({
5+
baseURL: 'https://vid.adocasts.com',
6+
headers: {
7+
Referer: 'https://adocasts.com',
8+
},
9+
})
10+
11+
async getTranscript(videoId: string) {
12+
const { data } = await this.#api.get(`/${videoId}/en.srt`)
13+
return data as string
14+
}
15+
}
16+
17+
const r2Service = new R2Service()
18+
19+
export default r2Service

inertia/components/FormInput.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const props = withDefaults(
1717
}
1818
)
1919
20-
const emits = defineEmits(['update:modelValue'])
20+
const emits = defineEmits(['update:modelValue', 'blur'])
2121
2222
const inputEl = ref()
2323
@@ -39,6 +39,7 @@ defineExpose({ inputEl })
3939
:type="type"
4040
:class="{ 'absolute start-2 inset-y-2 w-6 h-6 rounded': type === 'color' }"
4141
:disabled="disabled"
42+
@blur="$emit('blur')"
4243
/>
4344
<Input
4445
ref="inputEl"
@@ -47,6 +48,7 @@ defineExpose({ inputEl })
4748
:disabled="disabled"
4849
:placeholder="placeholder"
4950
:required="required"
51+
@blur="$emit('blur')"
5052
/>
5153
</div>
5254
<Select
@@ -55,6 +57,7 @@ defineExpose({ inputEl })
5557
ref="inputEl"
5658
:disabled="disabled"
5759
:required="required"
60+
@blur="$emit('blur')"
5861
>
5962
<SelectTrigger>
6063
<slot name="trigger">
@@ -70,6 +73,7 @@ defineExpose({ inputEl })
7073
v-model="internalValue"
7174
ref="inputEl"
7275
:disabled="disabled"
76+
@blur="$emit('blur')"
7377
/>
7478
<slot v-else-if="type === 'group'" />
7579
<Input
@@ -80,6 +84,7 @@ defineExpose({ inputEl })
8084
:disabled="disabled"
8185
:placeholder="placeholder"
8286
:required="required"
87+
@blur="$emit('blur')"
8388
/>
8489
</Label>
8590
<div class="flex items-center justify-between gap-3">

inertia/pages/posts/form.vue

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import States from '#enums/states'
1010
import VideoTypes, { VideoTypeDesc, VideoTypesOrdered } from '#enums/video_types'
1111
import { useForm } from '@inertiajs/vue3'
1212
import { Link } from '@tuyau/inertia/vue'
13+
import axios from 'axios'
1314
import {
1415
BookCheck,
1516
BookDashed,
@@ -18,9 +19,11 @@ import {
1819
ChevronsUpDown,
1920
Plus,
2021
Trash2,
22+
LucideLoader,
2123
} from 'lucide-vue-next'
2224
import { DateTime } from 'luxon'
23-
import { computed } from 'vue'
25+
import { computed, ref } from 'vue'
26+
import { toast } from 'vue-sonner'
2427
import { tuyau } from '~/lib/tuyau'
2528
import { enumKeys } from '~/lib/utils'
2629
@@ -29,6 +32,24 @@ const props = defineProps<{
2932
taxonomies: TaxonomyDto[]
3033
}>()
3134
35+
const r2DefaultCaptions = [
36+
{
37+
type: CaptionTypes.SRT,
38+
language: CaptionLanguages.ENGLISH,
39+
label: '',
40+
},
41+
{
42+
type: CaptionTypes.SRT,
43+
language: CaptionLanguages.SPANISH,
44+
label: '',
45+
},
46+
{
47+
type: CaptionTypes.SRT,
48+
language: CaptionLanguages.FRENCH,
49+
label: '',
50+
},
51+
]
52+
3253
const form = useForm({
3354
title: props.post?.title ?? '',
3455
slug: props.post?.slug ?? '',
@@ -63,6 +84,8 @@ const form = useForm({
6384
taxonomyIds: props.post?.taxonomyIds ?? [],
6485
})
6586
87+
const isGeneratingChapters = ref(false)
88+
6689
const publishAt = computed(() => {
6790
const iso = [form.publishAtDate, form.publishAtTime].filter(Boolean).join('T')
6891
return iso && DateTime.fromISO(iso)
@@ -81,6 +104,29 @@ function secondsToTimecode(seconds: number) {
81104
.join(':')
82105
}
83106
107+
async function generateVideoChapters() {
108+
if (form.chapters.length || !form.videoUrl) return
109+
110+
isGeneratingChapters.value = true
111+
112+
try {
113+
const { data } = await axios.post(`/ai/videos/${form.videoUrl}/chapters`)
114+
115+
if (Array.isArray(data.result.response)) {
116+
form.chapters = data.result.response
117+
118+
toast.success('Chapters generated successfully')
119+
} else {
120+
toast.error('Chapters were generated successfully, but came back in an unexpected format')
121+
}
122+
} catch (error) {
123+
console.error(error)
124+
toast.error('Failed to generate chapters')
125+
}
126+
127+
isGeneratingChapters.value = false
128+
}
129+
84130
function onSubmit(stateId: States = Number(form.stateId)) {
85131
const action = form.transform((data) => {
86132
data.stateId = stateId
@@ -93,6 +139,13 @@ function onSubmit(stateId: States = Number(form.stateId)) {
93139
94140
action.post(tuyau.$url('posts.store'))
95141
}
142+
143+
function onVideoTypeChanged(videoTypeId: string) {
144+
if (form.chapters.length) return
145+
if (videoTypeId !== VideoTypes.R2.toString()) return
146+
147+
form.captions = r2DefaultCaptions.map((r) => ({ ...r }))
148+
}
96149
</script>
97150

98151
<template>
@@ -317,6 +370,7 @@ function onSubmit(stateId: States = Number(form.stateId)) {
317370
v-model="form.videoTypeId"
318371
placeholder="Have a video? Where is it stored?"
319372
:errors="form.errors.videoTypeId"
373+
@update:modelValue="onVideoTypeChanged"
320374
>
321375
<SelectItem v-for="id in VideoTypesOrdered" :key="id" :value="id.toString()">
322376
{{ VideoTypeDesc[id] }}
@@ -348,6 +402,7 @@ function onSubmit(stateId: States = Number(form.stateId)) {
348402
v-model="form.videoUrl"
349403
:max="255"
350404
placeholder="Enter the R2 Video Id (directory name)"
405+
@blur="generateVideoChapters"
351406
/>
352407

353408
<FormInput
@@ -427,7 +482,15 @@ function onSubmit(stateId: States = Number(form.stateId)) {
427482
v-if="Number(form.videoTypeId) === VideoTypes.R2"
428483
class="-mx-4 p-4 border border-slate-300 rounded-lg"
429484
>
430-
<legend class="-mx-2 px-2">Chapters</legend>
485+
<legend class="-mx-2 px-2">
486+
Chapters
487+
<span
488+
v-show="isGeneratingChapters"
489+
class="text-slate-400 inline-flex items-center gap-1.5"
490+
>
491+
[Generating <LucideLoader class="w-4 h-4 animate-spin" />]
492+
</span>
493+
</legend>
431494

432495
<div
433496
v-if="form.chapters"

start/env.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,7 @@ export default await Env.create(new URL('../', import.meta.url), {
8484
PLOTMYCOURSE_API_KEY: Env.schema.string.optional(),
8585

8686
R2_SIGNING_KEY: Env.schema.string(),
87+
88+
AI_SEARCH_KEY: Env.schema.string.optional(),
89+
CLOUDFLARE_ACCOUNT_ID: Env.schema.string.optional(),
8790
})

start/routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import router from '@adonisjs/core/services/router'
1111
import { middleware } from '#start/kernel'
12+
const AiVideosController = () => import('#controllers/ai_videos_controller')
1213
const TaxonomyContentsController = () => import('#controllers/taxonomy_contents_controller')
1314
const CouponsController = () => import('#controllers/coupons_controller')
1415
const PlansController = () => import('#controllers/plans_controller')
@@ -107,4 +108,7 @@ router.group(() => {
107108
router.post('/coupons', [CouponsController, 'run']).as('coupons.run')
108109
router.delete('/coupons', [CouponsController, 'clear']).as('coupons.clear')
109110

111+
//* AI
112+
router.post('/ai/videos/:videoId/chapters', [AiVideosController, 'chapters']).as('ai.videos.chapters')
113+
110114
}).middleware([middleware.auth()])

0 commit comments

Comments
 (0)