mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
chore: migrate with new structure
This commit is contained in:
+1
-2
@@ -1,2 +1 @@
|
||||
open_collective: antfu
|
||||
github: [antfu]
|
||||
github: [nekomeowww, kwaa]
|
||||
|
||||
Vendored
+1
-4
@@ -38,8 +38,5 @@
|
||||
"json",
|
||||
"jsonc",
|
||||
"yaml"
|
||||
],
|
||||
"interline-translate.knownPopularWordCount": 6000,
|
||||
"iconify.annotations": true,
|
||||
"iconify.inplace": true
|
||||
]
|
||||
}
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
FROM node:20-alpine as build-stage
|
||||
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
|
||||
COPY .npmrc package.json pnpm-lock.yaml ./
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
# SSR
|
||||
FROM node:20-alpine as production-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build-stage /app/.output ./.output
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { appName } from '~/constants'
|
||||
import { appName } from './constants'
|
||||
|
||||
useHead({
|
||||
title: appName,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang='ts'>
|
||||
const { count, inc, dec } = useCount()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div inline-flex m="y-3">
|
||||
<button rounded-full p-2 btn @click="dec()">
|
||||
<div i-carbon-subtract />
|
||||
</button>
|
||||
<div font="mono" w="15" m-auto inline-block>
|
||||
{{ count }}
|
||||
</div>
|
||||
<button rounded-full p-2 btn @click="inc()">
|
||||
<div i-carbon-add />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const color = useColorMode()
|
||||
|
||||
useHead({
|
||||
meta: [{
|
||||
id: 'theme-color',
|
||||
name: 'theme-color',
|
||||
content: () => color.value === 'dark' ? '#222222' : '#ffffff',
|
||||
}],
|
||||
})
|
||||
|
||||
function toggleDark() {
|
||||
color.preference = color.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="!outline-none" @click="toggleDark">
|
||||
<div class="i-carbon-sun dark:i-carbon-moon" />
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<div text="xl gray4" m-5 flex="~ gap3" justify-center>
|
||||
<NuxtLink i-carbon-campsite to="/" />
|
||||
<a i-carbon-logo-github href="https://github.com/antfu/vitesse-nuxt3" target="_blank" />
|
||||
<DarkToggle />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,34 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const name = ref('')
|
||||
|
||||
const router = useRouter()
|
||||
function go() {
|
||||
if (name.value)
|
||||
router.push(`/hi/${encodeURIComponent(name.value)}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<input
|
||||
id="input"
|
||||
v-model="name"
|
||||
placeholder="What's your name?"
|
||||
type="text" autocomplete="off"
|
||||
p="x-4 y-2" m="t-5" w="250px"
|
||||
text="center" bg="transparent"
|
||||
border="~ rounded gray-200 dark:gray-700"
|
||||
outline="none active:none"
|
||||
@keydown.enter="go"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
m-3 text-sm btn
|
||||
:disabled="!name"
|
||||
@click="go"
|
||||
>
|
||||
GO
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -1,17 +0,0 @@
|
||||
<template>
|
||||
<div inline-flex cursor-default text-2xl font-300>
|
||||
<div flex flex-col children:mx-auto>
|
||||
<img inline-block h-18 w-18 src="/nuxt.svg">
|
||||
<span mt--2 text-green5>Nuxt 3</span>
|
||||
</div>
|
||||
<div
|
||||
text="3xl gray4"
|
||||
m="x-4 y-auto"
|
||||
i-carbon-add transform transition-all-500 hover:rotate-135
|
||||
/>
|
||||
<div flex flex-col children:mx-auto>
|
||||
<img inline-block h-18 w-18 src="/vite.png">
|
||||
<span mt--2 text-purple5>Vitesse</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,377 @@
|
||||
<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 { 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 (ctx) => {
|
||||
const now = Date.now()
|
||||
const res = await streamSpeech(ctx.data)
|
||||
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="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="openAiApiBaseURL"
|
||||
placeholder="Input your API base URL"
|
||||
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>
|
||||
@@ -1,13 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const { data } = await useFetch('/api/pageview')
|
||||
|
||||
const time = useTimeAgo(() => data.value?.startAt || 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div text-gray:80>
|
||||
<span text-gray font-500>{{ data?.pageview }}</span>
|
||||
page views since
|
||||
<span text-gray>{{ time }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,16 +0,0 @@
|
||||
export function useCount() {
|
||||
const count = useState('count', () => Math.round(Math.random() * 20))
|
||||
|
||||
function inc() {
|
||||
count.value += 1
|
||||
}
|
||||
function dec() {
|
||||
count.value -= 1
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
inc,
|
||||
dec,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { acceptHMRUpdate, defineStore } from 'pinia'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
/**
|
||||
* Current named 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, import.meta.hot))
|
||||
@@ -39,6 +39,7 @@ export const pwa: ModuleOptions = {
|
||||
navigateFallbackDenylist: [/^\/api\//],
|
||||
navigateFallback: '/',
|
||||
cleanupOutdatedCaches: true,
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // <== 5 MB
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts.googleapis.com\/.*/i,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export const appName = 'Vitesse for Nuxt 3'
|
||||
export const appDescription = 'Vitesse for Nuxt 3'
|
||||
export const appName = 'アイリ VTuber'
|
||||
export const appDescription = 'アイリ VTuber - LLM Powered Live2D VTuber'
|
||||
|
||||
export const llmInferenceEndToken = '<|llm_inference_end|>'
|
||||
|
||||
@@ -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
|
||||
@@ -1,15 +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.
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'home',
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
Learn more on https://nuxt.com/docs/guide/directory-structure/layouts
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<main class="px-10 py-20 text-center">
|
||||
<main text="gray-700 dark:gray-200" h-full font-sans>
|
||||
<slot />
|
||||
<Footer />
|
||||
<div class="mx-auto mt-5 text-center text-sm opacity-25">
|
||||
[Default Layout]
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
<main class="px-10 py-20 text-center">
|
||||
<slot />
|
||||
<Footer />
|
||||
<div class="mx-auto mt-5 text-center text-sm opacity-25">
|
||||
[Home Layout]
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main p="x4 y10" text="center teal-700 dark:gray-200">
|
||||
<div text-4xl>
|
||||
<div i-carbon-warning inline-block />
|
||||
</div>
|
||||
<div>Not found</div>
|
||||
<div>
|
||||
<button text-sm btn m="3 t8" @click="router.back()">
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -1,49 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute<'hi-id'>()
|
||||
const user = useUserStore()
|
||||
const name = route.params.id
|
||||
|
||||
watchEffect(() => {
|
||||
user.setNewName(route.params.id as string)
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
layout: 'home',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div i-twemoji:waving-hand inline-block animate-shake-x animate-duration-5000 text-4xl />
|
||||
<h3 text-2xl font-500>
|
||||
Hi,
|
||||
</h3>
|
||||
<div text-xl>
|
||||
{{ name }}!
|
||||
</div>
|
||||
|
||||
<template v-if="user.otherNames.length">
|
||||
<div my-4 text-sm>
|
||||
<span op-50>Also as known as:</span>
|
||||
<ul>
|
||||
<li v-for="otherName in user.otherNames" :key="otherName">
|
||||
<router-link :to="`/hi/${otherName}`" replace>
|
||||
{{ otherName }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Counter />
|
||||
|
||||
<div>
|
||||
<NuxtLink
|
||||
class="m-3 text-sm btn"
|
||||
to="/"
|
||||
>
|
||||
Back
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+1
-26
@@ -1,32 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'home',
|
||||
})
|
||||
|
||||
const online = useOnline()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Logos mb-6 />
|
||||
<ClientOnly>
|
||||
<Suspense>
|
||||
<PageView v-if="online" />
|
||||
<div v-else text-gray:80>
|
||||
You're offline
|
||||
</div>
|
||||
<template #fallback>
|
||||
<div italic op50>
|
||||
<span animate-pulse>Loading...</span>
|
||||
</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
<template #fallback>
|
||||
<div op50>
|
||||
<span animate-pulse>...</span>
|
||||
</div>
|
||||
</template>
|
||||
<MainStage />
|
||||
</ClientOnly>
|
||||
<InputEntry />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,79 @@
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
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) {
|
||||
if (!text || !text.trim())
|
||||
throw new Error('Text is required')
|
||||
|
||||
return await ofetch('/api/v1/llm/voice/text-to-speech', {
|
||||
body: {
|
||||
text,
|
||||
},
|
||||
method: 'POST',
|
||||
cache: 'no-cache',
|
||||
responseType: 'arrayBuffer',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
setupOpenAI,
|
||||
openAI,
|
||||
models,
|
||||
stream,
|
||||
streamSpeech,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
version: '0.2'
|
||||
ignorePaths: []
|
||||
dictionaryDefinitions: []
|
||||
dictionaries: []
|
||||
words:
|
||||
- airi-vtuber
|
||||
- composables
|
||||
- elevenlabs
|
||||
- hiyori
|
||||
- iconify
|
||||
- kwaa
|
||||
- Myriam
|
||||
- nekomeowww
|
||||
- Neuro
|
||||
- Neuro-sama
|
||||
- nuxi
|
||||
- nuxt
|
||||
- nuxtjs
|
||||
- ofetch
|
||||
- openai
|
||||
- pinia
|
||||
- pixi
|
||||
- rehype
|
||||
- unocss
|
||||
- vueuse
|
||||
- live2dcubismcore
|
||||
ignoreWords: []
|
||||
import: []
|
||||
+1
-1
@@ -3,7 +3,7 @@ publish = "dist"
|
||||
command = "pnpm run build"
|
||||
|
||||
[build.environment]
|
||||
NODE_VERSION = "20"
|
||||
NODE_VERSION = "22"
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
|
||||
+92
-4
@@ -1,5 +1,12 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { ofetch } from 'ofetch'
|
||||
|
||||
import { pwa } from './app/config/pwa'
|
||||
import { appDescription } from './app/constants/index'
|
||||
import { exists } from './scripts/fs'
|
||||
import { unzip } from './scripts/unzip'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
modules: [
|
||||
@@ -30,6 +37,9 @@ export default defineNuxtConfig({
|
||||
{ name: 'theme-color', media: '(prefers-color-scheme: light)', content: 'white' },
|
||||
{ name: 'theme-color', media: '(prefers-color-scheme: dark)', content: '#222222' },
|
||||
],
|
||||
script: [
|
||||
{ src: '/assets/js/CubismSdkForWeb-5-r.1/Core/live2dcubismcore.min.js' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -61,13 +71,91 @@ export default defineNuxtConfig({
|
||||
target: 'esnext',
|
||||
},
|
||||
},
|
||||
prerender: {
|
||||
crawlLinks: false,
|
||||
routes: ['/'],
|
||||
ignore: ['/hi'],
|
||||
routeRules: {
|
||||
'/assets/**': { static: true },
|
||||
},
|
||||
},
|
||||
|
||||
vite: {
|
||||
plugins: [
|
||||
{
|
||||
name: 'live2d-cubism-sdk',
|
||||
async configResolved(config) {
|
||||
const publicDir = resolve(join(config.root, '../public'))
|
||||
|
||||
try {
|
||||
if (await exists(resolve(join(publicDir, 'assets/js/CubismSdkForWeb-5-r.1')))) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Downloading Cubism SDK...')
|
||||
const stream = await ofetch('https://dist.ayaka.moe/npm/live2d-cubism/CubismSdkForWeb-5-r.1.zip', { responseType: 'arrayBuffer' })
|
||||
|
||||
console.log('Unzipping Cubism SDK...')
|
||||
await mkdir(join(publicDir, 'assets/js'), { recursive: true })
|
||||
await unzip(Buffer.from(stream), join(publicDir, 'assets/js'))
|
||||
|
||||
console.log('Cubism SDK downloaded and unzipped.')
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'live2d-models-hiyori-free',
|
||||
async configResolved(config) {
|
||||
const publicDir = resolve(join(config.root, '../public'))
|
||||
|
||||
try {
|
||||
if (await exists(resolve(join(publicDir, 'assets/live2d/models/hiyori_free_zh')))) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Downloading Demo Live2D Model - Hiyori Free...')
|
||||
const stream = await ofetch('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', { responseType: 'arrayBuffer' })
|
||||
|
||||
console.log('Unzipping Demo Live2D Model - Hiyori Free...')
|
||||
await mkdir(join(publicDir, 'assets/live2d/models'), { recursive: true })
|
||||
await unzip(Buffer.from(stream), join(publicDir, 'assets/live2d/models'))
|
||||
|
||||
console.log('Demo Live2D Model - Hiyori Free downloaded and unzipped.')
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'live2d-models-hiyori-pro',
|
||||
async configResolved(config) {
|
||||
const publicDir = resolve(join(config.root, '../public'))
|
||||
|
||||
try {
|
||||
if (await exists(resolve(join(publicDir, 'assets/live2d/models/hiyori_pro_zh')))) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Downloading Demo Live2D Model - Hiyori Pro...')
|
||||
const stream = await ofetch('https://dist.ayaka.moe/live2d-models/hiyori_pro_zh.zip', { responseType: 'arrayBuffer' })
|
||||
|
||||
console.log('Unzipping Demo Live2D Model - Hiyori Pro...')
|
||||
await mkdir(join(publicDir, 'assets/live2d/models'), { recursive: true })
|
||||
await unzip(Buffer.from(stream), join(publicDir, 'assets/live2d/models'))
|
||||
|
||||
console.log('Demo Live2D Model - Hiyori Pro downloaded and unzipped.')
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
eslint: {
|
||||
config: {
|
||||
standalone: false,
|
||||
|
||||
+37
-12
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.14.3",
|
||||
"packageManager": "pnpm@9.14.4",
|
||||
"scripts": {
|
||||
"build": "nuxi build",
|
||||
"dev:pwa": "VITE_PLUGIN_PWA=true nuxi dev",
|
||||
@@ -14,28 +14,53 @@
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/openai": "^1.0.5",
|
||||
"@antfu/eslint-config": "^3.11.2",
|
||||
"@iconify-json/carbon": "^1.2.4",
|
||||
"@iconify-json/twemoji": "^1.2.1",
|
||||
"@nuxt/devtools": "^1.6.1",
|
||||
"@nuxt/eslint": "^0.7.2",
|
||||
"@nuxtjs/color-mode": "^3.5.2",
|
||||
"@pinia/nuxt": "^0.7.0",
|
||||
"@unocss/eslint-config": "^0.64.1",
|
||||
"@unocss/nuxt": "^0.64.1",
|
||||
"@pinia/nuxt": "^0.8.0",
|
||||
"@pixi/app": "^6",
|
||||
"@pixi/constants": "6",
|
||||
"@pixi/core": "6",
|
||||
"@pixi/display": "6",
|
||||
"@pixi/extensions": "^6",
|
||||
"@pixi/loaders": "6",
|
||||
"@pixi/math": "6",
|
||||
"@pixi/runner": "6",
|
||||
"@pixi/settings": "6",
|
||||
"@pixi/sprite": "6",
|
||||
"@pixi/ticker": "^6",
|
||||
"@pixi/utils": "6",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"@typeschema/zod": "^0.14.0",
|
||||
"@unocss/eslint-config": "^0.65.0",
|
||||
"@unocss/nuxt": "^0.65.0",
|
||||
"@vite-pwa/nuxt": "^0.10.6",
|
||||
"@vueuse/nuxt": "^12.0.0",
|
||||
"ai": "^4.0.10",
|
||||
"consola": "^3.2.3",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-format": "^0.1.2",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-plugin-format": "^0.1.3",
|
||||
"nuxt": "^3.14.1592",
|
||||
"ofetch": "^1.4.1",
|
||||
"openai": "^4.73.1",
|
||||
"pinia": "^2.2.8",
|
||||
"typescript": "~5.6.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
},
|
||||
"resolutions": {
|
||||
"pixi": "^0.3.1",
|
||||
"pixi-live2d-display": "^0.4.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"typescript": "~5.7.2",
|
||||
"unified": "^11.0.5",
|
||||
"unplugin": "2.0.0-beta.1",
|
||||
"vite": "^6.0.1",
|
||||
"vite-plugin-inspect": "^0.10.1"
|
||||
"vite": "^6.0.2",
|
||||
"vite-plugin-inspect": "^0.10.2",
|
||||
"vue-tsc": "^2.1.10",
|
||||
"yauzl": "^3.2.0",
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2230
-850
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
export function rejectIfError<E = unknown>(error: E | undefined, reject: (error?: E) => void, handler?: (error?: E) => void) {
|
||||
if (error) {
|
||||
reject(error)
|
||||
!!handler && handler(error)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWhenNoError<R = void, E = unknown>(reject: (error?: E) => void, resolve: (result?: R) => void) {
|
||||
return (err?: E) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
}
|
||||
else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function onError<E = unknown>(reject: (error?: E) => void, handler?: (error?: E) => void) {
|
||||
return (error?: E) => rejectIfError(error, reject, handler)
|
||||
}
|
||||
|
||||
export function noError<
|
||||
T,
|
||||
U extends unknown[],
|
||||
E = unknown,
|
||||
>(
|
||||
reject: (err?: E) => void,
|
||||
fn: (...args: U) => T,
|
||||
): (err: E | undefined, ...args: U) => T | undefined {
|
||||
return (err, ...args) => {
|
||||
if (err) {
|
||||
rejectIfError(err, reject)
|
||||
return
|
||||
}
|
||||
return fn(...args)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
|
||||
export async function exists(path: string) {
|
||||
try {
|
||||
await stat(path)
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
if (isENOENTError(error))
|
||||
return false
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function isENOENTError(error: unknown): boolean {
|
||||
if (!(error instanceof Error))
|
||||
return false
|
||||
if (!('code' in error))
|
||||
return false
|
||||
if (error.code !== 'ENOENT')
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Buffer } from 'node:buffer'
|
||||
import { createWriteStream, existsSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fromBuffer } from 'yauzl'
|
||||
import { noError, onError, resolveWhenNoError } from './errors'
|
||||
|
||||
/**
|
||||
* Example:
|
||||
*
|
||||
* await unzip("./tim.zip", "./");
|
||||
*
|
||||
* Will create directories:
|
||||
*
|
||||
* ./tim.zip
|
||||
* ./tim
|
||||
*
|
||||
* Originally by [How to unzip to a folder using yauzl? - Stack Overflow](https://stackoverflow.com/questions/63932027/how-to-unzip-to-a-folder-using-yauzl)
|
||||
*
|
||||
* @param buffer Buffer of the zip file.
|
||||
* @param target Path to the folder where the zip folder will be put.
|
||||
*/
|
||||
export async function unzip(buffer: Buffer, target: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let pendingWrites = 0
|
||||
|
||||
fromBuffer(buffer, { lazyEntries: true }, noError(reject, (zipFile) => {
|
||||
// This is the key. We start by reading the first entry.
|
||||
zipFile.readEntry()
|
||||
|
||||
// Now for every entry, we will write a file or dir
|
||||
// to disk. Then call zipFile.readEntry() again to
|
||||
// trigger the next cycle.
|
||||
zipFile.on('entry', (entry) => {
|
||||
// Directories
|
||||
if (/\/$/.test(entry.fileName)) {
|
||||
// Create the directory then read the next entry.
|
||||
mkdirSync(join(target, entry.fileName), { recursive: true })
|
||||
zipFile.readEntry()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Files
|
||||
const dir = dirname(join(target, entry.fileName))
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
// Write the file to disk.
|
||||
pendingWrites++
|
||||
zipFile.openReadStream(entry, noError(reject, (readStream) => {
|
||||
const file = createWriteStream(join(target, entry.fileName))
|
||||
readStream.pipe(file)
|
||||
|
||||
// Handle errors
|
||||
file.on('error', (err) => {
|
||||
pendingWrites--
|
||||
zipFile.close()
|
||||
reject(err)
|
||||
})
|
||||
|
||||
// Wait until the file is finished writing, then read the next entry.
|
||||
file.on('finish', () => {
|
||||
file.close(() => {
|
||||
pendingWrites--
|
||||
if (pendingWrites === 0) {
|
||||
resolve()
|
||||
}
|
||||
|
||||
zipFile.readEntry()
|
||||
})
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
zipFile.on('error', onError(reject, zipFile.close))
|
||||
zipFile.on('end', resolveWhenNoError(reject, resolve))
|
||||
}))
|
||||
})
|
||||
}
|
||||
+4
-1
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
"extends": "./.nuxt/tsconfig.json",
|
||||
"exclude": [
|
||||
"public/assets/**/*"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createLocalFontProcessor } from '@unocss/preset-web-fonts/local'
|
||||
import {
|
||||
defineConfig,
|
||||
presetAttributify,
|
||||
@@ -28,7 +27,6 @@ export default defineConfig({
|
||||
serif: 'DM Serif Display',
|
||||
mono: 'DM Mono',
|
||||
},
|
||||
processors: createLocalFontProcessor(),
|
||||
}),
|
||||
],
|
||||
transformers: [
|
||||
|
||||
Reference in New Issue
Block a user