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
21 KiB
JSON

{"repo": "allen-answer/dataopsstudio", "files": {"frontend/frontend/src/views/admin/AIConfigView.vue": {"16c414f40d6aa3f44a91bf26a5197ff3f85c85f5": {"t": 1786068445.5019758, "n": 19452, "h": "db651361b378e0b6", "c": "<script setup lang=\"ts\">\nimport { computed, onMounted, reactive, ref } from 'vue'\nimport {\n Bot,\n CheckCircle2,\n FileText,\n KeyRound,\n Languages,\n RefreshCw,\n Save,\n ShieldCheck,\n Sparkles,\n TestTube2,\n Trash2,\n XCircle,\n} from 'lucide-vue-next'\nimport { apiGet, apiJson, invalidateAIEnabledCache } from '../../api'\nimport { useNoticeStore } from '../../stores/notice'\nimport { withStepUpRetry } from '../../utils/stepUpRetry'\n\ninterface AIConfigPayload {\n provider?: string\n model?: string\n base_url?: string\n timeout_seconds?: number\n include_raw?: boolean\n enable_inference?: boolean\n enable_auto_translation?: boolean\n api_key_set?: boolean\n [key: string]: unknown\n}\n\ninterface AITestResult {\n ok: boolean\n status?: string\n error?: string\n [key: string]: unknown\n}\n\nconst noticeStore = useNoticeStore()\n\nconst loading = ref<boolean>(false)\nconst saving = ref<boolean>(false)\nconst testing = ref<boolean>(false)\nconst config = ref<AIConfigPayload | null>(null)\nconst testResult = ref<AITestResult | null>(null)\n\nconst draft = reactive({\n provider: 'off',\n model: '',\n base_url: '',\n api_key: '',\n timeout_seconds: 60,\n include_raw: false,\n enable_inference: false,\n enable_auto_translation: false,\n clear_api_key: false,\n})\n\nconst PROVIDERS = [\n { value: 'anthropic', label: 'Anthropic / Claude', hint: 'Use /v1/messages protocol with x-api-key.' },\n { value: 'anthropic-compatible', label: 'Anthropic-Compatible', hint: 'For DeepSeek Anthropic API and Claude-compatible gateways.' },\n { value: 'off', label: '关闭(离线规则分析)', hint: '默认模式,不调用外部模型。' },\n { value: 'mock', label: 'Mock(本地演示)', hint: '不需要密钥,用于验证前端和流程。' },\n { value: 'openai', label: 'OpenAI / 兼容接口', hint: '使用 /v1/chat/completions 兼容协议。' },\n { value: 'openai-compatible', label: 'OpenAI-Compatible', hint: '适合私有网关、One API、LiteLLM。' },\n { value: 'ollama', label: 'Ollama(本地模型)', hint: '默认 http://localhost:11434,不需要 API Key。' },\n { value: 'azure', label: 'Azure OpenAI', hint: '按兼容接口配置 Base URL 和 Key。' },\n { value: 'http', label: '自定义 HTTP 兼容', hint: '当前按 OpenAI 兼容格式发送。' },\n]\n\nconst selectedProvider = computed(() => PROVIDERS.find(item => item.value === draft.provider) || PROVIDERS[0])\nconst requiresApiKey = computed(() => ['openai', 'openai-compatible', 'azure', 'http', 'anthropic', 'anthropic-compatible'].includes(draft.provider))\nconst requiresModel = computed(() => draft.provider !== 'off')\nconst modelPlaceholder = computed(() => {\n if (draft.provider === 'anthropic') return 'claude-sonnet-4-5-20250929'\n if (draft.provider === 'anthropic-compatible') return 'DeepSeek: deepseek-chat'\n if (draft.provider === 'ollama') return 'llama3.1 / qwen2.5'\n return 'deepseek-chat / kimi-k2.5 / gpt-4.1-mini'\n})\nconst baseUrlPlaceholder = computed(() => {\n if (draft.provider === 'anthropic') return 'https://api.anthropic.com'\n if (draft.provider === 'anthropic-compatible') return 'DeepSeek: https://api.deepseek.com/anthropic'\n if (draft.provider === 'ollama') return 'http://localhost:11434'\n return 'DeepSeek: https://api.deepseek.com/v1'\n})\nconst baseUrlHelp = computed(() => {\n if (['anthropic', 'anthropic-compatible'].includes(draft.provider)) {\n return '可以填 Anthropic SDK Base URL,也可以填完整 /v1/messages 地址;系统会自动规整。'\n }\n if (draft.provider === 'ollama') return 'Ollama 默认会调用 /api/generate。'\n return '可以填 SDK Base URL,也可以填完整 chat/completions 地址;系统会自动规整。'\n})\nconst canSave = computed(() => {\n if (!draft.provider) return false\n if (requiresModel.value && !draft.model.trim()) return false\n if (requiresApiKey.value && !draft.api_key.trim() && !config.value?.api_key_set) return false\n return true\n})\n\nfunction hydrate(nextConfig: AIConfigPayload): void {\n config.value = nextConfig\n draft.provider = nextConfig.provider || 'off'\n draft.model = nextConfig.model || ''\n draft.base_url = nextConfig.base_url || ''\n draft.api_key = ''\n draft.timeout_seconds = Number(nextConfig.timeout_seconds || 20)\n draft.include_raw = Boolean(nextConfig.include_raw)\n draft.enable_inference = Boolean(nextConfig.enable_inference)\n draft.enable_auto_translation = Boolean(nextConfig.enable_auto_translation)\n draft.clear_api_key = false\n}\n\nasync function reload(): Promise<void> {\n loading.value = true\n try {\n hydrate(await apiGet<AIConfigPayload>('/api/lineage/ai/config'))\n } catch (err: any) {\n noticeStore.setNotice(`加载 AI 配置失败:${err?.message || err}`)\n } finally {\n loading.value = false\n }\n}\n\nfunction payload({ includeKey = true }: { includeKey?: boolean } = {}): Record<string, unknown> {\n const body: Record<string, unknown> = {\n provider: draft.provider,\n model: draft.model.trim(),\n base_url: draft.base_url.trim(),\n timeout_seconds: Number(draft.timeout_seconds || 20),\n include_raw: Boolean(draft.include_raw),\n enable_inference: Boolean(draft.enable_inference),\n enable_auto_translation: Boolean(draft.enable_auto_translation),\n }\n if (includeKey && draft.api_key.trim()) body.api_key = draft.api_key\n if (includeKey && draft.clear_api_key) body.clear_api_key = true\n return body\n}\n\nasync function saveConfig(): Promise<void> {\n if (!canSave.value) {\n noticeStore.setNotice('请补齐 provider / model / API Key 配置')\n return\n }\n saving.value = true\n try {\n // 端点已挂 step-up;近 300s 没认证 → step_up_required → helper prompt 密码\n // → verify-password → 换新 token → 重试一次\n const result = await withStepUpRetry(\n () => apiJson<AIConfigPayload>('/api/lineage/ai/config', 'PUT', payload()),\n )\n hydrate(result)\n invalidateAIEnabledCache() // provider 改了 → api.js 错误翻译 hook 重新探测\n noticeStore.setNotice('AI 配置已保存,API Key 已加密落盘')\n } catch (err: any) {\n const msg = String(err?.message || err)\n if (msg === 'step_up_cancelled') {\n // 用户在密码 prompt 点了取消,静默退出\n } else if (msg === 'step_up_verify_failed') {\n noticeStore.setNotice('密码错误,AI 配置保存已取消')\n } else {\n noticeStore.setNotice(`保存 AI 配置失败:${msg}`)\n }\n } finally {\n saving.value = false\n }\n}\n\nasync function testConnection(): Promise<void> {\n testing.value = true\n testResult.value = null\n try {\n testResult.value = await apiJson<AITestResult>('/api/lineage/ai/test', 'POST', payload())\n noticeStore.setNotice(testResult.value.ok ? 'AI 连接测试成功' : `AI 连接测试失败:${testResult.value.error || '-'}`)\n } catch (err: any) {\n testResult.value = { ok: false, status: 'error', error: err?.message || String(err) }\n noticeStore.setNotice(`AI 连接测试失败:${err?.message || err}`)\n } finally {\n testing.value = false\n }\n}\n\nfunction toggleClearApiKey(): void {\n if (!config.value?.api_key_set) return\n if (draft.clear_api_key) {\n draft.clear_api_key = false\n return\n }\n const confirmed = window.confirm('保存配置后将清除已保存的 API Key,确认标记清除吗?')\n if (!confirmed) return\n draft.api_key = ''\n draft.clear_api_key = true\n}\n\nonMounted(() => {\n reload()\n})\n</script>\n\n<template>\n <section class=\"space-y-6\">\n <header class=\"flex flex-wrap items-end justify-between gap-3\">\n <div>\n <h2 class=\"text-2xl font-bold text-slate-800\">AI 配置</h2>\n <p class=\"mt-1 text-sm text-slate-500\">\n 配置血缘分析的可插拔 AI Provider。默认关闭,离线规则分析不受影响。\n </p>\n </div>\n <button class=\"btn btn-outline gap-1.5\" :disabled=\"loading\" @click=\"reload\">\n <RefreshCw class=\"h-4 w-4\" :class=\"loading ? 'animate-spin' : ''\" />\n 刷新\n </button>\n </header>\n\n <div class=\"grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_360px]\">\n <div class=\"card p-5\">\n <div class=\"mb-4 flex items-start gap-3\">\n <div class=\"grid h-10 w-10 place-items-center rounded-xl bg-primary-light text-primary\">\n <Bot class=\"h-5 w-5\" />\n </div>\n <div class=\"min-w-0\">\n <h3 class=\"text-sm font-bold text-slate-800\">Provider</h3>\n <p class=\"mt-1 text-xs text-slate-500\">{{ selectedProvider.hint }}</p>\n </div>\n </div>\n\n <div class=\"grid grid-cols-1 gap-4 lg:grid-cols-2\">\n <label class=\"space-y-1.5\">\n <span class=\"text-xs font-bold uppercase tracking-wider text-slate-500\">Provider</span>\n <select v-model=\"draft.provider\">\n <option v-for=\"item in PROVIDERS\" :key=\"item.value\" :value=\"item.value\">{{ item.label }}</option>\n </select>\n </label>\n\n <label class=\"space-y-1.5\">\n <span class=\"text-xs font-bold uppercase tracking-wider text-slate-500\">Model</span>\n <input v-model=\"draft.model\" :disabled=\"draft.provider === 'off'\" :placeholder=\"modelPlaceholder\" />\n </label>\n\n <label class=\"space-y-1.5 lg:col-span-2\">\n <span class=\"text-xs font-bold uppercase tracking-wider text-slate-500\">Base URL</span>\n <input v-model=\"draft.base_url\" :placeholder=\"baseUrlPlaceholder\" />\n <p class=\"text-xs text-slate-500\">\n {{ baseUrlHelp }}\n </p>\n </label>\n\n <label class=\"space-y-1.5 lg:col-span-2\">\n <span class=\"flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-slate-500\">\n <KeyRound class=\"h-3.5 w-3.5\" />\n API Key\n </span>\n <input\n v-model=\"draft.api_key\"\n type=\"password\"\n autocomplete=\"new-password\"\n :disabled=\"!requiresApiKey || draft.clear_api_key\"\n :placeholder=\"config?.api_key_set ? '已保存密钥,留空不修改' : 'sk-...'\"\n />\n <p class=\"text-xs text-slate-500\">\n 保存后只写入加密密文,接口和页面不会回显明文。\n </p>\n </label>\n\n <label class=\"space-y-1.5\">\n <span class=\"text-xs font-bold uppercase tracking-wider text-slate-500\">Timeout seconds</span>\n <input v-model.number=\"draft.timeout_seconds\" type=\"number\" min=\"1\" max=\"600\" />\n <p class=\"text-xs text-slate-500\">\n Kimi K2 / Claude Opus / GPT-4 等带 thinking 的慢模型建议 240-300;\n 短 prompt 简单 chat 模型 30-60 即可。\n </p>\n </label>\n\n <div class=\"space-y-3 lg:col-span-2\">\n <div class=\"flex items-center justify-between gap-3\">\n <div>\n <h4 class=\"text-sm font-bold text-slate-800\">功能开关</h4>\n <p class=\"mt-1 text-xs text-slate-500\">默认保持克制,按需开启会消耗 token 的能力。</p>\n </div>\n <span class=\"rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-500\">\n Provider: {{ draft.provider || 'off' }}\n </span>\n </div>\n\n <label class=\"flex cursor-pointer items-start justify-between gap-4 rounded-xl border border-slate-200 bg-white p-3 transition hover:border-primary/40 hover:bg-primary-light/40\">\n <span class=\"flex min-w-0 gap-3\">\n <span class=\"grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-slate-100 text-slate-600\">\n <FileText class=\"h-4 w-4\" />\n </span>\n <span class=\"min-w-0\">\n <span class=\"block text-sm font-semibold text-slate-800\">保存 AI 原始响应到报告</span>\n <span class=\"mt-1 block text-xs leading-relaxed text-slate-500\">\n 用于排查 prompt、模型返回和解析问题;生产环境建议只在定位问题时开启。\n </span>\n </span>\n </span>\n <input v-model=\"draft.include_raw\" type=\"checkbox\" class=\"sr-only\" />\n <span\n class=\"relative mt-1 h-6 w-11 shrink-0 rounded-full transition\"\n :class=\"draft.include_raw ? 'bg-primary' : 'bg-slate-300'\"\n >\n <span\n class=\"absolute top-1 h-4 w-4 rounded-full bg-white shadow transition\"\n :class=\"draft.include_raw ? 'left-6' : 'left-1'\"\n ></span>\n </span>\n </label>\n\n <label class=\"flex cursor-pointer items-start justify-between gap-4 rounded-xl border border-slate-200 bg-white p-3 transition hover:border-primary/40 hover:bg-primary-light/40\">\n <span class=\"flex min-w-0 gap-3\">\n <span class=\"grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-primary-light text-primary\">\n <Sparkles class=\"h-4 w-4\" />\n </span>\n <span class=\"min-w-0\">\n <span class=\"block text-sm font-semibold text-slate-800\">启用 AI 解析失败兜底</span>\n <span class=\"mt-1 block text-xs leading-relaxed text-slate-500\">\n 对静态解析失败的片段推断 source / target 表,输出独立\n <code class=\"rounded bg-slate-100 px-1 font-mono text-[10.5px]\">ai_inferred</code>\n 字段;白名单约束只允许使用脚本中出现过的表名和字段名。\n </span>\n </span>\n </span>\n <input v-model=\"draft.enable_inference\" type=\"checkbox\" class=\"sr-only\" />\n <span\n class=\"relative mt-1 h-6 w-11 shrink-0 rounded-full transition\"\n :class=\"draft.enable_inference ? 'bg-primary' : 'bg-slate-300'\"\n >\n <span\n class=\"absolute top-1 h-4 w-4 rounded-full bg-white shadow transition\"\n :class=\"draft.enable_inference ? 'left-6' : 'left-1'\"\n ></span>\n </span>\n </label>\n\n <label class=\"flex cursor-pointer items-start justify-between gap-4 rounded-xl border border-slate-200 bg-white p-3 transition hover:border-primary/40 hover:bg-primary-light/40\">\n <span class=\"flex min-w-0 gap-3\">\n <span class=\"grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-status-info-bg text-status-info\">\n <Languages class=\"h-4 w-4\" />\n </span>\n <span class=\"min-w-0\">\n <span class=\"block text-sm font-semibold text-slate-800\">错误信息 AI 解释模式</span>\n <span class=\"mt-1 block text-xs leading-relaxed text-slate-500\">\n 关闭时由用户点击“AI 解释”后再调用翻译接口;开启后 5xx 和长 4xx 错误自动调用\n <code class=\"rounded bg-slate-100 px-1 font-mono text-[10.5px]\">/api/ai/translate-error</code>\n 并返回中文解释和排查建议。\n </span>\n </span>\n </span>\n <input v-model=\"draft.enable_auto_translation\" type=\"checkbox\" class=\"sr-only\" />\n <span\n class=\"relative mt-1 h-6 w-11 shrink-0 rounded-full transition\"\n :class=\"draft.enable_auto_translation ? 'bg-primary' : 'bg-slate-300'\"\n >\n <span\n class=\"absolute top-1 h-4 w-4 rounded-full bg-white shadow transition\"\n :class=\"draft.enable_auto_translation ? 'left-6' : 'left-1'\"\n ></span>\n </span>\n </label>\n\n <div class=\"flex flex-wrap items-center justify-between gap-3 rounded-xl border border-rose-100 bg-rose-50 px-3 py-3\">\n <div class=\"flex min-w-0 gap-3\">\n <span class=\"grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-white text-rose-600\">\n <Trash2 class=\"h-4 w-4\" />\n </span>\n <div class=\"min-w-0\">\n <p class=\"text-sm font-semibold text-rose-800\">清除已保存 API Key</p>\n <p class=\"mt-1 text-xs text-rose-700/75\">\n {{ draft.clear_api_key ? '已标记清除,点击保存配置后生效。' : '不会立即删除,确认后需要再保存配置。' }}\n </p>\n </div>\n </div>\n <button\n type=\"button\"\n class=\"rounded-lg border px-3 py-1.5 text-xs font-bold transition\"\n :class=\"draft.clear_api_key ? 'border-rose-300 bg-white text-rose-700' : 'border-rose-200 bg-rose-600 text-white hover:bg-rose-700 disabled:cursor-not-allowed disabled:bg-rose-200'\"\n :disabled=\"!config?.api_key_set\"\n @click=\"toggleClearApiKey\"\n >\n {{ draft.clear_api_key ? '取消清除' : '标记清除' }}\n </button>\n </div>\n </div>\n </div>\n\n <div class=\"mt-5 flex flex-wrap justify-end gap-2\">\n <button class=\"btn btn-outline gap-1.5\" :disabled=\"testing || draft.provider === 'off'\" @click=\"testConnection\">\n <TestTube2 class=\"h-4 w-4\" />\n 测试连接\n </button>\n <button class=\"btn btn-primary gap-1.5\" :disabled=\"saving || !canSave\" @click=\"saveConfig\">\n <Save class=\"h-4 w-4\" />\n 保存配置\n </button>\n </div>\n </div>\n\n <aside class=\"space-y-4\">\n <div class=\"card p-5\">\n <div class=\"mb-3 flex items-center gap-2\">\n <ShieldCheck class=\"h-4 w-4 text-emerald-600\" />\n <h3 class=\"text-sm font-bold text-slate-800\">安全状态</h3>\n </div>\n <dl class=\"space-y-3 text-sm\">\n <div class=\"flex justify-between gap-3\">\n <dt class=\"text-slate-500\">当前来源</dt>\n <dd class=\"font-medium text-slate-800\">{{ config?.source || '-' }}</dd>\n </div>\n <div class=\"flex justify-between gap-3\">\n <dt class=\"text-slate-500\">Provider</dt>\n <dd class=\"font-medium text-slate-800\">{{ config?.provider || 'off' }}</dd>\n </div>\n <div class=\"flex justify-between gap-3\">\n <dt class=\"text-slate-500\">已配置</dt>\n <dd>\n <span class=\"status-badge\" :class=\"config?.configured ? 'status-success' : 'status-pending'\">\n {{ config?.configured ? '是' : '否' }}\n </span>\n </dd>\n </div>\n <div class=\"flex justify-between gap-3\">\n <dt class=\"text-slate-500\">API Key</dt>\n <dd>\n <span class=\"status-badge\" :class=\"config?.api_key_set ? 'status-success' : 'status-pending'\">\n {{ config?.api_key_set ? '已保存' : '未保存' }}\n </span>\n </dd>\n </div>\n <div class=\"flex justify-between gap-3\">\n <dt class=\"text-slate-500\">加密落盘</dt>\n <dd>\n <span class=\"status-badge\" :class=\"config?.api_key_encrypted ? 'status-success' : 'status-pending'\">\n {{ config?.api_key_encrypted ? '是' : '-' }}\n </span>\n </dd>\n </div>\n <div class=\"flex justify-between gap-3\">\n <dt class=\"text-slate-500\">更新时间</dt>\n <dd class=\"text-right text-xs text-slate-600\">{{ config?.updated_at || '-' }}</dd>\n </div>\n </dl>\n </div>\n\n <div v-if=\"testResult\" class=\"card p-5\">\n <div class=\"mb-3 flex items-center gap-2\">\n <CheckCircle2 v-if=\"testResult.ok\" class=\"h-4 w-4 text-emerald-600\" />\n <XCircle v-else class=\"h-4 w-4 text-rose-600\" />\n <h3 class=\"text-sm font-bold text-slate-800\">连接测试</h3>\n </div>\n <p class=\"text-sm\" :class=\"testResult.ok ? 'text-emerald-700' : 'text-rose-700'\">\n {{ testResult.ok ? '连接成功' : (testResult.error || '连接失败') }}\n </p>\n <p class=\"mt-2 text-xs text-slate-500\">\n {{ testResult.provider || '-' }} · {{ testResult.model || '-' }} · {{ testResult.elapsed_seconds || 0 }}s\n </p>\n <p v-if=\"testResult.summary\" class=\"mt-2 rounded-lg bg-slate-50 p-2 text-xs text-slate-600\">\n {{ testResult.summary }}\n </p>\n </div>\n </aside>\n </div>\n </section>\n</template>\n"}}}}