refactor(stage-web): better ui

Co-authored-by: Rizumu Ayaka <19204772+LittleSound@users.noreply.github.com>
This commit is contained in:
Neko Ayaka
2025-02-22 21:44:04 +08:00
co-authored by Rizumu Ayaka
parent e993849a5f
commit 9011d3e4ac
6 changed files with 324 additions and 46 deletions
+3
View File
@@ -39,12 +39,14 @@
"@pixiv/three-vrm-core": "^3.3.4",
"@proj-airi/stage-ui": "workspace:^",
"@ricky0123/vad-web": "^0.0.22",
"@standard-schema/spec": "^1.0.0",
"@tresjs/cientos": "^4.1.0",
"@tresjs/core": "^4.3.3",
"@types/yauzl": "^2.10.3",
"@typeschema/valibot": "^0.14.0",
"@unhead/vue": "^2.0.0-alpha.16",
"@unocss/reset": "^66.0.0",
"@valibot/to-json-schema": "1.0.0-rc.0",
"@vueuse/core": "^12.7.0",
"@vueuse/head": "^2.0.0",
"@vueuse/shared": "^12.7.0",
@@ -84,6 +86,7 @@
"@iconify-json/eos-icons": "^1.2.2",
"@iconify-json/lucide": "^1.2.26",
"@iconify-json/mingcute": "^1.2.3",
"@iconify-json/simple-icons": "^1.2.25",
"@iconify-json/solar": "^1.2.2",
"@iconify-json/svg-spinners": "^1.2.2",
"@iconify/utils": "^2.3.0",
@@ -24,7 +24,7 @@ const isOpen = ref(false)
class="max-w-40% min-w-500px w-full"
flex="~ col"
bg="white dark:zinc-900"
fixed inset-y-4 right-4 z-50 of-hidden rounded-lg
fixed inset-y-4 right-4 z-50 of-hidden rounded-lg outline-none
>
<div flex="~ 1 col gap-2" of-y-scroll rounded-t-lg p-5>
<MobileSettings />
@@ -35,10 +35,10 @@ function navigateBack() {
<Transition :name="slideDirection === 'forward' ? 'slide-forward' : 'slide-backward'">
<!-- Main Settings View -->
<div v-if="currentView === 'main'" key="main">
<h2 text="zinc-800/80 dark:zinc-200/80 xl" font-bold>
<h2 text="zinc-800/80 dark:zinc-200/80 xl" mb-4 font-bold>
{{ t('settings.title') }}
</h2>
<div my-2>
<div>
<!-- Model Providers Navigation Item -->
<div class="space-y-2">
<div
@@ -1,56 +1,227 @@
<script setup lang="ts">
import { Collapsable } from '@proj-airi/stage-ui/components'
import { ref } from 'vue'
import { toJsonSchema } from '@valibot/to-json-schema'
import { description, object, optional, pipe, record, string, title } from 'valibot'
import { computed, ref } from 'vue'
interface ModelProvider {
id: string
name: string
icon?: string
models: {
id: string
name: string
capabilities: string[]
}[]
fields: ReturnType<typeof toJsonSchema>
}
const providers = ref<ModelProvider[]>([
const providers = computed<ModelProvider[]>(() => [
{
id: 'openrouter-ai',
name: 'OpenRouter',
icon: 'i-lobe-icons:openrouter',
fields: toJsonSchema(
object({
apiKey: pipe(string(), title('API Key'), description('API Key for OpenRouter')),
baseUrl: optional(pipe(string(), title('Base URL'), description('Custom base URL (optional)')), 'https://openrouter.ai/api/v1/'),
}),
),
},
{
id: 'openai',
name: 'OpenAI',
icon: 'i-lobe-icons:openai',
models: [
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo', capabilities: ['chat', 'vision'] },
{ id: 'gpt-4', name: 'GPT-4', capabilities: ['chat'] },
{ id: 'whisper-1', name: 'Whisper', capabilities: ['stt'] },
],
fields: toJsonSchema(
object({
apiKey: pipe(string(), title('API Key'), description('API Key for OpenAI services')),
baseUrl: optional(pipe(string(), title('Base URL'), description('Custom base URL (optional)')), 'https://api.openai.com/v1/'),
}),
),
},
{
id: 'openrouter',
name: 'OpenRouter',
icon: 'i-lobe-icons:openrouter',
models: [
{ id: 'anthropic/claude-3-opus', name: 'Claude 3 Opus', capabilities: ['chat', 'vision'] },
{ id: 'google/gemini-pro', name: 'Gemini Pro', capabilities: ['chat'] },
],
id: 'ollama-ai',
name: 'Ollama',
icon: 'i-lobe-icons:ollama',
fields: toJsonSchema(
object({
baseUrl: optional(pipe(string(), title('Host'), description('Host of the Ollama instance (optional)'))),
extraHeaders: optional(pipe(record(string(), string()), title('Headers'), description('Custom Headers for Ollama instance (optional)'))),
}),
),
},
{
id: 'vllm',
name: 'vLLM',
icon: 'i-lobe-icons:vllm-color',
fields: toJsonSchema(
object({
baseUrl: optional(pipe(string(), title('Host'), description('Host of the vLLM instance (optional)'))),
apiKey: optional(pipe(string(), title('API Key'), description('API Key for vLLM'))),
extraHeaders: optional(pipe(record(string(), string()), title('Headers'), description('Custom Headers for vLLM instance (optional)'))),
}),
),
},
{
id: 'elevenlabs',
name: 'ElevenLabs',
icon: 'i-simple-icons:elevenlabs',
fields: toJsonSchema(
object({
apiKey: pipe(string(), title('API Key'), description('API Key for ElevenLabs')),
baseUrl: optional(pipe(string(), title('Base URL'), description('Custom base URL (optional)'))),
}),
),
},
{
id: 'xai',
name: 'xAI',
icon: 'i-lobe-icons:xai',
fields: toJsonSchema(
object({
apiKey: pipe(string(), title('API Key'), description('API Key for xAI')),
baseUrl: optional(pipe(string(), title('Base URL'), description('Custom base URL (optional)')), 'https://api.x.ai/v1/'),
}),
),
},
{
id: 'deepseek',
name: 'DeepSeek',
icon: 'i-lobe-icons:deepseek-color',
fields: toJsonSchema(
object({
apiKey: pipe(string(), title('API Key'), description('API Key for DeepSeek')),
baseUrl: optional(pipe(string(), title('Base URL'), description('Custom base URL (optional)')), 'https://api.deepseek.com/'),
}),
),
},
{
id: 'together-ai',
name: 'Together.ai',
icon: 'i-lobe-icons:together-color',
fields: toJsonSchema(
object({
apiKey: pipe(string(), title('API Key'), description('API Key for Together.ai')),
baseUrl: optional(pipe(string(), title('Base URL'), description('Custom base URL (optional)')), 'https://api.together.xyz/v1/'),
}),
),
},
{
id: 'novita-ai',
name: 'Novita',
icon: 'i-lobe-icons:novita-color',
fields: toJsonSchema(
object({
apiKey: pipe(string(), title('API Key'), description('API Key for Novita')),
baseUrl: optional(pipe(string(), title('Base URL'), description('Custom base URL (optional)')), 'https://api.novita.ai/v3/openai/'),
}),
),
},
{
id: 'fireworks-ai',
name: 'Fireworks.ai',
icon: 'i-lobe-icons:fireworks',
fields: toJsonSchema(
object({
apiKey: pipe(string(), title('API Key'), description('API Key for Fireworks.ai')),
baseUrl: optional(pipe(string(), title('Base URL'), description('Custom base URL (optional)')), 'https://api.fireworks.ai/inference/v1/'),
}),
),
},
{
id: 'cloudflare-workers-ai',
name: 'Cloudflare Workers AI',
icon: 'i-lobe-icons:cloudflare-color',
fields: toJsonSchema(
object({
apiKey: pipe(string(), title('API Key'), description('API Key')),
accountId: pipe(string(), title('Account ID'), description('Cloudflare Account ID')),
}),
),
},
{
id: 'mistral-ai',
name: 'Mistral',
icon: 'i-lobe-icons:mistral-color',
fields: toJsonSchema(
object({
apiKey: pipe(string(), title('API Key'), description('API Key for OpenRouter')),
baseUrl: optional(pipe(string(), title('Base URL'), description('Custom base URL (optional)')), 'https://api.mistral.ai/v1/'),
}),
),
},
{
id: 'moonshot-ai',
name: 'Moonshot AI',
icon: 'i-lobe-icons:moonshot',
fields: toJsonSchema(
object({
apiKey: pipe(string(), title('API Key'), description('API Key for OpenRouter')),
baseUrl: optional(pipe(string(), title('Base URL'), description('Custom base URL (optional)')), 'https://api.moonshot.cn/v1/'),
}),
),
},
])
const providerValues = ref<Record<string, Record<string, string>>>({})
function getFieldValue(providerId: string, fieldName: string): string {
return providerValues.value[providerId]?.[fieldName] || ''
}
function setFieldValue(providerId: string, fieldName: string, value: string) {
if (!providerValues.value[providerId]) {
providerValues.value[providerId] = {}
}
providerValues.value[providerId][fieldName] = value
}
function getRecordEntries(providerId: string, fieldName: string): Array<[string, string]> {
const value = providerValues.value[providerId]?.[fieldName]
if (!value)
return [['', '']]
try {
return Object.entries(JSON.parse(value))
}
catch {
return [['', '']]
}
}
function setRecordValue(providerId: string, fieldName: string, entries: Array<[string, string]>) {
const validEntries = entries.filter(([key, value]) => key || value)
if (validEntries.length === 0) {
delete providerValues.value[providerId]?.[fieldName]
return
}
const record = Object.fromEntries(validEntries)
setFieldValue(providerId, fieldName, JSON.stringify(record))
}
function addRecordEntry(entries: Array<[string, string]>) {
entries.push(['', ''])
}
function removeRecordEntry(entries: Array<[string, string]>, index: number) {
entries.splice(index, 1)
}
</script>
<template>
<div class="space-y-2">
<div v-for="(provider) in providers" :key="provider.id">
<div flex="~ col" gap-2>
<div v-for="provider in providers" :key="provider.id">
<Collapsable w-full>
<template #trigger="slotProps">
<button
bg="zinc-100 dark:zinc-800"
hover="bg-zinc-200 dark:bg-zinc-700"
transition="all ease-in-out duration-250"
w-full flex items-center gap-1.5 rounded-lg px-4 py-3
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)"
>
<div flex="~ row 1" items-center gap-1.5>
<div :class="provider.icon" class="size-6" />
<div class="font-medium">
<div
:class="provider.icon" class="provider-icon size-6"
transition="filter duration-250 ease-in-out"
/>
<div>
{{ provider.name }}
</div>
</div>
@@ -61,19 +232,92 @@ const providers = ref<ModelProvider[]>([
</template>
<div p-4>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="text-sm font-medium">
API Key
</div>
<input
class="rounded bg-zinc-200 px-2 py-1 text-sm dark:bg-zinc-800"
placeholder="Enter API key"
>
</div>
<div class="space-y-2">
<div class="text-sm font-medium">
Available Models
</div>
<!-- Dynamically render fields based on JSON Schema -->
<div
v-for="(field, fieldName) in provider.fields.properties"
:key="fieldName"
class="space-y-1"
>
<template v-if="typeof field !== 'boolean' && typeof fieldName !== 'number' && field.type === 'object' && field.additionalProperties">
<!-- Record field (key-value pairs) -->
<div>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-1 text-sm font-medium">
{{ field.title || fieldName }}
<span v-if="provider.fields.required?.includes(fieldName)" class="text-red-500">*</span>
</div>
<div v-if="field.description" class="text-xs text-zinc-500 dark:text-zinc-400">
{{ field.description }}
</div>
</div>
<button
class="text-sm"
@click="addRecordEntry(getRecordEntries(provider.id, fieldName))"
>
<div i-solar:add-circle-line-duotone />
</button>
</div>
<div class="mt-2 space-y-2">
<div
v-for="(_, index) in getRecordEntries(provider.id, fieldName)"
:key="index"
class="flex items-center gap-2"
>
<input
v-model="getRecordEntries(provider.id, fieldName)[index][0]"
type="text"
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
transition="border duration-250 ease-in-out"
flex-1 rounded px-2 py-1 text-sm outline-none
placeholder="Key"
@input="setRecordValue(provider.id, fieldName, getRecordEntries(provider.id, fieldName))"
>
<input
v-model="getRecordEntries(provider.id, fieldName)[index][1]"
type="text"
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
transition="border duration-250 ease-in-out"
flex-1 rounded px-2 py-1 text-sm outline-none
placeholder="Value"
@input="setRecordValue(provider.id, fieldName, getRecordEntries(provider.id, fieldName))"
>
<button
class="text-red-500 hover:text-red-600"
@click="removeRecordEntry(getRecordEntries(provider.id, fieldName), index)"
>
<div i-solar:trash-bin-trash-bold-duotone />
</button>
</div>
</div>
</div>
</template>
<template v-if="typeof field !== 'boolean' && typeof fieldName !== 'number' && field.type === 'string'">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-1 text-sm font-medium">
{{ field.title }}
<span
v-if="provider.fields.required?.includes(fieldName)"
class="text-red-500"
>*</span>
</div>
<div v-if="field.description" class="text-xs text-zinc-400 dark:text-zinc-600">
{{ field.description }}
</div>
</div>
<input
:type="fieldName.toLowerCase().includes('key') ? 'password' : 'text'"
:value="getFieldValue(provider.id, fieldName)"
rounded
border="zinc-300 dark:zinc-800 solid 1 focus:zinc-400 dark:focus:zinc-600"
transition="border duration-250 ease-in-out"
px-2 py-1 text-sm outline-none
:placeholder="(field.default && String(field.default)) || `Enter ${field.title || fieldName}`"
@input="(e) => setFieldValue(provider.id, fieldName, (e.target as HTMLInputElement).value)"
>
</div>
</template>
</div>
</div>
</div>
@@ -32,7 +32,7 @@ function setVisible(value: boolean) {
</button>
</slot>
<TransitionVertical>
<slot v-if="visible" />
<slot v-if="visible" v-bind="{ visible, setVisible }" />
</TransitionVertical>
</div>
</template>
+36 -5
View File
@@ -505,6 +505,9 @@ importers:
'@ricky0123/vad-web':
specifier: ^0.0.22
version: 0.0.22
'@standard-schema/spec':
specifier: ^1.0.0
version: 1.0.0
'@tresjs/cientos':
specifier: ^4.1.0
version: 4.1.0(@tresjs/core@4.3.3(three@0.173.0)(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)))(@types/three@0.173.0)(react@18.3.1)(three@0.173.0)(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))
@@ -523,6 +526,9 @@ importers:
'@unocss/reset':
specifier: ^66.0.0
version: 66.0.0
'@valibot/to-json-schema':
specifier: 1.0.0-rc.0
version: 1.0.0-rc.0(valibot@1.0.0-beta.9(typescript@5.7.3))
'@vueuse/core':
specifier: ^12.7.0
version: 12.7.0(typescript@5.7.3)
@@ -635,6 +641,9 @@ importers:
'@iconify-json/mingcute':
specifier: ^1.2.3
version: 1.2.3
'@iconify-json/simple-icons':
specifier: ^1.2.25
version: 1.2.25
'@iconify-json/solar':
specifier: ^1.2.2
version: 1.2.2
@@ -1102,7 +1111,7 @@ importers:
version: 0.1.0-beta.5
'@xsai/tool':
specifier: 'catalog:'
version: 0.1.0-beta.5(@xsai/generate-text@0.1.0-beta.5)(zod-to-json-schema@3.24.1(zod@3.24.2))
version: 0.1.0-beta.5(@valibot/to-json-schema@1.0.0-rc.0(valibot@1.0.0-beta.9(typescript@5.7.3)))(@xsai/generate-text@0.1.0-beta.5)(zod-to-json-schema@3.24.1(zod@3.24.2))
dotenv:
specifier: ^16.4.7
version: 16.4.7
@@ -2813,6 +2822,9 @@ packages:
'@iconify-json/mingcute@1.2.3':
resolution: {integrity: sha512-yiEQfLBF5iwyOdxuY0kEU06+8Mp6Mrp14KVXTb+5jjSVuD71C9EQrzM/mm1Efd8Nu2amJalTPisl3loC8pHBqQ==}
'@iconify-json/simple-icons@1.2.25':
resolution: {integrity: sha512-2E1/gOCO97rF6usfhhiXxwzCb+UhdEsxW3lW1Sew+xZY0COY6dp82Z/r1rUt2fWKneWjuoGcNeJHHXQyG8mIuw==}
'@iconify-json/solar@1.2.2':
resolution: {integrity: sha512-lcTb6DWL4HZObiY1W3fHfuxxuQHUc6CFHFeywKEx7Ry0k+dU6POZCMC7oVLr0F8vuf+KgaQ3oOoGO/yFzOwrNg==}
@@ -3944,6 +3956,9 @@ packages:
'@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@stylistic/eslint-plugin@4.0.1':
resolution: {integrity: sha512-RwKkRKiDrF4ptiur54ckDhOByQYKYZ1dEmI5K8BJCmuGpauFJXzVL1UQYTA2zq702CqMFdYiJcVFJWfokIgFxw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4436,6 +4451,11 @@ packages:
peerDependencies:
vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0
'@valibot/to-json-schema@1.0.0-rc.0':
resolution: {integrity: sha512-F3WDgnPzcDs9Y8qZwU9qfPnEJBQ6lCMCFjI7VsMjAza6yAixGr4cZ50gOy6zniSCk49GkFvq2a6cBKfZjTpyOw==}
peerDependencies:
valibot: ^1.0.0 || ^1.0.0-beta.5 || ^1.0.0-rc
'@vitejs/plugin-vue@5.2.1':
resolution: {integrity: sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -12729,6 +12749,10 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/simple-icons@1.2.25':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/solar@1.2.2':
dependencies:
'@iconify/types': 2.0.0
@@ -13915,6 +13939,8 @@ snapshots:
'@socket.io/component-emitter@3.1.2': {}
'@standard-schema/spec@1.0.0': {}
'@stylistic/eslint-plugin@4.0.1(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3)':
dependencies:
'@typescript-eslint/utils': 8.24.1(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3)
@@ -14510,6 +14536,10 @@ snapshots:
transitivePeerDependencies:
- vue
'@valibot/to-json-schema@1.0.0-rc.0(valibot@1.0.0-beta.9(typescript@5.7.3))':
dependencies:
valibot: 1.0.0-beta.9(typescript@5.7.3)
'@vitejs/plugin-vue@5.2.1(vite@6.1.1(@types/node@22.13.4)(jiti@2.4.2)(less@4.2.1)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3))':
dependencies:
vite: 6.1.1(@types/node@22.13.4)(jiti@2.4.2)(less@4.2.1)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0)
@@ -15106,11 +15136,11 @@ snapshots:
dependencies:
'@xsai/shared-chat': 0.1.0-beta.6
'@xsai/tool@0.1.0-beta.5(@xsai/generate-text@0.1.0-beta.5)(zod-to-json-schema@3.24.1(zod@3.24.2))':
'@xsai/tool@0.1.0-beta.5(@valibot/to-json-schema@1.0.0-rc.0(valibot@1.0.0-beta.9(typescript@5.7.3)))(@xsai/generate-text@0.1.0-beta.5)(zod-to-json-schema@3.24.1(zod@3.24.2))':
dependencies:
'@xsai/shared': 0.1.0-beta.6
'@xsai/shared-chat': 0.1.0-beta.6
xsschema: 0.1.0-beta.6(zod-to-json-schema@3.24.1(zod@3.24.2))
xsschema: 0.1.0-beta.6(@valibot/to-json-schema@1.0.0-rc.0(valibot@1.0.0-beta.9(typescript@5.7.3)))(zod-to-json-schema@3.24.1(zod@3.24.2))
optionalDependencies:
'@xsai/generate-text': 0.1.0-beta.5
transitivePeerDependencies:
@@ -19647,7 +19677,7 @@ snapshots:
'@xsai/providers': 0.1.0-beta.5
'@xsai/shared-chat': 0.1.0-beta.5
'@xsai/stream-text': 0.1.0-beta.5
'@xsai/tool': 0.1.0-beta.5(@xsai/generate-text@0.1.0-beta.5)(zod-to-json-schema@3.24.1(zod@3.24.2))
'@xsai/tool': 0.1.0-beta.5(@valibot/to-json-schema@1.0.0-rc.0(valibot@1.0.0-beta.9(typescript@5.7.3)))(@xsai/generate-text@0.1.0-beta.5)(zod-to-json-schema@3.24.1(zod@3.24.2))
'@xsai/utils-chat': 0.1.0-beta.5
defu: 6.1.4
fetch-mock: 12.3.0
@@ -23167,8 +23197,9 @@ snapshots:
xmlhttprequest-ssl@2.1.2: {}
xsschema@0.1.0-beta.6(zod-to-json-schema@3.24.1(zod@3.24.2)):
xsschema@0.1.0-beta.6(@valibot/to-json-schema@1.0.0-rc.0(valibot@1.0.0-beta.9(typescript@5.7.3)))(zod-to-json-schema@3.24.1(zod@3.24.2)):
optionalDependencies:
'@valibot/to-json-schema': 1.0.0-rc.0(valibot@1.0.0-beta.9(typescript@5.7.3))
zod-to-json-schema: 3.24.1(zod@3.24.2)
xtend@4.0.2: {}