feat(ui): added core system error message

Signed-off-by: Neko Ayaka <neko@ayaka.moe>
This commit is contained in:
Neko Ayaka
2025-03-04 00:55:06 +08:00
parent 0528239bfd
commit f52b8d85ac
12 changed files with 287 additions and 124 deletions
@@ -79,6 +79,7 @@ stage:
character-name:
airi: Airi
you: You
core-system: Core System
tabs:
chat: Chat
clothes: Clothes
@@ -66,3 +66,18 @@ stage:
select-a-model: 选择一个模型
select-a-voice: 选择一个声线
waiting: 等待中
chat:
message:
character-name:
airi: Airi
you:
core-system: 核心系统
tabs:
chat: 聊天
clothes: 换装
custom: 自定义
operations:
load-models: 加载推理模型
load-models-status:
loading: 加载中
ready: 已就绪
@@ -7,7 +7,7 @@ import { nextTick, ref } from 'vue'
const chatHistoryRef = ref<HTMLDivElement>()
const { messages } = storeToRefs(useChatStore())
const { messages, sending } = storeToRefs(useChatStore())
const bounding = useElementBounding(chatHistoryRef, { immediate: true, windowScroll: true, windowResize: true })
const { y: chatHistoryContainerY } = useScroll(chatHistoryRef)
@@ -34,38 +34,58 @@ onTokenLiteral(async () => {
<template>
<div py="1" flex="~ col" rounded="lg" overflow-hidden>
<div flex-1 /> <!-- spacer -->
<div ref="chatHistoryRef" v-auto-animate max-h="30vh" flex="~ col" scrollbar="~ w-2 track-color-transparent thumb-color-pink-300 rounded" h-full w-full overflow-scroll>
<div
ref="chatHistoryRef" v-auto-animate max-h="30vh" flex="~ col"
scrollbar="~ w-2 track-color-transparent thumb-color-pink-300 rounded" h-full w-full overflow-scroll
>
<div flex-1 /> <!-- spacer -->
<div v-for="(message, index) in messages" :key="index" mb-2>
<div v-if="message.role === 'error'" flex mr="12">
<div
flex="~ col" border="4 solid violet-200/50 dark:violet-500/50" shadow="md violet-200/50 dark:none"
min-w-20 rounded-lg px-2 py-1 h="unset <sm:fit" bg="<md:violet-500/25"
>
<div flex="~ row" gap-2>
<div flex-1>
<span text-xs text="violet-400/90 dark:violet-600/90" font-semibold class="inline <sm:hidden">{{
$t('stage.chat.message.character-name.core-system') }}</span>
</div>
<div i-solar:danger-triangle-bold-duotone text-violet-500 />
</div>
<div v-if="sending" i-eos-icons:three-dots-loading />
<div
v-else class="markdown-content text-violet-500" text="base <sm:xs"
v-html="process(message.content as string)"
/>
</div>
</div>
<div v-if="message.role === 'assistant'" flex mr="12">
<div
flex="~ col"
border="4 solid pink-200"
shadow="md pink-200/50"
min-w-20 rounded-lg px-2 py-1
h="fit"
flex="~ col" border="4 solid pink-200" shadow="md pink-200/50" min-w-20 rounded-lg px-2 py-1 h="fit"
bg="pink-100"
>
<div>
<span text-xs text="pink-400/90" font-semibold class="inline hidden">Airi</span>
</div>
<div v-if="message.content" class="markdown-content" text="xs pink-400" v-html="process(message.content as string)" />
<div
v-if="message.content" class="markdown-content" text="xs pink-400"
v-html="process(message.content as string)"
/>
<div v-else i-eos-icons:three-dots-loading />
</div>
</div>
<div v-else-if="message.role === 'user'" flex="~">
<div
flex="~ col"
border="4 solid cyan-200"
shadow="md cyan-200/50"
px="2"
h="fit" min-w-20 rounded-lg px-2 py-1
bg="cyan-100"
flex="~ col" border="4 solid cyan-200" shadow="md cyan-200/50" px="2" h="fit" min-w-20 rounded-lg px-2
py-1 bg="cyan-100"
>
<div>
<span text-xs text="cyan-600/90" font-semibold class="hidden">You</span>
</div>
<div v-if="message.content" class="markdown-content" text="xs cyan-600" v-html="process(message.content as string)" />
<div
v-if="message.content" class="markdown-content" text="xs cyan-600"
v-html="process(message.content as string)"
/>
<div v-else />
</div>
</div>
@@ -15,6 +15,7 @@ const listening = ref(false)
// const { selectedAudioDevice, isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { send, onAfterSend } = useChatStore()
const { messages } = storeToRefs(useChatStore())
const { t } = useI18n()
async function handleSend() {
@@ -22,7 +23,16 @@ async function handleSend() {
return
}
await send(messageInput.value)
try {
await send(messageInput.value)
}
catch (error) {
messages.value.pop()
messages.value.push({
role: 'error',
content: (error as Error).message,
})
}
}
const { destroy, start } = useMicVAD(selectedAudioDeviceId, {
+13 -12
View File
@@ -51,21 +51,21 @@ settings:
chinese: 简体中文
english: English
title: Language
live2d:
change-model:
from-file: Load from File
from-file-select: Select
from-url: Load from URL
from-url-confirm: Load
from-url-placeholder: Enter Live2D model URL
title: Change Model
map-motions:
play: Play Motion
title: Map Motions
title: Live2D Settings
microphone: Microphone
model-provider:
title: Model Providers
live2d:
title: Live2D Settings
change-model:
title: Change Model
from-url: Load from URL
from-url-placeholder: Enter Live2D model URL
from-url-confirm: Load
from-file: Load from File
from-file-select: Select
map-motions:
title: Map Motions
play: Play Motion
models: Model
openai-api-key:
label: OpenAI API Key
@@ -83,6 +83,7 @@ stage:
message:
character-name:
airi: Airi
core-system: Core System
you: You
tabs:
chat: Chat
+22 -8
View File
@@ -39,20 +39,21 @@ settings:
chinese: 简体中文
english: English
title: 语言
model-provider:
title: 模型提供商
live2d:
title: Live2D 设置
change-model:
title: 更换模型
from-url: 从 URL 加载
from-url-placeholder: 输入 Live2D 模型 URL
from-url-confirm: 加载
from-file: 从文件加载
from-file-select: 选择
from-url: 从 URL 加载
from-url-confirm: 加载
from-url-placeholder: 输入 Live2D 模型 URL
title: 更换模型
map-motions:
title: 映射动作
play: 播放动作
title: 映射动作
title: Live2D 设置
microphone: 麦克风
model-provider:
title: 模型提供商
models: 模型
openai-api-key:
label: OpenAI API 密钥
@@ -70,9 +71,22 @@ stage:
message:
character-name:
airi: Airi
core-system: 核心系统
you:
tabs:
chat: 聊天
clothes: 换装
custom: 自定义
message: 消息
operations:
load-models: 加载推理模型
load-models-status:
loading: 加载中
ready: 已就绪
select-a-audio-input: 选择一个音频输入设备
select-a-model: 选择一个模型
select-a-voice: 选择一个声线
viewers:
debug-menu:
emotions: 表情
waiting: 等待中
@@ -19,17 +19,35 @@ const showMicrophoneSelect = ref(false)
const { audioInputs } = useDevicesList({ constraints: { audio: true }, requestPermissions: true })
const { selectedAudioDevice, isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { send, onAfterSend } = useChatStore()
const { messages } = storeToRefs(useChatStore())
const { audioContext } = useAudioContext()
const { t } = useI18n()
const { transcribe: generate, load: loadWhisper, status: whisperStatus, terminate } = useWhisper(WhisperWorker, {
onComplete: async (res) => {
if (!res || !res.trim()) {
return
}
await send(res)
},
})
async function handleSend() {
await send(messageInput.value)
if (!messageInput.value.trim()) {
return
}
try {
await send(messageInput.value)
}
catch (error) {
messages.value.pop()
messages.value.push({
role: 'error',
content: (error as Error).message,
})
}
}
const { destroy, start } = useMicVAD(selectedAudioDeviceId, {
@@ -7,7 +7,7 @@ import { nextTick, ref } from 'vue'
const chatHistoryRef = ref<HTMLDivElement>()
const { messages } = storeToRefs(useChatStore())
const { messages, sending } = storeToRefs(useChatStore())
const bounding = useElementBounding(chatHistoryRef, { immediate: true, windowScroll: true, windowResize: true })
const { y: chatHistoryContainerY } = useScroll(chatHistoryRef)
@@ -32,43 +32,54 @@ onTokenLiteral(async () => {
</script>
<template>
<div
relative px="<sm:2" py="<sm:2" flex="~ col" rounded="lg" overflow-hidden
>
<div relative px="<sm:2" py="<sm:2" flex="~ col" rounded="lg" overflow-hidden>
<div flex-1 /> <!-- spacer -->
<div ref="chatHistoryRef" v-auto-animate h-full w-full flex="~ col" overflow-scroll>
<div flex-1 /> <!-- spacer -->
<div v-for="(message, index) in messages" :key="index" mb-2>
<div v-if="message.role === 'error'" flex mr="12">
<div
flex="~ col" border="4 solid violet-200/50 dark:violet-500/50" shadow="md violet-200/50 dark:none"
min-w-20 rounded-lg px-2 py-1 h="unset <sm:fit" bg="<md:violet-500/25"
>
<div flex="~ row" gap-2>
<div flex-1>
<span text-xs text="violet-400/90 dark:violet-600/90" font-semibold class="inline <sm:hidden">{{
$t('stage.chat.message.character-name.core-system') }}</span>
</div>
<div i-solar:danger-triangle-bold-duotone text-violet-500 />
</div>
<div v-if="sending" i-eos-icons:three-dots-loading />
<div
v-else class="markdown-content text-violet-500" text="base <sm:xs"
v-html="process(message.content as string)"
/>
</div>
</div>
<div v-if="message.role === 'assistant'" flex mr="12">
<div
flex="~ col"
border="4 solid pink-200/50 dark:pink-500/50"
shadow="md pink-200/50 dark:none"
min-w-20 rounded-lg px-2 py-1
h="unset <sm:fit"
bg="<md:pink-500/25"
flex="~ col" border="4 solid pink-200/50 dark:pink-500/50" shadow="md pink-200/50 dark:none" min-w-20
rounded-lg px-2 py-1 h="unset <sm:fit" bg="<md:pink-500/25"
>
<div>
<span text-xs text="pink-400/90 dark:pink-600/90" font-semibold class="inline <sm:hidden">{{ $t('stage.chat.message.character-name.airi') }}</span>
<span text-xs text="pink-400/90 dark:pink-600/90" font-semibold class="inline <sm:hidden">{{
$t('stage.chat.message.character-name.airi') }}</span>
</div>
<div v-if="message.content" class="markdown-content" text="base <sm:xs" v-html="process(message.content as string)" />
<div v-else i-eos-icons:three-dots-loading />
<div v-if="sending" i-eos-icons:three-dots-loading />
<div v-else class="markdown-content" text="base <sm:xs" v-html="process(message.content as string)" />
</div>
</div>
<div v-else-if="message.role === 'user'" flex="~ row-reverse" ml="12">
<div
flex="~ col"
border="4 solid cyan-200/50 dark:cyan-500/50"
shadow="md cyan-200/50 dark:none"
px="2"
h="unset <sm:fit" min-w-20 rounded-lg px-2 py-1
bg="<md:cyan-500/25"
flex="~ col" border="4 solid cyan-200/50 dark:cyan-500/50" shadow="md cyan-200/50 dark:none" px="2"
h="unset <sm:fit" min-w-20 rounded-lg px-2 py-1 bg="<md:cyan-500/25"
>
<div>
<span text-xs text="cyan-400/90 dark:cyan-600/90" font-semibold class="inline <sm:hidden">{{ $t('stage.chat.message.character-name.you') }}</span>
<span text-xs text="cyan-400/90 dark:cyan-600/90" font-semibold class="inline <sm:hidden">{{
$t('stage.chat.message.character-name.you') }}</span>
</div>
<div v-if="message.content" class="markdown-content" text="base <sm:xs" v-html="process(message.content as string)" />
<div v-else />
<div v-if="sending" i-eos-icons:three-dots-loading />
<div v-else class="markdown-content" text="base <sm:xs" v-html="process(message.content as string)" />
</div>
</div>
</div>
@@ -203,6 +203,58 @@ function addRecordEntry(entries: Array<[string, string]>) {
function removeRecordEntry(entries: Array<[string, string]>, index: number) {
entries.splice(index, 1)
}
// Helper function to get default value from field schema
function getDefaultValue(field: any, _fieldName: string) {
// Check if field has a default value defined
if (field.default !== undefined) {
return field.default
}
// For record types, return an empty array (for the key-value pairs)
if (field.type === 'object' && field.additionalProperties) {
return []
}
// Return appropriate empty values based on field type
switch (field.type) {
case 'string':
return ''
case 'number':
case 'integer':
return 0
case 'boolean':
return false
case 'array':
return []
case 'object':
return {}
default:
return ''
}
}
// Initialize field with default value if not already set
function initFieldWithDefault(providerId: string, fieldName: string, field: any) {
const currentValue = providerValues.value[providerId]?.[fieldName]
// Only set default if field doesn't already have a value
if (currentValue === undefined) {
const defaultValue = getDefaultValue(field, fieldName)
setFieldValue(providerId, fieldName, defaultValue)
}
}
// Initialize all fields with defaults when provider is expanded
function initProviderDefaults(providerId: string) {
const provider = providers.value.find(p => p.id === providerId)
if (!provider || !provider.fields.properties)
return
Object.entries(provider.fields.properties).forEach(([fieldName, field]) => {
initFieldWithDefault(providerId, fieldName, field)
})
}
</script>
<template>
@@ -216,7 +268,7 @@ function removeRecordEntry(entries: Array<[string, string]>, index: number) {
transition="all ease-in-out duration-250"
w-full flex items-center gap-1.5 rounded-lg px-4 py-3 outline-none
class="[&_.provider-icon]:grayscale-100 [&_.provider-icon]:hover:grayscale-0"
@click="slotProps.setVisible(!slotProps.visible)"
@click="slotProps.setVisible(!slotProps.visible) && initProviderDefaults(provider.id)"
>
<div flex="~ row 1" items-center gap-1.5>
<div
@@ -16,6 +16,7 @@ watchEffect(() => {
function setVisible(value: boolean) {
visible.value = value
return value
}
</script>
+79 -64
View File
@@ -10,11 +10,18 @@ import { useLLM } from '../stores/llm'
import { useProvidersStore } from '../stores/providers'
import { asyncIteratorFromReadableStream } from '../utils/iterator'
export interface ErrorMessage {
role: 'error'
content: string
}
export const useChatStore = defineStore('chat', () => {
const { stream } = useLLM()
const { t } = useI18n()
const { providers: providerValues } = storeToRefs(useProvidersStore())
const sending = ref(false)
const onBeforeMessageComposedHooks = ref<Array<(message: string) => Promise<void>>>([])
const onAfterMessageComposedHooks = ref<Array<(message: string) => Promise<void>>>([])
const onBeforeSendHooks = ref<Array<(message: string) => Promise<void>>>([])
@@ -56,7 +63,7 @@ export const useChatStore = defineStore('chat', () => {
onAssistantResponseEndHooks.value.push(cb)
}
const messages = ref<Array<Message>>([
const messages = ref<Array<Message | ErrorMessage>>([
SystemPromptV2(
t('prompt.prefix'),
t('prompt.suffix'),
@@ -70,75 +77,83 @@ export const useChatStore = defineStore('chat', () => {
apiKey?: string
model?: { id: string }
}) {
if (!sendingMessage)
return
try {
sending.value = true
for (const hook of onBeforeMessageComposedHooks.value) {
await hook(sendingMessage)
if (!sendingMessage)
return
for (const hook of onBeforeMessageComposedHooks.value) {
await hook(sendingMessage)
}
const {
baseUrl = providerValues.value['openrouter-ai']?.baseUrl as string | undefined || '',
apiKey = providerValues.value['openrouter-ai']?.apiKey as string | undefined || '',
model = providerValues.value['openrouter-ai']?.model as { id: string } | undefined || { id: 'openai/gpt-4o-mini' },
} = 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).map(msg => toRaw(msg))
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 as Message[])
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)
}
},
})
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()
}
for (const hook of onAssistantResponseEndHooks.value) {
await hook(fullText)
}
// eslint-disable-next-line no-console
console.debug('LLM output:', fullText)
}
const {
baseUrl = providerValues.value['openrouter-ai']?.baseUrl as string | undefined || '',
apiKey = providerValues.value['openrouter-ai']?.apiKey as string | undefined || '',
model = providerValues.value['openrouter-ai']?.model as { id: string } | undefined || { id: 'openai/gpt-4o-mini' },
} = 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).map(msg => toRaw(msg))
for (const hook of onAfterMessageComposedHooks.value) {
await hook(sendingMessage)
finally {
sending.value = false
}
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)
}
},
})
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()
}
for (const hook of onAssistantResponseEndHooks.value) {
await hook(fullText)
}
// eslint-disable-next-line no-console
console.debug('LLM output:', fullText)
}
return {
sending,
messages,
streamingMessage,
send,
@@ -4,7 +4,12 @@ import { defineStore } from 'pinia'
export const useProvidersStore = defineStore('providers', () => {
const providers = useLocalStorage<Record<string, Record<string, unknown>>>('settings/credentials/providers', {})
const coreControllerProvider = useLocalStorage<string>('settings/credentials/coreControllerProvider', 'openai')
const audioSynthesisProvider = useLocalStorage<string>('settings/credentials/audioSynthesisProvider', 'elevenlabs')
return {
providers,
coreControllerProvider,
audioSynthesisProvider,
}
})