diff --git a/apps/stage-web/package.json b/apps/stage-web/package.json
index 8d8773a8d..7b9a72992 100644
--- a/apps/stage-web/package.json
+++ b/apps/stage-web/package.json
@@ -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",
diff --git a/apps/stage-web/src/components/Widgets/DesktopSettings.vue b/apps/stage-web/src/components/Widgets/DesktopSettings.vue
index 8ab45b8c9..e81a159ea 100644
--- a/apps/stage-web/src/components/Widgets/DesktopSettings.vue
+++ b/apps/stage-web/src/components/Widgets/DesktopSettings.vue
@@ -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
>
diff --git a/apps/stage-web/src/components/Widgets/MobileSettings.vue b/apps/stage-web/src/components/Widgets/MobileSettings.vue
index 90bd0c8fc..3d04efaa0 100644
--- a/apps/stage-web/src/components/Widgets/MobileSettings.vue
+++ b/apps/stage-web/src/components/Widgets/MobileSettings.vue
@@ -35,10 +35,10 @@ function navigateBack() {
-
+
{{ t('settings.title') }}
-
+
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
}
-const providers = ref([
+const providers = computed(() => [
+ {
+ 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>>({})
+
+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)
+}
-
-
+
+