Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
27 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "b31o8321/dzmm", "files": {"frontend/src/views/ModelsView.vue": {"df38037b6e3510d2e035e20600f49bd1f48ff077": {"t": 1786068462.6600518, "n": 24217, "h": "b9e429cfc3ee2521", "c": "<script setup lang=\"ts\">\nimport { computed, onMounted, reactive, ref } from 'vue'\nimport { ElMessage, ElMessageBox } from 'element-plus'\nimport { useModelConfigsStore } from '@/stores/modelConfigs'\nimport { backendOrigin } from '@/api/client'\nimport type { ModelConfig, ModelConfigIn } from '@/api/types'\n\nconst store = useModelConfigsStore()\nconst recsOpen = ref(['recs'])\nconst dialogOpen = ref(false)\nconst editingId = ref<number | null>(null)\nconst submitting = ref(false)\nconst testing = ref<number | null>(null)\nconst removing = ref<number | null>(null)\nconst settingDefault = ref<number | null>(null)\n\nconst form = reactive<ModelConfigIn>({\n name: '',\n type: 'ollama',\n base_url: 'http://localhost:11434',\n model_name: '',\n api_key: '',\n timeout: 60,\n max_concurrent: 0,\n})\n\nfunction resetForm() {\n Object.assign(form, {\n name: '',\n type: 'ollama',\n base_url: 'http://localhost:11434',\n model_name: '',\n api_key: '',\n timeout: 60,\n max_concurrent: 0,\n })\n}\n\nconst TYPE_DEFAULTS: Record<string, { base_url: string; model_placeholder: string }> = {\n ollama: { base_url: 'http://localhost:11434', model_placeholder: '例如:qwen2.5:7b' },\n lm_studio: { base_url: 'http://localhost:1234/v1', model_placeholder: '例如:Qwen2.5-7B-Instruct(LM Studio 中加载的模型 id)' },\n openai_compat: { base_url: 'https://api.openai.com/v1', model_placeholder: '例如:gpt-4o-mini' },\n}\n\nconst modelPlaceholder = computed(\n () => TYPE_DEFAULTS[form.type]?.model_placeholder ?? '',\n)\n\nfunction onTypeChange(t: string) {\n // Auto-fill base_url with the type's default — but only if user hasn't\n // typed a custom URL or is leaving an empty form.\n const defaults = TYPE_DEFAULTS[t]\n if (!defaults) return\n const isDefaultUrl = Object.values(TYPE_DEFAULTS).some((d) => d.base_url === form.base_url)\n if (!form.base_url || isDefaultUrl) {\n form.base_url = defaults.base_url\n }\n // Clear api_key when switching to a local type (not used).\n if (t !== 'openai_compat') {\n form.api_key = ''\n }\n}\n\nfunction openCreate() {\n editingId.value = null\n resetForm()\n dialogOpen.value = true\n}\n\nfunction openEdit(row: ModelConfig) {\n editingId.value = row.id\n Object.assign(form, {\n name: row.name,\n type: row.type,\n base_url: row.base_url,\n model_name: row.model_name,\n api_key: '',\n timeout: row.timeout,\n max_concurrent: row.max_concurrent ?? 0,\n })\n dialogOpen.value = true\n}\n\nasync function onSubmit() {\n submitting.value = true\n try {\n const payload: ModelConfigIn = { ...form }\n if (!payload.api_key) delete (payload as any).api_key\n if (editingId.value === null) {\n await store.create(payload)\n ElMessage.success('已添加')\n } else {\n await store.update(editingId.value, payload)\n ElMessage.success('已更新')\n }\n dialogOpen.value = false\n resetForm()\n editingId.value = null\n } catch (e: any) {\n ElMessage.error(e.message)\n } finally {\n submitting.value = false\n }\n}\n\nasync function onDelete(row: ModelConfig) {\n try {\n await ElMessageBox.confirm(\n `确认删除模型配置 \"${row.name}\"?`,\n '确认',\n { type: 'warning' },\n )\n } catch {\n return\n }\n removing.value = row.id\n try {\n await store.remove(row.id)\n ElMessage.success('已删除')\n } catch (e: any) {\n ElMessage.error(e.message)\n } finally {\n removing.value = null\n }\n}\n\nasync function onTest(id: number) {\n testing.value = id\n try {\n const r = await store.test(id)\n if (r.ok) {\n ElMessageBox.alert(r.info, '连接成功', { type: 'success' })\n } else {\n ElMessageBox.alert(r.info, '连接失败', { type: 'error' })\n }\n } catch (e: any) {\n ElMessage.error(e.message)\n } finally {\n testing.value = null\n }\n}\n\nasync function onSetDefault(row: ModelConfig) {\n if (row.is_default) return\n settingDefault.value = row.id\n try {\n await store.setDefault(row.id)\n ElMessage.success(`已把 \"${row.name}\" 设为默认模型`)\n } catch (e: any) {\n ElMessage.error(e.message)\n } finally {\n settingDefault.value = null\n }\n}\n\nfunction copyText(text: string) {\n navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制'))\n}\n\nconst gmLocalModels = [\n { id: 'qwen2.5:14b', vram: '~10 GB', note: '中文叙事明显优于 7b,格式遵循稳定,推荐首选' },\n { id: 'qwen2.5:32b', vram: '~20 GB', note: '本地中文 RP 最佳,指令遵循和叙事质量均衡' },\n { id: 'mistral-nemo:12b', vram: '~8 GB', note: '创意写作强,多语言,指令遵循好' },\n { id: 'gemma3:12b', vram: '~8 GB', note: 'Google 最新,格式遵循稳定,创意性好' },\n { id: 'llama3.1:8b', vram: '~6 GB', note: '效果中等,VRAM 有限时的最低可用选项' },\n]\n\nconst gmCloudModels = [\n { id: 'deepseek-chat', baseUrl: 'https://api.deepseek.com/v1', note: '即 DeepSeek-V3,中文 RP 性价比最高,注意勿用 deepseek-reasoner(r1)' },\n { id: 'gpt-4o-mini', baseUrl: 'https://api.openai.com/v1', note: '稳定可靠,格式遵循极好,综合性价比高' },\n { id: 'gpt-4o', baseUrl: 'https://api.openai.com/v1', note: '叙事质量最强,成本较高' },\n { id: 'gemini-2.0-flash', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', note: '速度快,中文可用,需 Google API Key' },\n { id: 'claude-haiku-4-5-20251001', baseUrl: '(需 OpenAI 兼容转发层)', note: 'Roleplay 细腻,直接调用建议用官方 SDK 而非此处' },\n]\n\n// TTS external services with install/uninstall scripts (uv-based, no Docker required)\ninterface TtsExternalService {\n id: string\n name: string\n tier: number\n platform: string\n quality: string\n baseUrl: string\n defaultVoice: string\n note: string\n installScript: string\n startScript: string\n uninstallScript: string\n healthPath: string\n}\n\nconst ttsExternalServices: TtsExternalService[] = [\n {\n id: 'fish_speech',\n name: 'Fish-Speech',\n tier: 3,\n platform: '跨平台(macOS / Linux / Windows)',\n quality: '★★★★★',\n baseUrl: 'http://localhost:8080',\n defaultVoice: 'default',\n note: '最佳中文音质,支持零样本声音克隆。CPU 可运行(慢),有 GPU 速度快很多。',\n installScript: `#!/bin/bash\n# Fish-Speech 安装脚本(需要 uv:curl -LsSf https://astral.sh/uv/install.sh | sh)\nset -e\nVENV=\"$HOME/.dzmm/fish_speech_env\"\necho \"[1/2] 创建 Python 3.10 环境...\"\nuv venv \"$VENV\" --python 3.10\necho \"[2/2] 安装 Fish-Speech...\"\nuv pip install --python \"$VENV/bin/python\" fish-speech\necho \"\"\necho \"✅ 安装完成!运行以下命令启动服务:\"\necho \" bash ~/.dzmm/start_fish_speech.sh\"`,\n startScript: `#!/bin/bash\n# Fish-Speech 启动脚本\nVENV=\"$HOME/.dzmm/fish_speech_env\"\necho \"正在启动 Fish-Speech(端口 8080)...\"\n\"$VENV/bin/python\" -m fish_speech.api --listen 0.0.0.0:8080`,\n uninstallScript: `#!/bin/bash\n# Fish-Speech 卸载脚本\nrm -rf \"$HOME/.dzmm/fish_speech_env\"\nrm -f \"$HOME/.dzmm/start_fish_speech.sh\"\necho \"✅ Fish-Speech 已卸载\"`,\n healthPath: '/health',\n },\n {\n id: 'mlx_qwen3_tts',\n name: 'MLX-Qwen3-TTS',\n tier: 3,\n platform: 'Apple Silicon(M1/M2/M3/M4)',\n quality: '★★★★★',\n baseUrl: 'http://localhost:8000',\n defaultVoice: 'default',\n note: '阿里 Qwen3-TTS,M 系芯片原生 MLX 加速,0.2–0.3s/句,中文自然度极高。仅限 Apple Silicon Mac。',\n installScript: `#!/bin/bash\n# MLX-Qwen3-TTS 安装脚本(需要 uv + Apple Silicon Mac)\nset -e\nVENV=\"$HOME/.dzmm/mlx_tts_env\"\necho \"[1/2] 创建 Python 3.11 环境...\"\nuv venv \"$VENV\" --python 3.11\necho \"[2/2] 安装 MLX-Qwen3-TTS...\"\nuv pip install --python \"$VENV/bin/python\" mlx-lm mlx-audio\nuv pip install --python \"$VENV/bin/python\" fastapi uvicorn huggingface_hub\n# 下载启动脚本\ncat > \"$HOME/.dzmm/mlx_tts_server.py\" << 'EOF'\nimport asyncio, io, os\nfrom fastapi import FastAPI\nfrom fastapi.responses import Response\nfrom pydantic import BaseModel\nimport mlx_audio, soundfile as sf, numpy as np\n\napp = FastAPI()\n_model = None\n\ndef get_model():\n global _model\n if _model is None:\n from mlx_audio.tts.models.qwen3 import Qwen3TTS\n _model = Qwen3TTS.from_pretrained(\"Qwen/Qwen3-TTS-0.6B\")\n return _model\n\nclass Req(BaseModel):\n input: str\n voice: str = \"default\"\n\n@app.post(\"/v1/audio/speech\")\nasync def synth(req: Req):\n model = get_model()\n audio = await asyncio.get_event_loop().run_in_executor(\n None, lambda: model.generate(req.input))\n buf = io.BytesIO()\n sf.write(buf, np.array(audio), 22050, format=\"WAV\")\n buf.seek(0)\n return Response(content=buf.read(), media_type=\"audio/wav\")\n\n@app.get(\"/health\")\ndef health(): return {\"ok\": True}\nEOF\necho \"\"\necho \"✅ 安装完成!运行以下命令启动服务:\"\necho \" $VENV/bin/python $HOME/.dzmm/mlx_tts_server.py\"`,\n startScript: `#!/bin/bash\nVENV=\"$HOME/.dzmm/mlx_tts_env\"\necho \"正在启动 MLX-Qwen3-TTS(端口 8000)...\"\n\"$VENV/bin/python\" \"$HOME/.dzmm/mlx_tts_server.py\"`,\n uninstallScript: `#!/bin/bash\nrm -rf \"$HOME/.dzmm/mlx_tts_env\"\nrm -f \"$HOME/.dzmm/mlx_tts_server.py\"\necho \"✅ MLX-Qwen3-TTS 已卸载\"`,\n healthPath: '/health',\n },\n]\n\nconst ttsServiceStatus = ref<Record<string, 'idle' | 'checking' | 'ok' | 'fail'>>({})\nconst ttsExpandedScript = ref<Record<string, 'install' | 'start' | 'uninstall' | null>>({})\n\nasync function checkTtsService(svc: TtsExternalService) {\n ttsServiceStatus.value[svc.id] = 'checking'\n try {\n const r = await fetch(`${backendOrigin}/tts/probe?url=${encodeURIComponent(svc.baseUrl)}`)\n const data = await r.json()\n ttsServiceStatus.value[svc.id] = data.ok ? 'ok' : 'fail'\n } catch {\n ttsServiceStatus.value[svc.id] = 'fail'\n }\n}\n\nfunction downloadScript(filename: string, content: string) {\n const blob = new Blob([content], { type: 'text/plain' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n a.href = url\n a.download = filename\n a.click()\n URL.revokeObjectURL(url)\n}\n\nfunction toggleScript(id: string, type: 'install' | 'start' | 'uninstall') {\n ttsExpandedScript.value[id] = ttsExpandedScript.value[id] === type ? null : type\n}\n\nonMounted(() => store.refresh())\n</script>\n\n<template>\n <div class=\"p-6\">\n <div class=\"flex items-center justify-between mb-4\">\n <h2 class=\"text-2xl font-bold\">模型配置</h2>\n <el-button type=\"primary\" @click=\"openCreate\">+ 新增</el-button>\n </div>\n\n <el-collapse v-model=\"recsOpen\" class=\"mb-4 border border-slate-200 rounded-lg overflow-hidden bg-white\">\n <el-collapse-item name=\"recs\">\n <template #title>\n <span class=\"font-semibold text-slate-700 pl-1\">按用途推荐模型</span>\n </template>\n <div class=\"px-4 pb-4\">\n <el-tabs>\n\n <!-- GM 引擎 -->\n <el-tab-pane label=\"GM 叙事引擎\" name=\"gm\">\n <div class=\"flex items-center gap-2 mb-3\">\n <el-tag type=\"danger\" size=\"small\">避免推理模型</el-tag>\n <el-tooltip content=\"deepseek-r1、QwQ、qwen3 思考模式等推理模型会用思考过程占满输出,导致 XML 状态标签全部丢失,游戏无法正常运行。\" placement=\"right\" :width=\"300\">\n <span class=\"text-xs text-slate-500 cursor-help\">deepseek-r1 / QwQ / qwen3 思考模式 — 会破坏输出格式 ?</span>\n </el-tooltip>\n </div>\n\n <p class=\"text-xs font-semibold text-slate-600 mb-2\">本地模型(Ollama)</p>\n <table class=\"w-full text-sm mb-4\">\n <thead>\n <tr class=\"border-b border-slate-200 text-xs text-slate-500\">\n <th class=\"text-left py-1 pr-4 font-medium\">模型</th>\n <th class=\"text-left py-1 pr-4 font-medium\">显存需求</th>\n <th class=\"text-left py-1 font-medium\">适用场景 / 说明</th>\n </tr>\n </thead>\n <tbody class=\"text-slate-700\">\n <tr v-for=\"m in gmLocalModels\" :key=\"m.id\" class=\"border-b border-slate-100 last:border-0\">\n <td class=\"py-1.5 pr-4\">\n <code class=\"bg-slate-100 text-slate-800 px-1.5 py-0.5 rounded text-xs\">{{ m.id }}</code>\n <el-button\n link size=\"small\"\n class=\"ml-1 text-xs text-slate-400\"\n @click=\"copyText(`ollama pull ${m.id}`)\"\n >复制拉取命令</el-button>\n </td>\n <td class=\"py-1.5 pr-4 text-xs text-slate-500 whitespace-nowrap\">{{ m.vram }}</td>\n <td class=\"py-1.5 text-xs text-slate-600\">{{ m.note }}</td>\n </tr>\n </tbody>\n </table>\n\n <p class=\"text-xs font-semibold text-slate-600 mb-2\">云端模型(OpenAI 兼容接口)</p>\n <table class=\"w-full text-sm\">\n <thead>\n <tr class=\"border-b border-slate-200 text-xs text-slate-500\">\n <th class=\"text-left py-1 pr-4 font-medium\">模型 ID</th>\n <th class=\"text-left py-1 pr-4 font-medium\">Base URL</th>\n <th class=\"text-left py-1 font-medium\">说明</th>\n </tr>\n </thead>\n <tbody class=\"text-slate-700\">\n <tr v-for=\"m in gmCloudModels\" :key=\"m.id\" class=\"border-b border-slate-100 last:border-0\">\n <td class=\"py-1.5 pr-4\">\n <code class=\"bg-slate-100 text-slate-800 px-1.5 py-0.5 rounded text-xs\">{{ m.id }}</code>\n <el-button link size=\"small\" class=\"ml-1 text-xs text-slate-400\" @click=\"copyText(m.id)\">复制</el-button>\n </td>\n <td class=\"py-1.5 pr-4 text-xs text-slate-500\">\n <code class=\"text-xs\">{{ m.baseUrl }}</code>\n </td>\n <td class=\"py-1.5 text-xs text-slate-600\">{{ m.note }}</td>\n </tr>\n </tbody>\n </table>\n </el-tab-pane>\n\n <!-- TTS -->\n <el-tab-pane label=\"TTS 语音合成\" name=\"tts\">\n <p class=\"text-xs text-slate-500 mb-4\">在<strong>「设置」→「语音朗读」</strong>切换模式。按性能需求选择合适方案。</p>\n\n <!-- Tier 1: 开箱即用 -->\n <div class=\"mb-3\">\n <div class=\"flex items-center gap-2 mb-2\">\n <el-tag type=\"success\" size=\"small\">开箱即用</el-tag>\n <span class=\"text-xs text-slate-500\">无需额外安装</span>\n </div>\n <div class=\"border border-slate-200 rounded-lg p-3 bg-slate-50 flex items-center justify-between\">\n <div>\n <span class=\"text-sm font-medium\">edge-tts</span>\n <span class=\"text-xs text-slate-500 ml-2\">微软在线语音 · 中文音色丰富 · 需联网</span>\n </div>\n <el-tag type=\"info\" size=\"small\">内置默认</el-tag>\n </div>\n </div>\n\n <!-- Tier 2: 需要一定性能 -->\n <div class=\"mb-3\">\n <div class=\"flex items-center gap-2 mb-2\">\n <el-tag type=\"warning\" size=\"small\">需要一定性能</el-tag>\n <span class=\"text-xs text-slate-500\">本地运行,离线可用,效果更好</span>\n </div>\n <div class=\"border border-slate-200 rounded-lg p-3 bg-slate-50 flex items-center justify-between\">\n <div>\n <span class=\"text-sm font-medium\">CosyVoice</span>\n <span class=\"text-xs text-slate-500 ml-2\">阿里 300M 模型 · 7 种中文音色 · CPU 可运行</span>\n </div>\n <el-button size=\"small\" @click=\"$router.push('/settings')\">前往设置安装</el-button>\n </div>\n </div>\n\n <!-- Tier 3: 需要很好的性能 -->\n <div>\n <div class=\"flex items-center gap-2 mb-2\">\n <el-tag type=\"danger\" size=\"small\">需要较好性能</el-tag>\n <span class=\"text-xs text-slate-500\">音质最佳,需要独立启动外部服务</span>\n </div>\n <div class=\"space-y-3\">\n <div\n v-for=\"svc in ttsExternalServices\"\n :key=\"svc.id\"\n class=\"border border-slate-200 rounded-lg p-3 bg-white\"\n >\n <!-- Header row -->\n <div class=\"flex items-start justify-between gap-2 mb-2\">\n <div class=\"flex-1\">\n <div class=\"flex items-center gap-2 flex-wrap\">\n <span class=\"text-sm font-medium\">{{ svc.name }}</span>\n <span class=\"text-xs text-slate-400\">{{ svc.quality }}</span>\n <span class=\"text-xs text-slate-500\">{{ svc.platform }}</span>\n </div>\n <p class=\"text-xs text-slate-500 mt-0.5\">{{ svc.note }}</p>\n </div>\n <!-- Status + check -->\n <div class=\"flex items-center gap-2 shrink-0\">\n <el-tag\n v-if=\"ttsServiceStatus[svc.id] === 'ok'\" type=\"success\" size=\"small\"\n >已连接</el-tag>\n <el-tag\n v-else-if=\"ttsServiceStatus[svc.id] === 'fail'\" type=\"danger\" size=\"small\"\n >未运行</el-tag>\n <el-button\n size=\"small\"\n :loading=\"ttsServiceStatus[svc.id] === 'checking'\"\n @click=\"checkTtsService(svc)\"\n >检查连接</el-button>\n </div>\n </div>\n\n <!-- Base URL row -->\n <div class=\"flex items-center gap-2 mb-2\">\n <span class=\"text-xs text-slate-500 shrink-0\">Base URL:</span>\n <code class=\"bg-slate-100 text-slate-800 px-1.5 py-0.5 rounded text-xs flex-1\">{{ svc.baseUrl }}</code>\n <el-button type=\"button\" link size=\"small\" class=\"text-xs text-slate-400 shrink-0\" @click=\"copyText(svc.baseUrl)\">复制</el-button>\n </div>\n\n <!-- Script buttons -->\n <div class=\"flex items-center gap-2 flex-wrap\">\n <el-button\n size=\"small\"\n :type=\"ttsExpandedScript[svc.id] === 'install' ? 'primary' : 'default'\"\n plain\n @click=\"toggleScript(svc.id, 'install')\"\n >安装脚本</el-button>\n <el-button\n size=\"small\"\n :type=\"ttsExpandedScript[svc.id] === 'start' ? 'primary' : 'default'\"\n plain\n @click=\"toggleScript(svc.id, 'start')\"\n >启动脚本</el-button>\n <el-button\n size=\"small\"\n :type=\"ttsExpandedScript[svc.id] === 'uninstall' ? 'danger' : 'default'\"\n plain\n @click=\"toggleScript(svc.id, 'uninstall')\"\n >卸载脚本</el-button>\n </div>\n\n <!-- Expanded script panel -->\n <div\n v-if=\"ttsExpandedScript[svc.id]\"\n class=\"mt-2 bg-slate-900 rounded p-3 relative\"\n >\n <div class=\"flex items-center justify-between mb-1\">\n <span class=\"text-xs text-slate-400\">\n {{ ttsExpandedScript[svc.id] === 'install' ? '安装脚本' : ttsExpandedScript[svc.id] === 'start' ? '启动脚本' : '卸载脚本' }}\n </span>\n <div class=\"flex gap-2\">\n <el-button\n type=\"button\"\n link\n size=\"small\"\n class=\"!text-slate-300 text-xs\"\n @click=\"copyText(ttsExpandedScript[svc.id] === 'install' ? svc.installScript : ttsExpandedScript[svc.id] === 'start' ? svc.startScript : svc.uninstallScript)\"\n >复制</el-button>\n <el-button\n type=\"button\"\n link\n size=\"small\"\n class=\"!text-slate-300 text-xs\"\n @click=\"downloadScript(\n `${svc.id}_${ttsExpandedScript[svc.id]}.sh`,\n ttsExpandedScript[svc.id] === 'install' ? svc.installScript : ttsExpandedScript[svc.id] === 'start' ? svc.startScript : svc.uninstallScript\n )\"\n >下载</el-button>\n </div>\n </div>\n <pre class=\"text-xs text-green-300 whitespace-pre-wrap font-mono leading-5 max-h-48 overflow-y-auto\">{{\n ttsExpandedScript[svc.id] === 'install' ? svc.installScript\n : ttsExpandedScript[svc.id] === 'start' ? svc.startScript\n : svc.uninstallScript\n }}</pre>\n </div>\n </div>\n </div>\n </div>\n </el-tab-pane>\n\n </el-tabs>\n </div>\n </el-collapse-item>\n </el-collapse>\n\n <p class=\"text-xs text-slate-500 mb-2\">\n ⭐ <strong>默认模型</strong>:用于\"创建存档向导\"等没有 session 上下文的 LLM 调用。同一时刻只有一条配置能被标为默认。\n </p>\n\n <el-table :data=\"store.items\" v-loading=\"store.loading\" border>\n <el-table-column label=\"默认\" width=\"64\" align=\"center\">\n <template #default=\"{ row }\">\n <span v-if=\"row.is_default\" class=\"text-yellow-500\" title=\"当前默认模型\">★</span>\n <span v-else class=\"text-slate-300\">—</span>\n </template>\n </el-table-column>\n <el-table-column prop=\"name\" label=\"名称\" width=\"160\" />\n <el-table-column prop=\"type\" label=\"类型\" width=\"140\" />\n <el-table-column prop=\"base_url\" label=\"Base URL\" />\n <el-table-column prop=\"model_name\" label=\"模型\" width=\"200\" />\n <el-table-column label=\"API Key\" width=\"100\">\n <template #default=\"{ row }\">\n {{ row.api_key_ref ? '已设置' : '—' }}\n </template>\n </el-table-column>\n <el-table-column label=\"操作\" width=\"320\">\n <template #default=\"{ row }\">\n <el-button\n v-if=\"!row.is_default\"\n size=\"small\"\n :loading=\"settingDefault === row.id\"\n @click=\"onSetDefault(row)\"\n >设为默认</el-button>\n <el-button\n size=\"small\"\n :loading=\"testing === row.id\"\n @click=\"onTest(row.id)\"\n >测试</el-button>\n <el-button size=\"small\" @click=\"openEdit(row)\">编辑</el-button>\n <el-button\n size=\"small\"\n type=\"danger\"\n :loading=\"removing === row.id\"\n @click=\"onDelete(row)\"\n >删除</el-button>\n </template>\n </el-table-column>\n </el-table>\n\n <el-dialog\n v-model=\"dialogOpen\"\n :title=\"editingId === null ? '新增模型配置' : '编辑模型配置'\"\n width=\"520px\"\n >\n <el-form :model=\"form\" label-width=\"100px\">\n <el-form-item label=\"名称\" required>\n <el-input v-model=\"form.name\" placeholder=\"例如:本地 qwen\" />\n </el-form-item>\n <el-form-item label=\"类型\" required>\n <el-select v-model=\"form.type\" @change=\"onTypeChange\">\n <el-option label=\"Ollama 本地\" value=\"ollama\" />\n <el-option label=\"LM Studio 本地\" value=\"lm_studio\" />\n <el-option label=\"OpenAI 兼容(云端)\" value=\"openai_compat\" />\n </el-select>\n </el-form-item>\n <el-form-item label=\"Base URL\" required>\n <el-input v-model=\"form.base_url\" />\n </el-form-item>\n <el-form-item label=\"模型\" required>\n <el-input\n v-model=\"form.model_name\"\n :placeholder=\"modelPlaceholder\"\n />\n </el-form-item>\n <el-form-item label=\"API Key\" v-if=\"form.type === 'openai_compat'\">\n <el-input\n v-model=\"form.api_key\"\n type=\"password\"\n show-password\n :placeholder=\"editingId !== null ? '留空则保留原密钥' : ''\"\n />\n </el-form-item>\n <el-form-item label=\"超时(秒)\">\n <el-input-number v-model=\"form.timeout\" :min=\"5\" :max=\"300\" />\n </el-form-item>\n <el-form-item label=\"并发上限\">\n <el-input-number v-model=\"form.max_concurrent\" :min=\"0\" :max=\"20\" />\n <span class=\"text-xs text-slate-500 ml-2\">\n 0 = 不限。云端免费层(智谱 glm-4-flash 等)必须设 1,否则并发请求会被全部 429。\n </span>\n </el-form-item>\n </el-form>\n <template #footer>\n <el-button @click=\"dialogOpen = false\">取消</el-button>\n <el-button type=\"primary\" :loading=\"submitting\" @click=\"onSubmit\">保存</el-button>\n </template>\n </el-dialog>\n </div>\n</template>\n"}}}}