From 2d9cdd2e130f4706686a24b4762dec629909178e Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Fri, 13 Dec 2024 16:16:02 +0800 Subject: [PATCH] refactor: split into components --- packages/stage/src/auto-imports.d.ts | 6 + .../stage/src/components/Layouts/Header.vue | 15 + .../{Live2DViewer.vue => Live2D/Viewer.vue} | 0 packages/stage/src/components/MainStage.vue | 578 ------------------ .../{Live2DScene.vue => Scenes/Live2D.vue} | 17 +- .../{ThreeDScene.vue => Scenes/VRM.vue} | 6 +- packages/stage/src/components/Screen.vue | 2 +- .../{VRMModel.vue => VRM/Model.vue} | 7 +- .../src/components/Widgets/ChatHistory.vue | 99 +++ .../src/components/Widgets/InputArea.vue | 244 ++++++++ .../stage/src/components/Widgets/Stage.vue | 184 ++++++ packages/stage/src/pages/index.vue | 16 +- packages/stage/src/stores/audio.ts | 21 + packages/stage/src/stores/chat.ts | 140 +++++ packages/stage/src/stores/settings.ts | 21 +- 15 files changed, 765 insertions(+), 591 deletions(-) create mode 100644 packages/stage/src/components/Layouts/Header.vue rename packages/stage/src/components/{Live2DViewer.vue => Live2D/Viewer.vue} (100%) delete mode 100644 packages/stage/src/components/MainStage.vue rename packages/stage/src/components/{Live2DScene.vue => Scenes/Live2D.vue} (80%) rename packages/stage/src/components/{ThreeDScene.vue => Scenes/VRM.vue} (97%) rename packages/stage/src/components/{VRMModel.vue => VRM/Model.vue} (93%) create mode 100644 packages/stage/src/components/Widgets/ChatHistory.vue create mode 100644 packages/stage/src/components/Widgets/InputArea.vue create mode 100644 packages/stage/src/components/Widgets/Stage.vue create mode 100644 packages/stage/src/stores/chat.ts diff --git a/packages/stage/src/auto-imports.d.ts b/packages/stage/src/auto-imports.d.ts index 24e32bd..690ddc7 100644 --- a/packages/stage/src/auto-imports.d.ts +++ b/packages/stage/src/auto-imports.d.ts @@ -138,6 +138,8 @@ declare global { const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel'] const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation'] const useCached: typeof import('@vueuse/core')['useCached'] + const useChat: typeof import('./composables/chat')['useChat'] + const useChatStore: typeof import('./stores/chat')['useChatStore'] const useClipboard: typeof import('@vueuse/core')['useClipboard'] const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems'] const useCloned: typeof import('@vueuse/core')['useCloned'] @@ -250,6 +252,8 @@ declare global { const useShare: typeof import('@vueuse/core')['useShare'] const useSlots: typeof import('vue')['useSlots'] const useSorted: typeof import('@vueuse/core')['useSorted'] + const useSpeak: typeof import('./stores/audio')['useSpeak'] + const useSpeakingStore: typeof import('./stores/audio')['useSpeakingStore'] const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition'] const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis'] const useStepper: typeof import('@vueuse/core')['useStepper'] @@ -448,6 +452,7 @@ declare module 'vue' { readonly useBroadcastChannel: UnwrapRef readonly useBrowserLocation: UnwrapRef readonly useCached: UnwrapRef + readonly useChatStore: UnwrapRef readonly useClipboard: UnwrapRef readonly useClipboardItems: UnwrapRef readonly useCloned: UnwrapRef @@ -560,6 +565,7 @@ declare module 'vue' { readonly useShare: UnwrapRef readonly useSlots: UnwrapRef readonly useSorted: UnwrapRef + readonly useSpeakingStore: UnwrapRef readonly useSpeechRecognition: UnwrapRef readonly useSpeechSynthesis: UnwrapRef readonly useStepper: UnwrapRef diff --git a/packages/stage/src/components/Layouts/Header.vue b/packages/stage/src/components/Layouts/Header.vue new file mode 100644 index 0000000..f720a99 --- /dev/null +++ b/packages/stage/src/components/Layouts/Header.vue @@ -0,0 +1,15 @@ + + + diff --git a/packages/stage/src/components/Live2DViewer.vue b/packages/stage/src/components/Live2D/Viewer.vue similarity index 100% rename from packages/stage/src/components/Live2DViewer.vue rename to packages/stage/src/components/Live2D/Viewer.vue diff --git a/packages/stage/src/components/MainStage.vue b/packages/stage/src/components/MainStage.vue deleted file mode 100644 index 6944ed5..0000000 --- a/packages/stage/src/components/MainStage.vue +++ /dev/null @@ -1,578 +0,0 @@ - - - - - diff --git a/packages/stage/src/components/Live2DScene.vue b/packages/stage/src/components/Scenes/Live2D.vue similarity index 80% rename from packages/stage/src/components/Live2DScene.vue rename to packages/stage/src/components/Scenes/Live2D.vue index ccd7288..f193f1c 100644 --- a/packages/stage/src/components/Live2DScene.vue +++ b/packages/stage/src/components/Scenes/Live2D.vue @@ -1,5 +1,16 @@ diff --git a/packages/stage/src/components/ThreeDScene.vue b/packages/stage/src/components/Scenes/VRM.vue similarity index 97% rename from packages/stage/src/components/ThreeDScene.vue rename to packages/stage/src/components/Scenes/VRM.vue index a286a21..96bf4a5 100644 --- a/packages/stage/src/components/ThreeDScene.vue +++ b/packages/stage/src/components/Scenes/VRM.vue @@ -2,9 +2,9 @@ import { OrbitControls } from '@tresjs/cientos' import { TresCanvas } from '@tresjs/core' -import Collapsable from './Collapsable.vue' -import DataGuiRange from './DataGui/Range.vue' -import VRMModel from './VRMModel.vue' +import Collapsable from '../Collapsable.vue' +import DataGuiRange from '../DataGui/Range.vue' +import VRMModel from '../VRM/Model.vue' const props = defineProps<{ model: string diff --git a/packages/stage/src/components/Screen.vue b/packages/stage/src/components/Screen.vue index 914cf76..43a72c6 100644 --- a/packages/stage/src/components/Screen.vue +++ b/packages/stage/src/components/Screen.vue @@ -51,6 +51,6 @@ onMounted(async () => { diff --git a/packages/stage/src/components/VRMModel.vue b/packages/stage/src/components/VRM/Model.vue similarity index 93% rename from packages/stage/src/components/VRMModel.vue rename to packages/stage/src/components/VRM/Model.vue index 7ccc340..2e4098b 100644 --- a/packages/stage/src/components/VRMModel.vue +++ b/packages/stage/src/components/VRM/Model.vue @@ -2,9 +2,10 @@ import type { VRMCore } from '@pixiv/three-vrm-core' import { useLoop, useTresContext } from '@tresjs/core' import { AnimationMixer } from 'three' -import { clipFromVRMAnimation, loadVRMAnimation, useBlink } from '~/composables/vrm/animation' -import { loadVrm } from '~/composables/vrm/core' -import { useVRMEmote } from '~/composables/vrm/expression' + +import { clipFromVRMAnimation, loadVRMAnimation, useBlink } from '../../composables/vrm/animation' +import { loadVrm } from '../../composables/vrm/core' +import { useVRMEmote } from '../../composables/vrm/expression' const props = defineProps<{ model: string diff --git a/packages/stage/src/components/Widgets/ChatHistory.vue b/packages/stage/src/components/Widgets/ChatHistory.vue new file mode 100644 index 0000000..edcce19 --- /dev/null +++ b/packages/stage/src/components/Widgets/ChatHistory.vue @@ -0,0 +1,99 @@ + + + diff --git a/packages/stage/src/components/Widgets/InputArea.vue b/packages/stage/src/components/Widgets/InputArea.vue new file mode 100644 index 0000000..b7536ad --- /dev/null +++ b/packages/stage/src/components/Widgets/InputArea.vue @@ -0,0 +1,244 @@ + + + diff --git a/packages/stage/src/components/Widgets/Stage.vue b/packages/stage/src/components/Widgets/Stage.vue new file mode 100644 index 0000000..15bf81b --- /dev/null +++ b/packages/stage/src/components/Widgets/Stage.vue @@ -0,0 +1,184 @@ + + + diff --git a/packages/stage/src/pages/index.vue b/packages/stage/src/pages/index.vue index 52115fa..b7adcc8 100644 --- a/packages/stage/src/pages/index.vue +++ b/packages/stage/src/pages/index.vue @@ -1,6 +1,18 @@ + + diff --git a/packages/stage/src/stores/audio.ts b/packages/stage/src/stores/audio.ts index 55fb89a..81a7287 100644 --- a/packages/stage/src/stores/audio.ts +++ b/packages/stage/src/stores/audio.ts @@ -71,3 +71,24 @@ export const useAudioContext = defineStore('AudioContext', () => { calculateVolume, } }) + +export const useSpeakingStore = defineStore('SpeakingStore', () => { + const nowSpeakingAvatarBorderOpacityMin = 30 + const nowSpeakingAvatarBorderOpacityMax = 100 + const mouthOpenSize = ref(0) + const nowSpeaking = ref(false) + + const nowSpeakingAvatarBorderOpacity = computed(() => { + if (!nowSpeaking.value) + return nowSpeakingAvatarBorderOpacityMin + + return ((nowSpeakingAvatarBorderOpacityMin + + (nowSpeakingAvatarBorderOpacityMax - nowSpeakingAvatarBorderOpacityMin) * mouthOpenSize.value) / 100) + }) + + return { + mouthOpenSize, + nowSpeaking, + nowSpeakingAvatarBorderOpacity, + } +}) diff --git a/packages/stage/src/stores/chat.ts b/packages/stage/src/stores/chat.ts new file mode 100644 index 0000000..fbdb237 --- /dev/null +++ b/packages/stage/src/stores/chat.ts @@ -0,0 +1,140 @@ +import type { AssistantMessage, Message } from '@xsai/shared-chat-completion' + +import { defineStore, storeToRefs } from 'pinia' +import SystemPromptV2 from '../constants/prompts/system-v2' +import { useLLM } from '../stores/llm' +import { asyncIteratorFromReadableStream } from '../utils/iterator' + +export const useChatStore = defineStore('chat', () => { + const { stream } = useLLM() + const { t } = useI18n() + const { openAiApiBaseURL, openAiApiKey, openAiModel } = storeToRefs(useSettings()) + + const onBeforeMessageComposedHooks = ref Promise>>([]) + const onAfterMessageComposedHooks = ref Promise>>([]) + const onBeforeSendHooks = ref Promise>>([]) + const onAfterSendHooks = ref Promise>>([]) + const onTokenLiteralHooks = ref Promise>>([]) + const onTokenSpecialHooks = ref Promise>>([]) + const onStreamEndHooks = ref Promise>>([]) + + function onBeforeMessageComposed(cb: (message: string) => Promise) { + onBeforeMessageComposedHooks.value.push(cb) + } + + function onAfterMessageComposed(cb: (message: string) => Promise) { + onAfterMessageComposedHooks.value.push(cb) + } + + function onBeforeSend(cb: (message: string) => Promise) { + onBeforeSendHooks.value.push(cb) + } + + function onAfterSend(cb: (message: string) => Promise) { + onAfterSendHooks.value.push(cb) + } + + function onTokenLiteral(cb: (literal: string) => Promise) { + onTokenLiteralHooks.value.push(cb) + } + + function onTokenSpecial(cb: (special: string) => Promise) { + onTokenSpecialHooks.value.push(cb) + } + + function onStreamEnd(cb: () => Promise) { + onStreamEndHooks.value.push(cb) + } + + const messages = ref>([ + SystemPromptV2( + t('prompt.prefix'), + t('prompt.suffix'), + ), + ]) + const streamingMessage = ref({ role: 'assistant', content: '' }) + + async function send(sendingMessage: string, options?: { + baseUrl?: string + apiKey?: string + model?: { id: string } + }) { + if (!sendingMessage) + return + + for (const hook of onBeforeMessageComposedHooks.value) { + await hook(sendingMessage) + } + + const { + baseUrl = openAiApiBaseURL.value, + apiKey = openAiApiKey.value, + model = openAiModel.value, + } = options ?? { } + + streamingMessage.value = { role: 'assistant', content: '' } + messages.value.push({ role: 'user', content: sendingMessage }) + messages.value.push(streamingMessage.value) + const newMessages = messages.value.slice(0, messages.value.length - 1) + + for (const hook of onAfterMessageComposedHooks.value) { + await hook(sendingMessage) + } + + for (const hook of onBeforeSendHooks.value) { + await hook(sendingMessage) + } + + const res = await stream(baseUrl, apiKey, model.id, newMessages) + + for (const hook of onAfterSendHooks.value) { + await hook(sendingMessage) + } + + let fullText = '' + + const parser = useLlmmarkerParser({ + onLiteral: async (literal) => { + for (const hook of onTokenLiteralHooks.value) { + await hook(literal) + } + + streamingMessage.value.content += literal + }, + onSpecial: async (special) => { + for (const hook of onTokenSpecialHooks.value) { + await hook(special) + } + + streamingMessage.value.content += special + }, + }) + + for await (const textPart of asyncIteratorFromReadableStream(res.textStream, async v => v)) { + fullText += textPart + await parser.consume(textPart) + } + + await parser.end() + + for (const hook of onStreamEndHooks.value) { + await hook() + } + + // eslint-disable-next-line no-console + console.debug('LLM output:', fullText) + } + + return { + messages, + streamingMessage, + send, + onBeforeMessageComposed, + onAfterMessageComposed, + onBeforeSend, + onAfterSend, + onTokenLiteral, + onTokenSpecial, + onStreamEnd, + } +}) diff --git a/packages/stage/src/stores/settings.ts b/packages/stage/src/stores/settings.ts index fac21f9..569bb1d 100644 --- a/packages/stage/src/stores/settings.ts +++ b/packages/stage/src/stores/settings.ts @@ -6,16 +6,35 @@ export const useSettings = defineStore('settings', () => { const openAiApiKey = useLocalStorage('settings/credentials/openai-api-key', '') const openAiApiBaseURL = useLocalStorage('settings/credentials/openai-api-base-url', '') const elevenLabsApiKey = useLocalStorage('settings/credentials/elevenlabs-api-key', '') - const language = useLocalStorage('settings/language', 'en') + + const language = useLocalStorage('settings/language', 'en-US') const stageView = useLocalStorage('settings/stage/view/model-renderer', '2d') + const openAiModel = useLocalStorage<{ id: string, name?: string }>('settings/llm/openai/model', { id: 'openai/gpt-3.5-turbo', name: 'OpenAI GPT3.5 Turbo' }) + const isAudioInputOn = useLocalStorage('settings/audio/input', 'true') + const selectedAudioDevice = useLocalStorage('settings/audio/input/device', undefined) + const selectedAudioDeviceId = computed(() => selectedAudioDevice.value?.deviceId) + const { audioInputs } = useDevicesList({ constraints: { audio: true }, requestPermissions: true }) + + watch(isAudioInputOn, async (value) => { + if (value === 'false') { + selectedAudioDevice.value = undefined + } + if (value === 'true') { + selectedAudioDevice.value = audioInputs.value[0] + } + }) watch(language, value => i18n.global.locale.value = value) return { openAiApiKey, openAiApiBaseURL, + openAiModel, elevenLabsApiKey, language, stageView, + isAudioInputOn, + selectedAudioDevice, + selectedAudioDeviceId, } })