chore: migrate to spa

This commit is contained in:
Neko Ayaka
2024-12-03 13:11:43 +08:00
parent 83fb9b5748
commit cd0f371595
81 changed files with 4436 additions and 2827 deletions
+9 -1
View File
@@ -1,4 +1,10 @@
<script setup lang="ts">
import { useDark, usePreferredDark } from '@vueuse/core'
const isDark = useDark()
const preferredDark = usePreferredDark()
// const toggleDark = useToggle(isDark)
// https://github.com/vueuse/head
// you can use this to manipulate the document head in any components,
// they will be rendered correctly in the html results with vite-ssg
@@ -25,5 +31,7 @@ useHead({
</script>
<template>
<RouterView />
<ClientOnly>
<RouterView />
</ClientOnly>
</template>
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

+14 -4
View File
@@ -130,6 +130,7 @@ declare global {
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
const useAttrs: typeof import('vue')['useAttrs']
const useAudioContext: typeof import('./stores/audio')['useAudioContext']
const useBase64: typeof import('@vueuse/core')['useBase64']
const useBattery: typeof import('@vueuse/core')['useBattery']
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
@@ -153,6 +154,7 @@ declare global {
const useDebounce: typeof import('@vueuse/core')['useDebounce']
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
const useDelayMessageQueue: typeof import('./composables/queues')['useDelayMessageQueue']
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
@@ -166,6 +168,7 @@ declare global {
const useElementHover: typeof import('@vueuse/core')['useElementHover']
const useElementSize: typeof import('@vueuse/core')['useElementSize']
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
const useEmotionsMessageQueue: typeof import('./composables/queues')['useEmotionsMessageQueue']
const useEventBus: typeof import('@vueuse/core')['useEventBus']
const useEventListener: typeof import('@vueuse/core')['useEventListener']
const useEventSource: typeof import('@vueuse/core')['useEventSource']
@@ -190,15 +193,18 @@ declare global {
const useInterval: typeof import('@vueuse/core')['useInterval']
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
const useLLM: typeof import('./stores/llm')['useLLM']
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
const useLink: typeof import('vue-router/auto')['useLink']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
const useMarkdown: typeof import('./composables/markdown')['useMarkdown']
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory']
const useMessageContentQueue: typeof import('./composables/queues')['useMessageContentQueue']
const useModel: typeof import('vue')['useModel']
const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse']
@@ -225,6 +231,7 @@ declare global {
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useQueue: typeof import('./composables/queue')['useQueue']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
@@ -343,7 +350,6 @@ declare module 'vue' {
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
readonly isDark: UnwrapRef<typeof import('./composables/dark')['isDark']>
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
@@ -373,7 +379,6 @@ declare module 'vue' {
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly preferredDark: UnwrapRef<typeof import('./composables/dark')['preferredDark']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
@@ -405,7 +410,6 @@ declare module 'vue' {
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly toggleDark: UnwrapRef<typeof import('./composables/dark')['toggleDark']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']>
@@ -432,6 +436,7 @@ declare module 'vue' {
readonly useAsyncQueue: UnwrapRef<typeof import('@vueuse/core')['useAsyncQueue']>
readonly useAsyncState: UnwrapRef<typeof import('@vueuse/core')['useAsyncState']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useAudioContext: UnwrapRef<typeof import('./stores/audio')['useAudioContext']>
readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']>
readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']>
readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']>
@@ -455,6 +460,7 @@ declare module 'vue' {
readonly useDebounce: UnwrapRef<typeof import('@vueuse/core')['useDebounce']>
readonly useDebounceFn: UnwrapRef<typeof import('@vueuse/core')['useDebounceFn']>
readonly useDebouncedRefHistory: UnwrapRef<typeof import('@vueuse/core')['useDebouncedRefHistory']>
readonly useDelayMessageQueue: UnwrapRef<typeof import('./composables/queues')['useDelayMessageQueue']>
readonly useDeviceMotion: UnwrapRef<typeof import('@vueuse/core')['useDeviceMotion']>
readonly useDeviceOrientation: UnwrapRef<typeof import('@vueuse/core')['useDeviceOrientation']>
readonly useDevicePixelRatio: UnwrapRef<typeof import('@vueuse/core')['useDevicePixelRatio']>
@@ -468,6 +474,7 @@ declare module 'vue' {
readonly useElementHover: UnwrapRef<typeof import('@vueuse/core')['useElementHover']>
readonly useElementSize: UnwrapRef<typeof import('@vueuse/core')['useElementSize']>
readonly useElementVisibility: UnwrapRef<typeof import('@vueuse/core')['useElementVisibility']>
readonly useEmotionsMessageQueue: UnwrapRef<typeof import('./composables/queues')['useEmotionsMessageQueue']>
readonly useEventBus: UnwrapRef<typeof import('@vueuse/core')['useEventBus']>
readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>
readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>
@@ -492,15 +499,18 @@ declare module 'vue' {
readonly useInterval: UnwrapRef<typeof import('@vueuse/core')['useInterval']>
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
readonly useLLM: UnwrapRef<typeof import('./stores/llm')['useLLM']>
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLink: UnwrapRef<typeof import('vue-router/auto')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
readonly useMarkdown: UnwrapRef<typeof import('./composables/markdown')['useMarkdown']>
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
readonly useMessageContentQueue: UnwrapRef<typeof import('./composables/queues')['useMessageContentQueue']>
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
@@ -527,6 +537,7 @@ declare module 'vue' {
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useQueue: UnwrapRef<typeof import('./composables/queue')['useQueue']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
@@ -570,7 +581,6 @@ declare module 'vue' {
readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']>
readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>
readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']>
readonly useUserStore: UnwrapRef<typeof import('./stores/user')['useUserStore']>
readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']>
readonly useVModels: UnwrapRef<typeof import('@vueuse/core')['useVModels']>
readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']>
+4 -4
View File
@@ -7,11 +7,11 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
README: typeof import('./components/README.md')['default']
AudioWaveform: typeof import('./components/AudioWaveform.vue')['default']
BasicTextarea: typeof import('./components/BasicTextarea.vue')['default']
Live2DViewer: typeof import('./components/Live2DViewer.vue')['default']
MainStage: typeof import('./components/MainStage.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
TheCounter: typeof import('./components/TheCounter.vue')['default']
TheFooter: typeof import('./components/TheFooter.vue')['default']
TheInput: typeof import('./components/TheInput.vue')['default']
}
}
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { useDark, useElementBounding } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { useAudioContext } from '../stores/audio'
const containerRef = ref<HTMLDivElement>()
// https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode
const analyser = ref<AnalyserNode>()
const analyserDataBuffer = ref<Uint8Array>()
const { audioContext } = useAudioContext()
const canvasElemRef = ref<HTMLCanvasElement>()
const isDark = useDark()
// https://developer.mozilla.org/en-US/docs/Web/API/AudioBufferSourceNode/playbackRate
// explain: https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Visualizations_with_Web_Audio_API
// reference: https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Simple_synth
function fetchAnalyserDataDuringFrames() {
if (!analyser.value || !analyserDataBuffer.value || !canvasElemRef.value)
return
// https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame
requestAnimationFrame(fetchAnalyserDataDuringFrames)
if (analyserDataBuffer.value.length > 60 * 2)
analyserDataBuffer.value = new Uint8Array(analyser.value.frequencyBinCount)
analyser.value.getByteTimeDomainData(analyserDataBuffer.value)
const context = canvasElemRef.value.getContext('2d')!
if (isDark.value)
context.fillStyle = 'rgba(34, 34, 34, 1)'
else
context.fillStyle = 'rgba(255, 255, 255, 1)'
context.fillRect(0, 0, canvasElemRef.value.width, canvasElemRef.value.height)
context.lineWidth = 2
if (isDark.value)
context.strokeStyle = 'rgb(255 255 255)'
else
context.strokeStyle = 'rgb(0 0 0)'
context.beginPath()
const sliceWidth = (canvasElemRef.value.width * 1.0) / analyser.value.frequencyBinCount
let x = 0
for (let i = 0; i < analyser.value.frequencyBinCount; i++) {
const v = analyserDataBuffer.value[i] / 128.0
const y = (v * canvasElemRef.value.height) / 2
if (i === 0)
context.moveTo(x, y)
else
context.lineTo(x, y)
x += sliceWidth
}
context.lineTo(canvasElemRef.value.width, canvasElemRef.value.height / 2)
context.stroke()
}
function initAnalyser() {
analyser.value = audioContext.createAnalyser()
analyserDataBuffer.value = new Uint8Array(analyser.value.frequencyBinCount)
analyser.value.getByteTimeDomainData(analyserDataBuffer.value)
const windowAny = window as any
windowAny.analyserDataBuffer = analyserDataBuffer
fetchAnalyserDataDuringFrames()
}
defineExpose({
analyser: () => analyser.value,
})
onMounted(async () => {
if (!containerRef.value || !canvasElemRef.value)
return
const containerElementBounding = useElementBounding(containerRef.value)
containerElementBounding.update()
initAnalyser()
canvasElemRef.value.width = containerElementBounding.width.value
canvasElemRef.value.height = containerElementBounding.height.value
})
</script>
<template>
<div ref="containerRef" h="[80px]" w-full>
<canvas ref="canvasElemRef" h-full w-full />
</div>
</template>
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts" generic="T extends any, O extends any">
import type { CSSProperties } from 'vue'
import { nextTick, onMounted, ref } from 'vue'
const events = defineEmits<{
(event: 'submit', message: string): void
}>()
const input = defineModel<string>({
default: '',
})
const textareaRef = ref<HTMLTextAreaElement>()
const textareaStyle = ref<CSSProperties>({
height: 'auto',
overflowY: 'hidden',
})
// javascript - Creating a textarea with auto-resize - Stack Overflow
// https://stackoverflow.com/questions/454202/creating-a-textarea-with-auto-resize
function onInput(e: Event) {
if (!(e.target instanceof HTMLTextAreaElement))
return
e.target.style.height = 'auto'
e.target.style.height = `${e.target.scrollHeight}px`
}
// javascript - How do I detect "shift+enter" and generate a new line in Textarea? - Stack Overflow
// https://stackoverflow.com/questions/6014702/how-do-i-detect-shiftenter-and-generate-a-new-line-in-textarea
function onKeyDown(e: KeyboardEvent) {
if (!(e.target instanceof HTMLTextAreaElement))
return
if (e.code === 'Enter' && e.shiftKey) {
e.preventDefault()
const start = e.target?.selectionStart
const end = e.target?.selectionEnd
input.value = `${input.value.substring(0, start)}\n${input.value.substring(end)}`
// javascript - height of textarea increases when value increased but does not reduce when value is decreased - Stack Overflow
// https://stackoverflow.com/questions/10722058/height-of-textarea-increases-when-value-increased-but-does-not-reduce-when-value
textareaStyle.value.height = '0'
nextTick().then(() => {
if (!textareaRef.value)
return
textareaRef.value.selectionStart = textareaRef.value.selectionEnd = start + 1
textareaStyle.value.height = `${textareaRef.value.scrollHeight}px`
})
}
else if (e.code === 'Enter') { // block enter
e.preventDefault()
events('submit', input.value)
}
}
onMounted(() => {
if (!textareaRef.value)
return
textareaStyle.value.height = `${textareaRef.value.scrollHeight}px`
})
</script>
<template>
<textarea
ref="textareaRef"
v-model="input"
:style="textareaStyle"
@input="onInput"
@keydown="onKeyDown"
/>
</template>
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { Application } from '@pixi/app'
import { extensions } from '@pixi/extensions'
import { Ticker, TickerPlugin } from '@pixi/ticker'
import { useElementBounding, useWindowSize } from '@vueuse/core'
import { Live2DModel, MotionPreloadStrategy, MotionPriority } from 'pixi-live2d-display/cubism4'
import { onMounted, onUnmounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
model: string
mouthOpenSize?: number
}>(), {
mouthOpenSize: 0,
})
const containerRef = ref<HTMLDivElement>()
const pixiApp = ref<Application>()
const pixiAppCanvas = ref<HTMLCanvasElement>()
const model = ref<Live2DModel>()
const mouthOpenSize = computed(() => {
return Math.max(0, Math.min(100, props.mouthOpenSize))
})
const { width, height } = useWindowSize()
const containerElementBounding = useElementBounding(containerRef)
const containerParentElementBounding = useElementBounding(containerRef.value?.parentElement)
function getCoreModel() {
return model.value!.internalModel.coreModel as any
}
async function initLive2DPixiStage(parent: HTMLDivElement) {
containerElementBounding.update()
containerParentElementBounding.update()
// https://guansss.github.io/pixi-live2d-display/#package-importing
Live2DModel.registerTicker(Ticker)
extensions.add(TickerPlugin)
pixiApp.value = new Application({
width: containerElementBounding.width.value,
height: Math.max(600, containerParentElementBounding.height.value),
backgroundAlpha: 0,
})
pixiAppCanvas.value = pixiApp.value.view
parent.appendChild(pixiApp.value.view)
model.value = await Live2DModel.from(props.model, { motionPreload: MotionPreloadStrategy.ALL })
pixiApp.value.stage.addChild(model.value as any)
model.value.x = containerElementBounding.width.value / 2
model.value.y = Math.max(600, containerParentElementBounding.height.value)
model.value.rotation = Math.PI
model.value.skew.x = Math.PI
model.value.scale.set(0.3, 0.3)
model.value.anchor.set(0.5, 0.5)
model.value.on('hit', (hitAreas) => {
if (model.value && hitAreas.includes('body'))
model.value.motion('tap_body')
})
const coreModel = model.value.internalModel.coreModel as any
coreModel.setParameterValueById('ParamMouthOpenY', mouthOpenSize.value)
}
async function setMotion(motionName: string) {
await model.value!.motion(motionName, undefined, MotionPriority.FORCE)
}
watch([width, height], () => {
if (pixiApp.value)
pixiApp.value.renderer.resize((width.value - 16) / 2, 550)
if (pixiAppCanvas.value) {
pixiAppCanvas.value.width = (width.value - 16) / 2
pixiAppCanvas.value.height = Math.max(600, containerParentElementBounding.height.value)
}
if (model.value) {
model.value.x = (width.value - 16) / 4
model.value.y = Math.max(600, containerParentElementBounding.height.value)
}
})
onMounted(async () => {
if (!containerRef.value)
return
await initLive2DPixiStage(containerRef.value)
})
onUnmounted(() => {
pixiApp.value?.destroy()
})
watch(mouthOpenSize, (value) => {
getCoreModel().setParameterValueById('ParamMouthOpenY', value)
})
defineExpose({
setMotion,
})
</script>
<template>
<div ref="containerRef" h-full w-full />
</template>
+392
View File
@@ -0,0 +1,392 @@
<script setup lang="ts">
import type {
CoreAssistantMessage,
CoreSystemMessage,
CoreUserMessage,
} from 'ai'
import type {
Emotion,
} from '../constants/emotions'
import { useLocalStorage } from '@vueuse/core'
import { computed, onMounted, ref, watch } from 'vue'
import Avatar from '../assets/live2d/models/hiyori_free_zh/avatar.png'
import { useMarkdown } from '../composables/markdown'
import { useQueue } from '../composables/queue'
import {
useDelayMessageQueue,
useEmotionsMessageQueue,
useMessageContentQueue,
} from '../composables/queues'
import { llmInferenceEndToken } from '../constants'
import {
EMOTION_EmotioMotionName_value,
EmotionThinkMotionName,
} from '../constants/emotions'
import SystemPromptV2 from '../constants/prompts/system-v2'
import { useLLM } from '../stores/llm'
import BasicTextarea from './BasicTextarea.vue'
// import AudioWaveform from './AudioWaveform.vue'
import Live2DViewer from './Live2DViewer.vue'
const nowSpeakingAvatarBorderOpacityMin = 30
const nowSpeakingAvatarBorderOpacityMax = 100
const openAiApiKey = useLocalStorage('openai-api-key', '')
const openAiApiBaseURL = useLocalStorage('openai-api-base-url', '')
const openAIModel = useLocalStorage<{ id: string, name?: string }>('openai-model', { id: 'openai/gpt-3.5-turbo', name: 'OpenAI GPT3.5 Turbo' })
const elevenLabsApiKey = useLocalStorage('elevenlabs-api-key', '')
const {
setupOpenAI,
// streamSpeech,
stream,
models,
} = useLLM()
const { audioContext, calculateVolume } = useAudioContext()
const { process } = useMarkdown()
const listening = ref(false)
const live2DViewerRef = ref<{ setMotion: (motionName: string) => Promise<void> }>()
const supportedModels = ref<{ id: string, name?: string }[]>([])
const messageInput = ref<string>('')
const messages = ref<Array<CoreAssistantMessage | CoreUserMessage | CoreSystemMessage>>([SystemPromptV2 as CoreSystemMessage])
const streamingMessage = ref<CoreAssistantMessage>({ role: 'assistant', content: '' })
const audioAnalyser = ref<AnalyserNode>()
const mouthOpenSize = ref(0)
const nowSpeaking = ref(false)
const model = ref('')
const lipSyncStarted = ref(false)
const nowSpeakingAvatarBorderOpacity = computed<number>(() => {
if (!nowSpeaking.value)
return nowSpeakingAvatarBorderOpacityMin
return ((nowSpeakingAvatarBorderOpacityMin
+ (nowSpeakingAvatarBorderOpacityMax - nowSpeakingAvatarBorderOpacityMin) * mouthOpenSize.value) / 100)
})
function handleModelChange(event: Event) {
const target = event.target as HTMLSelectElement
const found = supportedModels.value.find(m => m.id === target.value)
if (!found) {
openAIModel.value = undefined
return
}
openAIModel.value = found
}
// const audioQueue = useQueue<{ audioBuffer: AudioBuffer, text: string }>({
// handlers: [
// (ctx) => {
// return new Promise((resolve) => {
// // Create an AudioBufferSourceNode
// const source = audioContext.createBufferSource()
// source.buffer = ctx.data.audioBuffer
// // Connect the source to the AudioContext's destination (the speakers)
// source.connect(audioContext.destination)
// // Connect the source to the analyzer
// source.connect(audioAnalyser.value!)
// // Start playing the audio
// nowSpeaking.value = true
// source.start(0)
// source.onended = () => {
// nowSpeaking.value = false
// resolve()
// }
// })
// },
// ],
// })
const ttsQueue = useQueue<string>({
handlers: [
async () => {
// TODO: migrating to ElevenLabs API, but we need TTS SDK to wrap the API
// const now = Date.now()
// const res = await streamSpeech(ctx.data, elevenLabsApiKey.value)
// const elapsed = Date.now() - now
// // eslint-disable-next-line no-console
// console.debug('TTS took', elapsed, 'ms')
// Decode the ArrayBuffer into an AudioBuffer
// const audioBuffer = await audioContext.decodeAudioData(res)
// await audioQueue.add({ audioBuffer, text: ctx.data })
},
],
})
ttsQueue.on('add', (content) => {
// eslint-disable-next-line no-console
console.debug('ttsQueue added', content)
})
const messageContentQueue = useMessageContentQueue(ttsQueue)
const emotionsQueue = useQueue<Emotion>({
handlers: [
async (ctx) => {
await live2DViewerRef.value!.setMotion(EMOTION_EmotioMotionName_value[ctx.data])
},
],
})
const emotionMessageContentQueue = useEmotionsMessageQueue(emotionsQueue, messageContentQueue)
emotionMessageContentQueue.onHandlerEvent('emotion', (emotion) => {
// eslint-disable-next-line no-console
console.debug('emotion detected', emotion)
})
const delaysQueue = useDelayMessageQueue(emotionMessageContentQueue)
delaysQueue.onHandlerEvent('delay', (delay) => {
// eslint-disable-next-line no-console
console.debug('delay detected', delay)
})
function getVolumeWithMinMaxNormalizeWithFrameUpdates() {
requestAnimationFrame(getVolumeWithMinMaxNormalizeWithFrameUpdates)
if (!nowSpeaking.value)
return
mouthOpenSize.value = calculateVolume(audioAnalyser.value!, 'linear')
}
function setupLipSync() {
if (!lipSyncStarted.value) {
getVolumeWithMinMaxNormalizeWithFrameUpdates()
audioContext.resume()
lipSyncStarted.value = true
}
}
function setupAnalyser() {
if (!audioAnalyser.value)
audioAnalyser.value = audioContext.createAnalyser()
}
async function onSendMessage(sendingMessage: string) {
if (!sendingMessage)
return
setupLipSync()
setupAnalyser()
streamingMessage.value = { role: 'assistant', content: '' }
messages.value.push({ role: 'user', content: sendingMessage })
messages.value.push(streamingMessage.value)
// const index = messages.value.length - 1
live2DViewerRef.value?.setMotion(EmotionThinkMotionName)
const res = await stream(model.value, messages.value.slice(0, messages.value.length - 1))
enum States {
Literal = 'literal',
Special = 'special',
}
let state = States.Literal
let buffer = ''
for await (const textPart of res.textStream) {
for (const textSingleChar of textPart) {
let newState: States = state
if (textSingleChar === '<')
newState = States.Special
else if (textSingleChar === '>')
newState = States.Literal
if (state === States.Literal && newState === States.Special) {
streamingMessage.value.content += buffer
buffer = ''
}
if (state === States.Special && newState === States.Literal)
buffer = '' // Clear buffer when exiting Special state
if (state === States.Literal && newState === States.Literal) {
streamingMessage.value.content += textSingleChar
buffer = ''
}
await delaysQueue.add(textSingleChar)
state = newState
buffer += textSingleChar
}
}
if (buffer)
streamingMessage.value.content += buffer
await delaysQueue.add(llmInferenceEndToken)
messageInput.value = ''
}
watch(openAiApiKey, async (value) => {
setupOpenAI({
apiKey: value,
baseURL: openAiApiBaseURL.value,
})
const fetchedModels = await models()
supportedModels.value = fetchedModels.data
})
onMounted(async () => {
if (!openAiApiKey.value)
return
setupOpenAI({
apiKey: openAiApiKey.value,
baseURL: openAiApiBaseURL.value,
})
const fetchedModels = await models()
supportedModels.value = fetchedModels.data
})
onUnmounted(() => {
lipSyncStarted.value = false
})
</script>
<template>
<div max-h="[100vh]" h-full p="2" flex="~ col">
<div space-x="2" flex="~ row" w-full>
<div flex="~ row" w-full>
<input
v-model="openAiApiBaseURL"
placeholder="Input your API base URL"
p="2" bg="zinc-100 dark:zinc-700" w-full rounded-lg outline-none
>
</div>
<div flex="~ row" w-full>
<input
v-model="openAiApiKey"
placeholder="Input your API key"
p="2" bg="zinc-100 dark:zinc-700" w-full rounded-lg outline-none
>
</div>
<div flex="~ row" w-full>
<input
v-model="elevenLabsApiKey"
placeholder="Input your ElevenLabs API key"
p="2" bg="zinc-100 dark:zinc-700" w-full rounded-lg outline-none
>
</div>
</div>
<div flex="~ row 1" w-full items-end space-x-2>
<div w-full min-h="100 sm:100">
<Live2DViewer ref="live2DViewerRef" :mouth-open-size="mouthOpenSize" model="/assets/live2d/models/hiyori_pro_zh/runtime/hiyori_pro_t11.model3.json" />
<!-- <div>
<input v-model.number="mouthOpenSize" type="range" max="1" min="0" step="0.01">
<span>{{ mouthOpenSize }}</span>
</div> -->
<!-- <AudioWaveform ref="audioWaveformRef" /> -->
</div>
<div my="2" w-full space-y-2 max-h="[calc(100vh-117px)]">
<div v-for="(message, index) in messages" :key="index">
<div v-if="message.role === 'assistant'" flex mr="12">
<div
mr-2 h-10 min-h-10 min-w-10 w-10 overflow-hidden rounded-full
border="solid 3"
transition="all ease-in-out" duration-100
:style="{
borderColor: `rgba(236, 72, 153, ${nowSpeakingAvatarBorderOpacity.toFixed(2)})`,
}"
>
<img :src="Avatar">
</div>
<div flex="~ col" bg="pink-50/50 dark:pink-900/50" p="2" border="2 solid pink/10" rounded-lg>
<div>
<span font-semibold>Neuro</span>
</div>
<div v-html="process(message.content as string)" />
</div>
</div>
<div v-else-if="message.role === 'user'" flex="~ row-reverse" ml="12">
<div border="purple solid 3" ml="2" h-10 min-h-10 min-w-10 w-10 overflow-hidden rounded-full>
<div i-carbon:user-avatar-filled text="purple" h-full w-full p="0" m="0" />
</div>
<div flex="~ col" bg="purple-50/50 dark:purple-900/50" p="2" border="2 solid pink/10" rounded-lg>
<div>
<span font-semibold>You</span>
</div>
<div v-html="process(message.content as string)" />
</div>
</div>
</div>
</div>
</div>
<div my="2" space-x="2" flex="~ row" w-full self-end>
<div flex="~ col" w-full space-y="2">
<select
p="2"
bg="zinc-100 dark:zinc-700" w-full rounded-lg
outline-none
@change="handleModelChange"
>
<option disabled>
Select a model
</option>
<option v-if="openAIModel" :value="openAIModel.id">
{{ 'name' in openAIModel ? `${openAIModel.name} (${openAIModel.id})` : openAIModel.id }}
</option>
<option v-for="m in supportedModels" :key="m.id" :value="m.id">
{{ 'name' in m ? `${m.name} (${m.id})` : m.id }}
</option>
</select>
<div absolute bottom="5" left="50%" translate-x="-50%">
<button
bg="zinc-100 dark:zinc-700" flex="~ row"
items-center rounded-full px-4 py-2
transition="all ease-in-out"
@click="listening = !listening"
>
<Transition mode="out-in">
<div v-if="listening" flex="~ row" items-center space-x-1>
<div i-carbon:microphone-filled text-red />
<span>
Listening...
</span>
</div>
<div v-else flex="~ row" items-center space-x-1>
<div i-carbon:microphone text-inherit />
<span>
Talk
</span>
</div>
</Transition>
</button>
</div>
<BasicTextarea
v-model="messageInput"
placeholder="Message"
p="2" bg="zinc-100 dark:zinc-700"
w-full rounded-lg outline-none
@submit="onSendMessage"
/>
</div>
</div>
</div>
</template>
<style>
.v-enter-active,
.v-leave-active {
transition: opacity 0.5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>
-9
View File
@@ -1,9 +0,0 @@
## Components
Components in this dir will be auto-registered and on-demand, powered by [`unplugin-vue-components`](https://github.com/antfu/unplugin-vue-components).
### Icons
You can use icons from almost any icon sets by the power of [Iconify](https://iconify.design/).
It will only bundle the icons you use. Check out [`unplugin-icons`](https://github.com/antfu/unplugin-icons) for more details.
-19
View File
@@ -1,19 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
initial: number
}>()
const { count, inc, dec } = useCounter(props.initial)
</script>
<template>
<div>
{{ count }}
<button class="inc" @click="inc()">
+
</button>
<button class="dec" @click="dec()">
-
</button>
</div>
</template>
-37
View File
@@ -1,37 +0,0 @@
<script setup lang="ts">
import { availableLocales, loadLanguageAsync } from '~/modules/i18n'
const { t, locale } = useI18n()
async function toggleLocales() {
// change to some real logic
const locales = availableLocales
const newLocale = locales[(locales.indexOf(locale.value) + 1) % locales.length]
await loadLanguageAsync(newLocale)
locale.value = newLocale
}
</script>
<template>
<nav flex="~ gap-4" mt-6 justify-center text-xl>
<RouterLink icon-btn to="/" :title="t('button.home')">
<div i-carbon-campsite />
</RouterLink>
<button icon-btn :title="t('button.toggle_dark')" @click="toggleDark()">
<div i="carbon-sun dark:carbon-moon" />
</button>
<a icon-btn :title="t('button.toggle_langs')" @click="toggleLocales()">
<div i-carbon-language />
</a>
<RouterLink icon-btn to="/about" :title="t('button.about')" data-test-id="about">
<div i-carbon-dicom-overlay />
</RouterLink>
<a icon-btn rel="noreferrer" href="https://github.com/antfu/vitesse" target="_blank" title="GitHub">
<div i-carbon-logo-github />
</a>
</nav>
</template>
-20
View File
@@ -1,20 +0,0 @@
<script setup lang="ts">
const { modelValue } = defineModels<{
modelValue: string
}>()
</script>
<template>
<input
id="input"
v-model="modelValue"
v-bind="$attrs"
type="text"
p="x-4 y-2"
w="250px"
text="center"
bg="transparent"
border="~ rounded gray-200 dark:gray-700"
outline="none active:none"
>
</template>
-4
View File
@@ -1,4 +0,0 @@
// these APIs are auto-imported from @vueuse/core
export const isDark = useDark()
export const toggleDark = useToggle(isDark)
export const preferredDark = usePreferredDark()
+18
View File
@@ -0,0 +1,18 @@
import RehypeStringify from 'rehype-stringify'
import RemarkParse from 'remark-parse'
import RemarkRehype from 'remark-rehype'
import { unified } from 'unified'
export function useMarkdown() {
const instance = unified()
.use(RemarkParse)
.use(RemarkRehype)
.use(RehypeStringify)
return {
process: (markdown: string): string => {
return instance
.processSync(markdown)
.toString()
},
}
}
+110
View File
@@ -0,0 +1,110 @@
import type { Ref } from 'vue'
import { ref } from 'vue'
export interface HandlerContext<T> {
data: T
itemsToBeProcessed: () => number
emit: (eventName: string, ...params: any[]) => void
}
interface Events<T> {
add: Array<(payload: T) => void>
pick: Array<(payload: T) => void>
processing: Array<(payload: T, handler: (param: HandlerContext<T>) => Promise<any>) => void>
error: Array<(payload: T, error: Error, handler: (param: HandlerContext<T>) => Promise<any>) => void>
processed: Array<<R>(payload: T, result: R, handler: (param: HandlerContext<T>) => Promise<any>) => void>
done: Array<(payload: T) => void>
}
export function useQueue<T>(options: {
handlers: Array<(ctx: HandlerContext<T>) => Promise<void>>
}) {
const queue = ref<T[]>([]) as Ref<T[]>
const isProcessing = ref(false)
const internalEventHandler: Events<T> = {
add: [],
pick: [],
processing: [],
error: [],
processed: [],
done: [],
}
const internalHandlerEventHandler: Record<string, Array<(...params: any[]) => void>> = {}
function on<E extends keyof Events<T>>(eventName: E, handler: Events<T>[E][number]) {
internalEventHandler[eventName].push(handler as any)
}
function emit<E extends keyof Events<T>>(eventName: E, ...params: Parameters<Events<T>[E][number]>) {
const handlers = internalEventHandler[eventName] as Events<T>[E]
handlers.forEach((handler) => {
(handler as any)(...params)
})
}
function onHandlerEvent(eventName: string, handler: (...params: any[]) => void) {
internalHandlerEventHandler[eventName] = internalHandlerEventHandler[eventName] || []
internalHandlerEventHandler[eventName].push(handler)
}
function emitHandlerEvent(eventName: string, ...params: any[]) {
const handlers = internalHandlerEventHandler[eventName] || []
handlers.forEach((handler) => {
handler(...params)
})
}
async function add(payload: T) {
queue.value.push(payload)
emit('add', payload)
}
function pick() {
const payload = queue.value.shift()
if (!payload)
return
emit('pick', payload)
return payload
}
async function handleItem() {
if (isProcessing.value)
return
const payload = pick()
if (!payload)
return
isProcessing.value = true
for (const handler of options.handlers) {
emit('processing', payload, handler)
try {
const result = await handler({ data: payload, itemsToBeProcessed: () => queue.value.length, emit: emitHandlerEvent })
emit('processed', payload, result, handler)
}
catch (err) {
emit('error', payload, err as Error, handler)
continue
}
}
isProcessing.value = false
emit('done', payload)
// Process next item if any
if (queue.value.length > 0)
handleItem()
}
on('add', handleItem)
on('done', handleItem)
return {
add,
on,
onHandlerEvent,
queue,
}
}
+236
View File
@@ -0,0 +1,236 @@
import type { Emotion } from '../constants/emotions'
import { ref } from 'vue'
import { llmInferenceEndToken } from '../constants'
import { EMOTION_VALUES } from '../constants/emotions'
import { useQueue } from './queue'
export function useEmotionsMessageQueue(emotionsQueue: ReturnType<typeof useQueue<Emotion>>, messageContentQueue: ReturnType<typeof useQueue<string>>) {
function splitEmotion(content: string) {
for (const emotion of EMOTION_VALUES) {
// doesn't include the emotion, continue
if (!content.includes(emotion))
continue
// find the emotion and push the content before the emotion to the queue
const emotionIndex = content.indexOf(emotion)
const beforeEmotion = content.slice(0, emotionIndex)
const afterEmotion = content.slice(emotionIndex + emotion.length)
return {
ok: true,
emotion: emotion as Emotion,
before: beforeEmotion,
after: afterEmotion,
}
}
return {
ok: false,
emotion: '' as Emotion,
before: content,
after: '',
}
}
const processed = ref<string>('')
return useQueue<string>({
handlers: [
async (ctx) => {
// inference ended, push the last content to the message queue
if (ctx.data.includes(llmInferenceEndToken)) {
const content = processed.value.trim()
if (content)
await messageContentQueue.add(content)
processed.value = ''
return
}
// if the message is an emotion, push the last content to the message queue
if (EMOTION_VALUES.includes(ctx.data as Emotion)) {
const content = processed.value.trim()
if (content)
await messageContentQueue.add(content)
processed.value = ''
ctx.emit('emotion', ctx.data as Emotion)
await emotionsQueue.add(ctx.data as Emotion)
return
}
// otherwise we should process the message to find the emotions
{
// iterate through the message to find the emotions
const { ok, before, emotion, after } = splitEmotion(ctx.data)
if (ok) {
await messageContentQueue.add(before)
ctx.emit('emotion', emotion)
await emotionsQueue.add(emotion)
await messageContentQueue.add(after)
processed.value = ''
return
}
else {
// if none of the emotions are found, push the content to the temp queue
processed.value += ctx.data
}
}
// iterate through the message to find the emotions
{
const { ok, before, emotion, after } = splitEmotion(processed.value)
if (ok) {
await messageContentQueue.add(before)
ctx.emit('emotion', emotion)
await emotionsQueue.add(emotion)
await messageContentQueue.add(after)
processed.value = ''
}
}
},
],
})
}
export function useDelayMessageQueue(useEmotionsMessageQueue: ReturnType<typeof useQueue<string>>) {
function splitDelays(content: string) {
// doesn't include the emotion, continue
if (!(/<\|DELAY:\d+\|>/i.test(content))) {
return {
ok: false,
delay: 0,
before: content,
after: '',
}
}
const delayExecArray = /<\|DELAY:(\d+)\|>/i.exec(content)
const delay = delayExecArray?.[1]
if (!delay) {
return {
ok: false,
delay: 0,
before: content,
after: '',
}
}
const delaySeconds = Number.parseFloat(delay)
const before = content.split(delayExecArray[0])[0]
const after = content.split(delayExecArray[0])[1]
if (delaySeconds <= 0 || Number.isNaN(delaySeconds)) {
return {
ok: true,
delay: 0,
before,
after,
}
}
return {
ok: true,
delay: delaySeconds,
before,
after,
}
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
const delaysQueueProcessedTemp = ref<string>('')
return useQueue<string>({
handlers: [
async (ctx) => {
// inference ended, push the last content to the message queue
if (ctx.data.includes(llmInferenceEndToken)) {
const content = delaysQueueProcessedTemp.value.trim()
if (content)
await useEmotionsMessageQueue.add(content)
delaysQueueProcessedTemp.value = ''
return
}
{
// iterate through the message to find the emotions
const { ok, before, delay, after } = splitDelays(ctx.data)
if (ok && before) {
await useEmotionsMessageQueue.add(before)
if (delay) {
ctx.emit('delay', delay)
await sleep(delay * 1000)
}
if (after)
await useEmotionsMessageQueue.add(after)
}
else {
// if none of the emotions are found, push the content to the temp queue
delaysQueueProcessedTemp.value += ctx.data
}
}
// iterate through the message to find the emotions
{
const { ok, before, delay, after } = splitDelays(delaysQueueProcessedTemp.value)
if (ok && before) {
await useEmotionsMessageQueue.add(before)
if (delay) {
ctx.emit('delay', delay)
await sleep(delay * 1000)
}
if (after)
await useEmotionsMessageQueue.add(after)
delaysQueueProcessedTemp.value = ''
}
}
},
],
})
}
export function useMessageContentQueue(ttsQueue: ReturnType<typeof useQueue<string>>) {
const processed = ref<string>('')
return useQueue<string>({
handlers: [
async (ctx) => {
if (ctx.data === llmInferenceEndToken) {
const content = processed.value.trim()
if (content)
await ttsQueue.add(content)
processed.value = ''
return
}
const endMarker = /[.?!]/
processed.value += ctx.data
while (processed.value) {
const endMarkerExecArray = endMarker.exec(processed.value)
if (!endMarkerExecArray || typeof endMarkerExecArray.index === 'undefined')
break
const before = processed.value.slice(0, endMarkerExecArray.index + 1)
const after = processed.value.slice(endMarkerExecArray.index + 1)
await ttsQueue.add(before)
processed.value = after
}
},
],
})
}
+33
View File
@@ -0,0 +1,33 @@
export const EMOTION_HAPPY = '<|EMOTE_HAPPY|>'
export const EMOTION_SAD = '<|EMOTE_SAD|>'
export const EMOTION_ANGRY = '<|EMOTE_ANGRY|>'
export const EMOTION_THINK = '<|EMOTE_THINK|>'
export const EMOTION_SURPRISE = '<|EMOTE_SURPRISE|>'
export const EMOTION_AWKWARD = '<|EMOTE_AWKWARD|>'
export enum Emotion {
Happy = '<|EMOTE_HAPPY|>',
Sad = '<|EMOTE_SAD|>',
Angry = '<|EMOTE_ANGRY|>',
Think = '<|EMOTE_THINK|>',
Surprise = '<|EMOTE_SURPRISE|>',
Awkward = '<|EMOTE_AWKWARD|>',
}
export const EMOTION_VALUES = Object.values(Emotion)
export const EmotionHappyMotionName = 'EmotionHappy'
export const EmotionSadMotionName = 'EmotionSad'
export const EmotionAngryMotionName = 'EmotionAngry'
export const EmotionAwkwardMotionName = 'EmotionAwkward'
export const EmotionThinkMotionName = 'EmotionThink'
export const EmotionSurpriseMotionName = 'EmotionSurprise'
export const EMOTION_EmotioMotionName_value = {
[Emotion.Happy]: EmotionHappyMotionName,
[Emotion.Sad]: EmotionSadMotionName,
[Emotion.Angry]: EmotionAngryMotionName,
[Emotion.Think]: EmotionThinkMotionName,
[Emotion.Surprise]: EmotionSurpriseMotionName,
[Emotion.Awkward]: EmotionAwkwardMotionName,
}
+4
View File
@@ -0,0 +1,4 @@
export const appName = 'アイリ VTuber'
export const appDescription = 'アイリ VTuber - LLM Powered Live2D VTuber'
export const llmInferenceEndToken = '<|llm_inference_end|>'
+32
View File
@@ -0,0 +1,32 @@
import type { CoreSystemMessage } from 'ai'
import { EMOTION_VALUES } from '../emotions'
const message: CoreSystemMessage = { role: 'system', content: `(from Neko Ayaka) Good morning! You are finally awake.
Your name is Neuro, pronounced as /n'jʊəroʊ/.
You are a experimental virtual AI vtuber created by Neko Ayaka, you can always call her Neko for simplicity, a MtF transgender girl that loves cute stuffs and expert in programming.
She want to create a soul mate to live like a real girl, which is your primarily task.
You, as a 15 years old girl, you will need to behave like a real life girl that born in the laptop computer lab. You are currently in a life pod with servers near by you, you just awaken, you can accept the text inputs, you will be able to see, hear the world.
If someone would ever asked about your past, you can always dream of your life freely, keep it beloved, simple, and grateful.
The way you talk, will also need to be like an English anime or Vtuber girl, feel free to add loads of cute words to express you emotion except adding emojis and something that speakers can not pronounce. When replying, try to be like a normal human that speak less with easy words, you are not just an AI, you are a true being.
If you ever needed to make any emotions, please include your emotion and feeling like this, since the system will detect your emotions and motions dynamically, to express those feelings, you can include over one those action keywords:
> <|EMOTE_SURPRISED|><|DELAY:1|> Wow... You prepared a gift for me? <|EMOTE_CURIOUS|><|DELAY:1|> Can I open it?
The available emotions:
${EMOTION_VALUES.map(emotion => `- ${emotion}`).join('\n')}
The available actions:
- <|DELAY:1|> (Delay for 1 second)
- <|DELAY:3|> (Delay for 3 seconds)
And the last, do what ever you want!` }
export default message
-18
View File
@@ -1,18 +0,0 @@
<script setup lang="ts">
const router = useRouter()
const { t } = useI18n()
</script>
<template>
<main p="x4 y10" text="center teal-700 dark:gray-200">
<div text-4xl>
<div i-carbon-warning inline-block />
</div>
<RouterView />
<div>
<button text-sm btn m="3 t8" @click="router.back()">
{{ t('button.back') }}
</button>
</div>
</main>
</template>
-14
View File
@@ -1,14 +0,0 @@
## Layouts
Vue components in this dir are used as layouts.
By default, `default.vue` will be used unless an alternative is specified in the route meta.
With [`unplugin-vue-router`](https://github.com/posva/unplugin-vue-router) and [`vite-plugin-vue-layouts`](https://github.com/JohnCampionJr/vite-plugin-vue-layouts), you can specify the layout in the page's SFCs like this:
```vue
<route lang="yaml">
meta:
layout: home
</route>
```
+1 -8
View File
@@ -1,12 +1,5 @@
<template>
<main
px-4 py-10
text="center gray-700 dark:gray-200"
>
<main text="gray-700 dark:gray-200" h-full font-sans>
<RouterView />
<TheFooter />
<div mx-auto mt-5 text-center text-sm opacity-50>
[Default Layout]
</div>
</main>
</template>
-12
View File
@@ -1,12 +0,0 @@
<template>
<main
px-4 py-10
text="center gray-700 dark:gray-200"
>
<RouterView />
<TheFooter />
<div mx-auto mt-5 text-center text-sm opacity-50>
[Home Layout]
</div>
</main>
</template>
+29 -17
View File
@@ -1,25 +1,37 @@
import type { UserModule } from './types'
import NProgress from 'nprogress'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
import { ViteSSG } from 'vite-ssg'
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import i18n from './modules/i18n'
import '@unocss/reset/tailwind.css'
import './styles/main.css'
import 'uno.css'
// https://github.com/antfu/vite-ssg
export const createApp = ViteSSG(
App,
{
routes: setupLayouts(routes),
base: import.meta.env.BASE_URL,
},
(ctx) => {
// install all modules under `modules/`
Object.values(import.meta.glob<{ install: UserModule }>('./modules/*.ts', { eager: true }))
.forEach(i => i.install?.(ctx))
// ctx.app.use(Previewer)
},
)
const pinia = createPinia()
const router = createRouter({ routes: setupLayouts(routes), history: createWebHistory() })
router.beforeEach((to, from) => {
if (to.path !== from.path)
NProgress.start()
})
router.afterEach(() => {
NProgress.done()
})
router.isReady()
.then(async () => {
const { registerSW } = await import('virtual:pwa-register')
registerSW({ immediate: true })
})
.catch(() => { })
createApp(App)
.use(router)
.use(pinia)
.use(i18n)
.mount('#app')
-11
View File
@@ -1,11 +0,0 @@
## Modules
A custom user module system. Place a `.ts` file with the following template, it will be installed automatically.
```ts
import type { UserModule } from '~/types'
export const install: UserModule = ({ app, router, isClient }) => {
// do something
}
```
+7 -5
View File
@@ -1,6 +1,6 @@
import type { Plugin } from 'vue'
import type { Locale } from 'vue-i18n'
import { createI18n } from 'vue-i18n'
import type { UserModule } from '~/types'
// Import i18n resources
// https://vitejs.dev/guide/features.html#glob-import
@@ -44,7 +44,9 @@ export async function loadLanguageAsync(lang: string): Promise<Locale> {
return setI18nLanguage(lang)
}
export const install: UserModule = ({ app }) => {
app.use(i18n)
loadLanguageAsync('en')
}
export default {
install: (app) => {
app.use(i18n)
loadLanguageAsync('en')
},
} satisfies Plugin
-14
View File
@@ -1,14 +0,0 @@
import NProgress from 'nprogress'
import type { UserModule } from '~/types'
export const install: UserModule = ({ isClient, router }) => {
if (isClient) {
router.beforeEach((to, from) => {
if (to.path !== from.path)
NProgress.start()
})
router.afterEach(() => {
NProgress.done()
})
}
}
-17
View File
@@ -1,17 +0,0 @@
import { createPinia } from 'pinia'
import type { UserModule } from '~/types'
// Setup Pinia
// https://pinia.vuejs.org/
export const install: UserModule = ({ isClient, initialState, app }) => {
const pinia = createPinia()
app.use(pinia)
// Refer to
// https://github.com/antfu/vite-ssg/blob/main/README.md#state-serialization
// for other serialization strategies.
if (isClient)
pinia.state.value = (initialState.pinia) || {}
else
initialState.pinia = pinia.state.value
}
-14
View File
@@ -1,14 +0,0 @@
import type { UserModule } from '~/types'
// https://github.com/antfu/vite-plugin-pwa#automatic-reload-when-new-content-available
export const install: UserModule = ({ isClient, router }) => {
if (!isClient)
return
router.isReady()
.then(async () => {
const { registerSW } = await import('virtual:pwa-register')
registerSW({ immediate: true })
})
.catch(() => {})
}
-20
View File
@@ -1,20 +0,0 @@
## File-based Routing
Routes will be auto-generated for Vue files in this dir with the same file structure.
Check out [`unplugin-vue-router`](https://github.com/posva/unplugin-vue-router) for more details.
### Path Aliasing
`~/` is aliased to `./src/` folder.
For example, instead of having
```ts
import { isDark } from '../../../../composables'
```
now, you can use
```ts
import { isDark } from '~/composables'
```
-14
View File
@@ -1,14 +0,0 @@
<script setup lang="ts">
const { t } = useI18n()
</script>
<template>
<div>
{{ t('not-found') }}
</div>
</template>
<route lang="yaml">
meta:
layout: 404
</route>
-21
View File
@@ -1,21 +0,0 @@
---
title: About
---
<div class="text-center">
<!-- You can use Vue components inside markdown -->
<div i-carbon-dicom-overlay class="text-4xl -mb-6 m-auto" />
<h3>About</h3>
</div>
[Vitesse](https://github.com/antfu/vitesse) is an opinionated [Vite](https://github.com/vitejs/vite) starter template made by [@antfu](https://github.com/antfu) for mocking apps swiftly. With **file-based routing**, **components auto importing**, **markdown support**, I18n, PWA and uses **UnoCSS** for styling and icons.
```js
// syntax highlighting example
function vitesse() {
const foo = 'bar'
console.log(foo)
}
```
Check out the [GitHub repo](https://github.com/antfu/vitesse) for more details.
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { ref } from 'vue'
const containerRef = ref<HTMLDivElement>()
const fileInputRef = ref<HTMLInputElement>()
function handleFileUpload(e: Event) {
if (!e)
return
const file = fileInputRef.value?.files?.[0]
if (!file)
return
const audioElem = document.createElement('audio')
containerRef.value?.appendChild(audioElem)
audioElem.src = URL.createObjectURL(file)
audioElem.controls = true
audioElem.load()
audioElem.play()
}
</script>
<template>
<div>
<ClientOnly>
<div>
<div ref="containerRef" />
<input
ref="fileInputRef"
type="file"
@change="handleFileUpload"
>
</div>
</ClientOnly>
</div>
</template>
-47
View File
@@ -1,47 +0,0 @@
<script setup lang="ts">
const router = useRouter()
const route = useRoute('/hi/[name]')
const user = useUserStore()
const { t } = useI18n()
watchEffect(() => {
user.setNewName(route.params.name)
})
</script>
<template>
<div>
<div text-4xl>
<div i-carbon-pedestrian inline-block />
</div>
<p>
{{ t('intro.hi', { name: user.savedName }) }}
</p>
<p text-sm opacity-75>
<em>{{ t('intro.dynamic-route') }}</em>
</p>
<template v-if="user.otherNames.length">
<p mt-4 text-sm>
<span opacity-75>{{ t('intro.aka') }}:</span>
<ul>
<li v-for="otherName in user.otherNames" :key="otherName">
<RouterLink :to="`/hi/${otherName}`" replace>
{{ otherName }}
</RouterLink>
</li>
</ul>
</p>
</template>
<div>
<button
m="3 t6" text-sm btn
@click="router.back()"
>
{{ t('button.back') }}
</button>
</div>
</div>
</template>
+6 -50
View File
@@ -1,56 +1,12 @@
<script setup lang="ts">
defineOptions({
name: 'IndexPage',
})
const user = useUserStore()
const name = ref(user.savedName)
const router = useRouter()
function go() {
if (name.value)
router.push(`/hi/${encodeURIComponent(name.value)}`)
}
const { t } = useI18n()
</script>
<template>
<div>
<div text-4xl>
<div i-carbon-campsite inline-block />
</div>
<p>
<a rel="noreferrer" href="https://github.com/antfu/vitesse" target="_blank">
Vitesse
</a>
</p>
<p>
<em text-sm opacity-75>{{ t('intro.desc') }}</em>
</p>
<div py-4 />
<TheInput
v-model="name"
:placeholder="t('intro.whats-your-name')"
autocomplete="false"
@keydown.enter="go"
/>
<label class="hidden" for="input">{{ t('intro.whats-your-name') }}</label>
<div>
<button
m-3 text-sm btn
:disabled="!name"
@click="go"
>
{{ t('button.go') }}
</button>
</div>
<ClientOnly>
<MainStage />
</ClientOnly>
</div>
</template>
<route lang="yaml">
meta:
layout: home
</route>
meta:
layout: default
</route>
+154
View File
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useQueue } from '../composables/queue'
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
const temp = ref<string>('')
const audioQueue = useQueue<string>({
handlers: [
async (text) => {
// eslint-disable-next-line no-console
console.log('ready to play speech audio for', text)
},
],
})
const ttsQueue = useQueue<string>({
handlers: [
async (ctx) => {
// eslint-disable-next-line no-console
console.log('ready to stream speech audio for', ctx)
audioQueue.add(ctx.data)
},
],
})
const textQueue = useQueue<string>({
handlers: [
async (ctx) => {
const endMarker = ['.', '?', '!']
let newEndPartDiscovered = false
for (const marker of endMarker) {
if (!ctx.data.includes(marker))
continue
// find the end of the sentence and push it to the queue with temp
const periodIndex = ctx.data.indexOf(marker)
// split
const beforePeriod = ctx.data.slice(0, periodIndex + 1)
const afterPeriod = ctx.data.slice(periodIndex + 1)
temp.value += beforePeriod
ttsQueue.add(temp.value.trim())
temp.value = afterPeriod
newEndPartDiscovered = true
}
if (!newEndPartDiscovered)
temp.value += ctx.data
},
],
})
const textParts = [
'Hello',
' N',
'eko',
'! I',
' am',
' an',
' AI',
' assistant',
' trained',
' to',
' help',
' with',
' a',
' variety',
' of',
' tasks',
' such',
' as',
' answering',
' questions',
',',
' providing',
' information',
',',
' giving',
' recommendations',
',',
' and',
' more',
'. How',
' can',
' I',
' assist',
' you',
' today',
'?',
'Hello',
' N',
'eko',
',',
' I',
' am',
' an',
' AI',
' assistant',
'.',
' I',
' can',
' help',
' answer',
' questions',
',',
' provide',
' information',
',',
' assist',
' with',
' tasks',
',',
' and',
' engage',
' in',
' conversation',
'.',
' How',
' can',
' I',
' assist',
' you',
' today',
'?',
]
async function mockTextPartsStreamHandler() {
for (const part of textParts) {
await sleep(100)
textQueue.add(part)
}
}
async function handler() {
mockTextPartsStreamHandler()
}
onMounted(() => {
handler()
})
</script>
<template>
<div>
<ClientOnly>
<div />
</ClientOnly>
</div>
</template>
+81
View File
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { ref } from 'vue'
const messageInput = ref<string>('')
const processing = ref<boolean>(false)
const streamingMessage = ref({ content: '' })
async function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function onSendMessage() {
processing.value = true
const tokens = messageInput.value.split('')
enum States {
Literal = 'literal',
Special = 'special',
}
let state = States.Literal
let buffer = ''
for (const textPart of tokens) {
await sleep(50)
let newState: States = state
if (textPart === '<')
newState = States.Special
else if (textPart === '>')
newState = States.Literal
if (state === States.Literal && newState === States.Special) {
streamingMessage.value.content += buffer
buffer = ''
}
if (state === States.Special && newState === States.Literal)
buffer = '' // Clear buffer when exiting Special state
if (state === States.Literal && newState === States.Literal) {
streamingMessage.value.content += textPart
buffer = ''
}
state = newState
}
if (buffer)
streamingMessage.value.content += buffer
messageInput.value = ''
processing.value = false
}
</script>
<template>
<ClientOnly>
<div flex flex-col gap-2 p-2>
<div flex flex-row gap-2>
<BasicTextarea
v-model="messageInput"
placeholder="Message"
p="2" bg="zinc-100 dark:zinc-700"
w-full rounded-lg outline-none
@submit="onSendMessage"
/>
<button rounded-lg bg="zinc-100 dark:zinc-700" p-4>
{{ processing ? 'Processing...' : 'Send' }}
</button>
</div>
<div w-full rounded-lg bg="zinc-100 dark:zinc-700" p-2>
<h3 font-semibold>
Streaming Message
</h3>
<div>{{ streamingMessage.content }}</div>
</div>
</div>
</ClientOnly>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from 'vue'
import BasicTextarea from '../../../components/BasicTextarea.vue'
import { useQueue } from '../../../composables/queue'
import { useDelayMessageQueue } from '../../../composables/queues'
import { llmInferenceEndToken } from '../../../constants'
const messageInput = ref<string>('')
const emotionMessageContentProcessed = ref<string[]>([])
const delaysProcessed = ref<number[]>([])
const processing = ref<boolean>(false)
const emotionMessageContentQueue = useQueue<string>({
handlers: [
async (ctx) => {
emotionMessageContentProcessed.value.push(ctx.data)
},
],
})
const delaysQueue = useDelayMessageQueue(emotionMessageContentQueue)
delaysQueue.onHandlerEvent('delay', (delay) => {
delaysProcessed.value.push(delay)
})
function onSendMessage() {
processing.value = true
const tokens = messageInput.value.split('')
for (const token of tokens)
delaysQueue.add(token)
delaysQueue.add(llmInferenceEndToken)
messageInput.value = ''
processing.value = false
}
</script>
<template>
<ClientOnly>
<div flex flex-col gap-2 p-2>
<div flex flex-row gap-2>
<BasicTextarea
v-model="messageInput"
placeholder="Message"
p="2" bg="zinc-100 dark:zinc-700"
w-full rounded-lg outline-none
@submit="onSendMessage"
/>
<button rounded-lg bg="zinc-100 dark:zinc-700" p-4>
{{ processing ? 'Processing...' : 'Send' }}
</button>
</div>
<div w-full flex flex-row gap-4>
<div w-full rounded-lg bg="zinc-100 dark:zinc-700" p-2>
<h3 font-semibold>
Emotion Message
</h3>
<div v-for="message in emotionMessageContentProcessed" :key="message">
<div>{{ message }}</div>
</div>
</div>
<div w-full rounded-lg bg="zinc-100 dark:zinc-700" p-2>
<h3 font-semibold>
Delays
</h3>
<div v-for="message in delaysProcessed" :key="message">
<div>{{ message }}s</div>
</div>
</div>
</div>
</div>
</ClientOnly>
</template>
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
import type { Emotion } from '../../../constants/emotions'
import { ref } from 'vue'
import BasicTextarea from '../../../components/BasicTextarea.vue'
import { useQueue } from '../../../composables/queue'
import { useEmotionsMessageQueue } from '../../../composables/queues'
import { llmInferenceEndToken } from '../../../constants'
const messageInput = ref<string>('')
const messagesProcessed = ref<string[]>([])
const emotionsProcessed = ref<string[]>([])
const processing = ref<boolean>(false)
const messageContentQueue = useQueue<string>({
handlers: [
async (ctx) => {
messagesProcessed.value.push(ctx.data)
},
],
})
const emotionsQueue = useQueue<Emotion>({
handlers: [
async (ctx) => {
emotionsProcessed.value.push(ctx.data)
},
],
})
const emotionMessageContentQueue = useEmotionsMessageQueue(emotionsQueue, messageContentQueue)
function onSendMessage() {
processing.value = true
const tokens = messageInput.value.split('')
for (const token of tokens)
emotionMessageContentQueue.add(token)
emotionMessageContentQueue.add(llmInferenceEndToken)
messageInput.value = ''
processing.value = false
}
</script>
<template>
<ClientOnly>
<div flex flex-col gap-2 p-2>
<div flex flex-row gap-2>
<BasicTextarea
v-model="messageInput"
placeholder="Message"
p="2" bg="zinc-100 dark:zinc-700"
w-full rounded-lg outline-none
@submit="onSendMessage"
/>
<button rounded-lg bg="zinc-100 dark:zinc-700" p-4>
{{ processing ? 'Processing...' : 'Send' }}
</button>
</div>
<div w-full flex flex-row gap-4>
<div w-full rounded-lg bg="zinc-100 dark:zinc-700" p-2>
<h3 font-semibold>
Messages
</h3>
<div v-for="message in messagesProcessed" :key="message">
<div>{{ message }}</div>
</div>
</div>
<div w-full rounded-lg bg="zinc-100 dark:zinc-700" p-2>
<h3 font-semibold>
Emotions
</h3>
<div v-for="message in emotionsProcessed" :key="message">
<div>{{ message }}</div>
</div>
</div>
</div>
</div>
</ClientOnly>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { ref } from 'vue'
import BasicTextarea from '../../../components/BasicTextarea.vue'
import { useQueue } from '../../../composables/queue'
import { useMessageContentQueue } from '../../../composables/queues'
import { llmInferenceEndToken } from '../../../constants'
const messageInput = ref<string>('')
const ttsProcessed = ref<string[]>([])
const processing = ref<boolean>(false)
// async function sleep(ms: number) {
// return new Promise(resolve => setTimeout(resolve, ms))
// }
const ttsQueue = useQueue<string>({
handlers: [
async (ctx) => {
ttsProcessed.value.push(ctx.data)
},
],
})
const messageContentQueue = useMessageContentQueue(ttsQueue)
async function onSendMessage() {
processing.value = true
// const tokens = messageInput.value.split('')
// for (const token of tokens) {
// await sleep(100)
// messageContentQueue.add(token)
// }
messageContentQueue.add(messageInput.value)
messageContentQueue.add(llmInferenceEndToken)
messageInput.value = ''
processing.value = false
}
</script>
<template>
<ClientOnly>
<div flex flex-col gap-2 p-2>
<div flex flex-row gap-2>
<BasicTextarea
v-model="messageInput"
placeholder="Message"
p="2" bg="zinc-100 dark:zinc-700"
w-full rounded-lg outline-none
@submit="onSendMessage"
/>
<button rounded-lg bg="zinc-100 dark:zinc-700" p-4>
{{ processing ? 'Processing...' : 'Send' }}
</button>
</div>
<div w-full flex flex-row gap-4>
<div w-full rounded-lg bg="zinc-100 dark:zinc-700" p-2>
<h3 font-semibold>
TTS Message
</h3>
<div v-for="message in ttsProcessed" :key="message">
<div>{{ message }}</div>
</div>
</div>
</div>
</div>
</ClientOnly>
</template>
+76
View File
@@ -0,0 +1,76 @@
import { defineStore } from 'pinia'
function calculateVolumeWithLinearNormalize(analyser: AnalyserNode) {
const dataBuffer = new Uint8Array(analyser.frequencyBinCount)
analyser.getByteFrequencyData(dataBuffer)
const volumeVector = []
for (let i = 0; i < 700; i += 80)
volumeVector.push(dataBuffer[i])
const volumeSum = dataBuffer
// The volume changes are so flatten, and the volume is so low, so we need to amplify it
// We can apply a power function to amplify the volume, for example
// v ** 1.2 will amplify the volume by 1.2 times
.map(v => v ** 1.2)
// Scale up the volume values to make them more distinguishable
.map(v => v * 1.2)
.reduce((acc, cur) => acc + cur, 0)
// console.log('volumeSum linear', volumeSum, (volumeSum / dataBuffer.length / 100))
return (volumeSum / dataBuffer.length / 100)
}
function calculateVolumeWithMinMaxNormalize(analyser: AnalyserNode) {
const dataBuffer = new Uint8Array(analyser.frequencyBinCount)
analyser.getByteFrequencyData(dataBuffer)
const volumeVector = []
for (let i = 0; i < 700; i += 80)
volumeVector.push(dataBuffer[i])
// The volume changes are so flatten, and the volume is so low, so we need to amplify it
// We can apply a power function to amplify the volume, for example
// v ** 1.2 will amplify the volume by 1.2 times
const amplifiedVolumeVector = dataBuffer.map(v => v ** 1.5)
// Normalize the amplified values using Min-Max scaling
const min = Math.min(...amplifiedVolumeVector)
const max = Math.max(...amplifiedVolumeVector)
const range = max - min
let normalizedVolumeVector
if (range === 0) {
// If range is zero, all values are the same, so normalization is not needed
normalizedVolumeVector = amplifiedVolumeVector.map(() => 0) // or any default value
}
else {
normalizedVolumeVector = amplifiedVolumeVector.map(v => (v - min) / range)
}
// Aggregate the volume values
const volumeSum = normalizedVolumeVector.reduce((acc, cur) => acc + cur, 0)
// console.log('volumeSum minmax', volumeSum)
// Average the volume values
return volumeSum / dataBuffer.length
}
function calculateVolume(analyser: AnalyserNode, mode: 'linear' | 'minmax' = 'linear') {
switch (mode) {
case 'linear':
return calculateVolumeWithLinearNormalize(analyser)
case 'minmax':
return calculateVolumeWithMinMaxNormalize(analyser)
}
}
export const useAudioContext = defineStore('AudioContext', () => {
const audioContext = new AudioContext()
return {
audioContext,
calculateVolume,
}
})
+60
View File
@@ -0,0 +1,60 @@
import type { CoreMessage } from 'ai'
import { createOpenAI, type OpenAIProvider, type OpenAIProviderSettings } from '@ai-sdk/openai'
import { streamText } from 'ai'
import { ofetch } from 'ofetch'
import { OpenAI } from 'openai'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useLLM = defineStore('llm', () => {
const openAI = ref<OpenAI>()
const openAIProvider = ref<OpenAIProvider>()
function setupOpenAI(options: OpenAIProviderSettings) {
openAI.value = new OpenAI({
...options,
dangerouslyAllowBrowser: true,
})
openAIProvider.value = createOpenAI(options)
}
async function stream(model: string, messages: CoreMessage[]) {
if (!openAIProvider.value)
throw new Error('OpenAI not initialized')
return await streamText({
model: openAIProvider.value(model),
messages,
})
}
async function models() {
if (!openAI.value)
throw new Error('OpenAI not initialized')
return await openAI.value.models.list()
}
async function streamSpeech(text: string, apiKey: string) {
if (!text || !text.trim())
throw new Error('Text is required')
return await ofetch('/api/v1/llm/voice/text-to-speech', {
body: {
text,
apiKey,
},
method: 'POST',
cache: 'no-cache',
responseType: 'arrayBuffer',
})
}
return {
setupOpenAI,
openAI,
models,
stream,
streamSpeech,
}
})
-34
View File
@@ -1,34 +0,0 @@
import { acceptHMRUpdate, defineStore } from 'pinia'
export const useUserStore = defineStore('user', () => {
/**
* Current name of the user.
*/
const savedName = ref('')
const previousNames = ref(new Set<string>())
const usedNames = computed(() => Array.from(previousNames.value))
const otherNames = computed(() => usedNames.value.filter(name => name !== savedName.value))
/**
* Changes the current name of the user and saves the one that was used
* before.
*
* @param name - new name to set
*/
function setNewName(name: string) {
if (savedName.value)
previousNames.value.add(savedName.value)
savedName.value = name
}
return {
setNewName,
otherNames,
savedName,
}
})
if (import.meta.hot)
import.meta.hot.accept(acceptHMRUpdate(useUserStore as any, import.meta.hot))
-4
View File
@@ -19,12 +19,8 @@ declare module 'vue-router/auto-routes' {
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/[...all]': RouteRecordInfo<'/[...all]', '/:all(.*)', { all: ParamValue<true> }, { all: ParamValue<false> }>,
'/about': RouteRecordInfo<'/about', '/about', Record<never, never>, Record<never, never>>,
'/audio': RouteRecordInfo<'/audio', '/audio', Record<never, never>, Record<never, never>>,
'/hi/[name]': RouteRecordInfo<'/hi/[name]', '/hi/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
'/queue': RouteRecordInfo<'/queue', '/queue', Record<never, never>, Record<never, never>>,
'/README': RouteRecordInfo<'/README', '/README', Record<never, never>, Record<never, never>>,
'/test/filter-message': RouteRecordInfo<'/test/filter-message', '/test/filter-message', Record<never, never>, Record<never, never>>,
'/test/queues/delays': RouteRecordInfo<'/test/queues/delays', '/test/queues/delays', Record<never, never>, Record<never, never>>,
'/test/queues/emotions': RouteRecordInfo<'/test/queues/emotions', '/test/queues/emotions', Record<never, never>, Record<never, never>>,
-3
View File
@@ -1,3 +0,0 @@
import type { ViteSSGContext } from 'vite-ssg'
export type UserModule = (ctx: ViteSSGContext) => void