Files

1 line
195 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": "123-qw-as/beacon", "files": {"frontend/providers.json": {"4fc3ab1285422d3b317708a8542a91a8882eddab": {"t": 1785674191.0372386, "n": 1674, "h": "3ce60c2ac93e515f", "c": "[\n {\n \"id\": \"deepseek\",\n \"name\": \"DeepSeek\",\n \"apiBase\": \"https://api.deepseek.com/v1\",\n \"defaultModel\": \"openai/deepseek-chat\",\n \"strongModel\": \"openai/deepseek-reasoner\",\n \"figureModel\": \"openai/deepseek-chat\",\n \"website\": \"https://platform.deepseek.com\",\n \"description\": \"性价比高,适合数学建模\"\n },\n {\n \"id\": \"openai\",\n \"name\": \"OpenAI\",\n \"apiBase\": \"https://api.openai.com/v1\",\n \"defaultModel\": \"openai/gpt-4o-mini\",\n \"strongModel\": \"openai/gpt-4o\",\n \"figureModel\": \"openai/gpt-4o\",\n \"website\": \"https://platform.openai.com\",\n \"description\": \"GPT 系列,能力全面\"\n },\n {\n \"id\": \"zhipu\",\n \"name\": \"智谱 AI\",\n \"apiBase\": \"https://open.bigmodel.cn/api/paas/v4\",\n \"defaultModel\": \"openai/glm-4-flash\",\n \"strongModel\": \"openai/glm-4\",\n \"figureModel\": \"openai/glm-4v\",\n \"website\": \"https://open.bigmodel.cn\",\n \"description\": \"GLM 系列,国产大模型\"\n },\n {\n \"id\": \"moonshot\",\n \"name\": \"月之暗面\",\n \"apiBase\": \"https://api.moonshot.cn/v1\",\n \"defaultModel\": \"openai/moonshot-v1-8k\",\n \"strongModel\": \"openai/moonshot-v1-32k\",\n \"figureModel\": \"openai/moonshot-v1-32k\",\n \"website\": \"https://platform.moonshot.cn\",\n \"description\": \"Kimi,长上下文优势\"\n },\n {\n \"id\": \"ollama\",\n \"name\": \"本地 Ollama\",\n \"apiBase\": \"http://localhost:11434/v1\",\n \"defaultModel\": \"ollama/llama3\",\n \"strongModel\": \"ollama/llama3\",\n \"figureModel\": \"ollama/llama3\",\n \"website\": \"https://ollama.com\",\n \"description\": \"本地部署,离线可用\"\n },\n {\n \"id\": \"custom\",\n \"name\": \"自定义\",\n \"apiBase\": \"\",\n \"defaultModel\": \"\",\n \"strongModel\": \"\",\n \"figureModel\": \"\",\n \"website\": \"\",\n \"description\": \"手动填写端点和模型\"\n }\n]\n"}, "05990eb9bf14055753eab2c1e27447eb85357573": {"t": 1785761235.378646, "n": 1674, "h": "3ce60c2ac93e515f", "c": "[\n {\n \"id\": \"deepseek\",\n \"name\": \"DeepSeek\",\n \"apiBase\": \"https://api.deepseek.com/v1\",\n \"defaultModel\": \"openai/deepseek-chat\",\n \"strongModel\": \"openai/deepseek-reasoner\",\n \"figureModel\": \"openai/deepseek-chat\",\n \"website\": \"https://platform.deepseek.com\",\n \"description\": \"性价比高,适合数学建模\"\n },\n {\n \"id\": \"openai\",\n \"name\": \"OpenAI\",\n \"apiBase\": \"https://api.openai.com/v1\",\n \"defaultModel\": \"openai/gpt-4o-mini\",\n \"strongModel\": \"openai/gpt-4o\",\n \"figureModel\": \"openai/gpt-4o\",\n \"website\": \"https://platform.openai.com\",\n \"description\": \"GPT 系列,能力全面\"\n },\n {\n \"id\": \"zhipu\",\n \"name\": \"智谱 AI\",\n \"apiBase\": \"https://open.bigmodel.cn/api/paas/v4\",\n \"defaultModel\": \"openai/glm-4-flash\",\n \"strongModel\": \"openai/glm-4\",\n \"figureModel\": \"openai/glm-4v\",\n \"website\": \"https://open.bigmodel.cn\",\n \"description\": \"GLM 系列,国产大模型\"\n },\n {\n \"id\": \"moonshot\",\n \"name\": \"月之暗面\",\n \"apiBase\": \"https://api.moonshot.cn/v1\",\n \"defaultModel\": \"openai/moonshot-v1-8k\",\n \"strongModel\": \"openai/moonshot-v1-32k\",\n \"figureModel\": \"openai/moonshot-v1-32k\",\n \"website\": \"https://platform.moonshot.cn\",\n \"description\": \"Kimi,长上下文优势\"\n },\n {\n \"id\": \"ollama\",\n \"name\": \"本地 Ollama\",\n \"apiBase\": \"http://localhost:11434/v1\",\n \"defaultModel\": \"ollama/llama3\",\n \"strongModel\": \"ollama/llama3\",\n \"figureModel\": \"ollama/llama3\",\n \"website\": \"https://ollama.com\",\n \"description\": \"本地部署,离线可用\"\n },\n {\n \"id\": \"custom\",\n \"name\": \"自定义\",\n \"apiBase\": \"\",\n \"defaultModel\": \"\",\n \"strongModel\": \"\",\n \"figureModel\": \"\",\n \"website\": \"\",\n \"description\": \"手动填写端点和模型\"\n }\n]\n"}, "fec125addfcafe9c7c60a70464650278192cb8e5": {"t": 1785761237.7883666, "n": 1590, "h": "5fc1b12b62615b6e", "c": "[\n {\n \"id\": \"deepseek\",\n \"name\": \"DeepSeek\",\n \"apiBase\": \"https://api.deepseek.com/v1\",\n \"defaultModel\": \"deepseek-chat\",\n \"strongModel\": \"deepseek-reasoner\",\n \"figureModel\": \"deepseek-chat\",\n \"website\": \"https://platform.deepseek.com\",\n \"description\": \"性价比高,适合数学建模\"\n },\n {\n \"id\": \"openai\",\n \"name\": \"OpenAI\",\n \"apiBase\": \"https://api.openai.com/v1\",\n \"defaultModel\": \"openai/gpt-4o-mini\",\n \"strongModel\": \"openai/gpt-4o\",\n \"figureModel\": \"openai/gpt-4o\",\n \"website\": \"https://platform.openai.com\",\n \"description\": \"GPT 系列,能力全面\"\n },\n {\n \"id\": \"zhipu\",\n \"name\": \"智谱 AI\",\n \"apiBase\": \"https://open.bigmodel.cn/api/paas/v4\",\n \"defaultModel\": \"glm-4-flash\",\n \"strongModel\": \"glm-4\",\n \"figureModel\": \"glm-4v\",\n \"website\": \"https://open.bigmodel.cn\",\n \"description\": \"GLM 系列,国产大模型\"\n },\n {\n \"id\": \"moonshot\",\n \"name\": \"月之暗面\",\n \"apiBase\": \"https://api.moonshot.cn/v1\",\n \"defaultModel\": \"moonshot-v1-8k\",\n \"strongModel\": \"moonshot-v1-32k\",\n \"figureModel\": \"moonshot-v1-32k\",\n \"website\": \"https://platform.moonshot.cn\",\n \"description\": \"Kimi,长上下文优势\"\n },\n {\n \"id\": \"ollama\",\n \"name\": \"本地 Ollama\",\n \"apiBase\": \"http://localhost:11434/v1\",\n \"defaultModel\": \"llama3\",\n \"strongModel\": \"llama3\",\n \"figureModel\": \"llama3\",\n \"website\": \"https://ollama.com\",\n \"description\": \"本地部署,离线可用\"\n },\n {\n \"id\": \"custom\",\n \"name\": \"自定义\",\n \"apiBase\": \"\",\n \"defaultModel\": \"\",\n \"strongModel\": \"\",\n \"figureModel\": \"\",\n \"website\": \"\",\n \"description\": \"手动填写端点和模型\"\n }\n]\n"}, "14cbc5c32e7b0d7e75343cf7d45dc90c934eb9f1": {"t": 1785761239.9334903, "n": 1399, "h": "d34b58591c3ed7c2", "c": "[\n {\n \"id\": \"deepseek\",\n \"name\": \"DeepSeek\",\n \"apiBase\": \"https://api.deepseek.com/v1\",\n \"defaultModel\": \"deepseek-chat\",\n \"strongModel\": \"deepseek-reasoner\",\n \"website\": \"https://platform.deepseek.com\",\n \"description\": \"性价比高,适合数学建模\"\n },\n {\n \"id\": \"openai\",\n \"name\": \"OpenAI\",\n \"apiBase\": \"https://api.openai.com/v1\",\n \"defaultModel\": \"openai/gpt-4o-mini\",\n \"strongModel\": \"openai/gpt-4o\",\n \"website\": \"https://platform.openai.com\",\n \"description\": \"GPT 系列,能力全面\"\n },\n {\n \"id\": \"zhipu\",\n \"name\": \"智谱 AI\",\n \"apiBase\": \"https://open.bigmodel.cn/api/paas/v4\",\n \"defaultModel\": \"glm-4-flash\",\n \"strongModel\": \"glm-4\",\n \"website\": \"https://open.bigmodel.cn\",\n \"description\": \"GLM 系列,国产大模型\"\n },\n {\n \"id\": \"moonshot\",\n \"name\": \"月之暗面\",\n \"apiBase\": \"https://api.moonshot.cn/v1\",\n \"defaultModel\": \"moonshot-v1-8k\",\n \"strongModel\": \"moonshot-v1-32k\",\n \"website\": \"https://platform.moonshot.cn\",\n \"description\": \"Kimi,长上下文优势\"\n },\n {\n \"id\": \"ollama\",\n \"name\": \"本地 Ollama\",\n \"apiBase\": \"http://localhost:11434/v1\",\n \"defaultModel\": \"llama3\",\n \"strongModel\": \"llama3\",\n \"website\": \"https://ollama.com\",\n \"description\": \"本地部署,离线可用\"\n },\n {\n \"id\": \"custom\",\n \"name\": \"自定义\",\n \"apiBase\": \"\",\n \"defaultModel\": \"\",\n \"strongModel\": \"\",\n \"website\": \"\",\n \"description\": \"手动填写端点和模型\"\n }\n]\n"}}, "docs/superpowers/plans/2026-07-11-packaging-and-onboarding.md": {"4fc3ab1285422d3b317708a8542a91a8882eddab": {"t": 1785746579.271744, "n": 55718, "h": "2a2861eb9233b37a", "c": "# Beacon 打包与使用门槛降低 - 实现计划\n\n> **历史方案记录(非现行事实源)**:路径、状态、命令和待办保留当时上下文;当前实现与限制见 [`../../README.md`](../../README.md)。\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** 在现有 Beacon 架构上新增引导向导(5步)、设置页面、启动脚本增强,让用户无需手动编辑 .env 或安装命令行工具即可使用。\n\n**Architecture:** Node 服务器全权负责环境检测/winget 安装/.env 读写/LLM 连接测试。将 server.mjs 的共享工具提取到 `frontend/lib/shared.mjs`,新增路由模块挂载到 `/api/env/*`、`/api/config/*`、`/api/onboarding/*`。前端新增引导向导覆盖层和设置页面。Python 后端完全不改。\n\n**Tech Stack:** Node.js (原生 http)、vanilla HTML/CSS/JS、winget (Windows 包管理)、dotenv\n\n---\n\n## 文件结构\n\n| 文件 | 操作 | 职责 |\n|------|------|------|\n| `frontend/lib/shared.mjs` | 新建 | 共享工具:sendJson、readJsonBody、safeProjectPath、HttpError、projectRoot、env 解析 |\n| `frontend/providers.json` | 新建 | 6 个 API 提供商预设数据 |\n| `frontend/routes/env.mjs` | 新建 | 环境检测 + winget 安装 |\n| `frontend/routes/config.mjs` | 新建 | .env 读写 + LLM 连接测试 |\n| `frontend/routes/onboarding.mjs` | 新建 | 引导状态判断 |\n| `frontend/server.mjs` | 修改 | 路由分发 + health 增强 |\n| `frontend/server.test.mjs` | 修改 | 新增 API 测试 |\n| `frontend/index.html` | 修改 | 引导向导覆盖层 + 设置页入口 |\n| `frontend/styles.css` | 修改 | 引导/设置样式 |\n| `frontend/app.js` | 修改 | 引导/设置交互逻辑 |\n| `scripts/start.bat` | 修改 | 不阻断启动 + 友好提示 |\n\n---\n\n### Task 1: 创建共享工具模块\n\n**Files:**\n- Create: `frontend/lib/shared.mjs`\n\n- [ ] **Step 1: 创建 `frontend/lib/shared.mjs`**\n\n从 `server.mjs` 提取共享工具,供路由模块复用。\n\n```javascript\n// frontend/lib/shared.mjs\nimport { resolve } from \"node:path\";\nimport { isAbsolute, relative, sep } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nexport const root = resolve(fileURLToPath(new URL(\".\", import.meta.url)));\nexport const projectRoot = resolve(root, \"..\", \"..\");\nexport const env = globalThis.process?.env || {};\n\nexport class HttpError extends Error {\n constructor(status, message) {\n super(message);\n this.status = status;\n }\n}\n\nexport function sendJson(response, status, payload) {\n response.writeHead(status, { \"Content-Type\": \"application/json; charset=utf-8\" });\n response.end(JSON.stringify(payload));\n}\n\nexport function safeProjectPath(inputPath) {\n const target = resolve(projectRoot, inputPath || \".\");\n const rel = relative(projectRoot, target);\n if (isAbsolute(rel) || rel === \"..\" || rel.startsWith(`..${sep}`)) {\n throw new HttpError(403, \"Path is outside the project workspace.\");\n }\n return target;\n}\n\nexport async function readJsonBody(request) {\n const chunks = [];\n let size = 0;\n for await (const chunk of request) {\n size += chunk.length;\n if (size > 1024 * 1024) throw new HttpError(413, \"Request body exceeds 1 MiB.\");\n chunks.push(chunk);\n }\n const text = Buffer.concat(chunks).toString(\"utf8\");\n if (!text) return {};\n try {\n return JSON.parse(text);\n } catch (error) {\n throw new HttpError(400, `Invalid JSON body: ${error.message}`);\n }\n}\n```\n\n- [ ] **Step 2: 验证模块可被导入**\n\nRun: `node -e \"import('./frontend/lib/shared.mjs').then(m => console.log('OK', m.projectRoot))\"`\nExpected: 输出 `OK` + 项目根路径\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add frontend/lib/shared.mjs\ngit commit -m \"feat: extract shared utilities to lib/shared.mjs\"\n```\n\n---\n\n### Task 2: 创建 API 提供商预设数据\n\n**Files:**\n- Create: `frontend/providers.json`\n\n- [ ] **Step 1: 创建 `frontend/providers.json`**\n\n```json\n[\n {\n \"id\": \"deepseek\",\n \"name\": \"DeepSeek\",\n \"apiBase\": \"https://api.deepseek.com/v1\",\n \"defaultModel\": \"deepseek-chat\",\n \"strongModel\": \"deepseek-reasoner\",\n \"website\": \"https://platform.deepseek.com\",\n \"description\": \"性价比高,适合数学建模\"\n },\n {\n \"id\": \"openai\",\n \"name\": \"OpenAI\",\n \"apiBase\": \"https://api.openai.com/v1\",\n \"defaultModel\": \"openai/gpt-4o-mini\",\n \"strongModel\": \"openai/gpt-4o\",\n \"website\": \"https://platform.openai.com\",\n \"description\": \"GPT 系列,能力全面\"\n },\n {\n \"id\": \"zhipu\",\n \"name\": \"智谱 AI\",\n \"apiBase\": \"https://open.bigmodel.cn/api/paas/v4\",\n \"defaultModel\": \"glm-4-flash\",\n \"strongModel\": \"glm-4\",\n \"website\": \"https://open.bigmodel.cn\",\n \"description\": \"GLM 系列,国产大模型\"\n },\n {\n \"id\": \"moonshot\",\n \"name\": \"月之暗面\",\n \"apiBase\": \"https://api.moonshot.cn/v1\",\n \"defaultModel\": \"moonshot-v1-8k\",\n \"strongModel\": \"moonshot-v1-32k\",\n \"website\": \"https://platform.moonshot.cn\",\n \"description\": \"Kimi,长上下文优势\"\n },\n {\n \"id\": \"ollama\",\n \"name\": \"本地 Ollama\",\n \"apiBase\": \"http://localhost:11434/v1\",\n \"defaultModel\": \"llama3\",\n \"strongModel\": \"llama3\",\n \"website\": \"https://ollama.com\",\n \"description\": \"本地部署,离线可用\"\n },\n {\n \"id\": \"custom\",\n \"name\": \"自定义\",\n \"apiBase\": \"\",\n \"defaultModel\": \"\",\n \"strongModel\": \"\",\n \"website\": \"\",\n \"description\": \"手动填写端点和模型\"\n }\n]\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add frontend/providers.json\ngit commit -m \"feat: add API provider presets (providers.json)\"\n```\n\n---\n\n### Task 3: 创建环境检测路由模块\n\n**Files:**\n- Create: `frontend/routes/env.mjs`\n\n- [ ] **Step 1: 创建 `frontend/routes/env.mjs`**\n\n```javascript\n// frontend/routes/env.mjs\nimport { execSync } from \"node:child_process\";\nimport { sendJson, readJsonBody, HttpError } from \"../lib/shared.mjs\";\n\n/**\n * 检测单个工具的版本信息。\n * @param {string} command - 命令名 (如 \"python\", \"node\", \"uv\")\n * @returns {{ installed: boolean, version: string }}\n */\nfunction checkTool(command) {\n try {\n const output = execSync(`${command} --version`, {\n encoding: \"utf8\",\n timeout: 10_000,\n windowsHide: true,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n }).trim();\n // 提取版本号:取输出中第一个 x.y.z 格式的字符串\n const match = output.match(/(\\d+\\.\\d+(?:\\.\\d+)?)/);\n return { installed: true, version: match ? match[1] : output };\n } catch {\n return { installed: false, version: \"\" };\n }\n}\n\n/**\n * 比较版本号是否达标。\n * @param {string} version - 实际版本 \"3.12.3\"\n * @param {string} minVersion - 最低要求 \"3.11\"\n * @returns {boolean}\n */\nfunction versionOk(version, minVersion) {\n if (!version) return false;\n const actual = version.split(\".\").map(Number);\n const required = minVersion.split(\".\").map(Number);\n for (let i = 0; i < required.length; i++) {\n const a = actual[i] || 0;\n const r = required[i] || 0;\n if (a > r) return true;\n if (a < r) return false;\n }\n return true;\n}\n\nconst WINGET_PACKAGES = {\n python: \"Python.Python.3.12\",\n node: \"OpenJS.NodeJS.LTS\",\n uv: \"astral-sh.uv\",\n};\n\n/**\n * 处理 /api/env/* 路由。\n */\nexport async function handleEnvRoutes(request, response, url) {\n // GET /api/env/check - 检测环境\n if (request.method === \"GET\" && url.pathname === \"/api/env/check\") {\n const python = checkTool(\"python\");\n const python3 = !python.installed ? checkTool(\"python3\") : null;\n const node = checkTool(\"node\");\n const uv = checkTool(\"uv\");\n\n const pythonResult = python.installed\n ? python\n : python3 && python3.installed\n ? python3\n : { installed: false, version: \"\" };\n\n sendJson(response, 200, {\n python: {\n installed: pythonResult.installed,\n version: pythonResult.version,\n ok: pythonResult.installed && versionOk(pythonResult.version, \"3.11\"),\n },\n node: {\n installed: node.installed,\n version: node.version,\n ok: node.installed && versionOk(node.version, \"18\"),\n },\n uv: {\n installed: uv.installed,\n version: uv.version,\n ok: uv.installed,\n },\n allOk:\n pythonResult.installed &&\n versionOk(pythonResult.version, \"3.11\") &&\n node.installed &&\n versionOk(node.version, \"18\") &&\n uv.installed,\n });\n return;\n }\n\n // POST /api/env/install - winget 安装\n if (request.method === \"POST\" && url.pathname === \"/api/env/install\") {\n const body = await readJsonBody(request);\n const tool = body.tool;\n if (!tool || !WINGET_PACKAGES[tool]) {\n throw new HttpError(400, `Invalid tool. Must be one of: ${Object.keys(WINGET_PACKAGES).join(\", \")}`);\n }\n\n // 检查 winget 是否可用\n try {\n execSync(\"winget --version\", { encoding: \"utf8\", timeout: 10_000, windowsHide: true, stdio: [\"pipe\", \"pipe\", \"pipe\"] });\n } catch {\n sendJson(response, 200, {\n tool,\n status: \"failed\",\n message: \"winget 不可用,请手动安装\",\n downloadUrl: getDownloadUrl(tool),\n });\n return;\n }\n\n // 阻塞式安装\n const pkg = WINGET_PACKAGES[tool];\n try {\n const output = execSync(\n `winget install ${pkg} --silent --accept-package-agreements --accept-source-agreements`,\n {\n encoding: \"utf8\",\n timeout: 300_000, // 5 分钟\n windowsHide: true,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n },\n );\n sendJson(response, 200, {\n tool,\n status: \"done\",\n message: \"安装完成\",\n output: output.slice(-500),\n });\n } catch (error) {\n sendJson(response, 200, {\n tool,\n status: \"failed\",\n message: error.killed ? \"安装超时(超过5分钟)\" : \"安装失败\",\n output: String(error.stderr || error.stdout || error.message).slice(-500),\n downloadUrl: getDownloadUrl(tool),\n });\n }\n return;\n }\n\n // GET /api/providers - 返回提供商预设\n if (request.method === \"GET\" && url.pathname === \"/api/providers\") {\n const { readFile } = await import(\"node:fs/promises\");\n const { resolve } = await import(\"node:path\");\n const { projectRoot } = await import(\"../lib/shared.mjs\");\n try {\n const raw = await readFile(resolve(projectRoot, \"frontend/providers.json\"), \"utf8\");\n sendJson(response, 200, JSON.parse(raw));\n } catch {\n sendJson(response, 200, []);\n }\n return;\n }\n\n sendJson(response, 404, { error: \"Unknown env route.\" });\n}\n\nfunction getDownloadUrl(tool) {\n const urls = {\n python: \"https://www.python.org/downloads/\",\n node: \"https://nodejs.org/\",\n uv: \"https://docs.astral.sh/uv/getting-started/installation/\",\n };\n return urls[tool] || \"\";\n}\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add frontend/routes/env.mjs\ngit commit -m \"feat: add env check + winget install route module\"\n```\n\n---\n\n### Task 4: 创建配置读写路由模块\n\n**Files:**\n- Create: `frontend/routes/config.mjs`\n\n- [ ] **Step 1: 创建 `frontend/routes/config.mjs`**\n\n```javascript\n// frontend/routes/config.mjs\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { sendJson, readJsonBody, HttpError, projectRoot } from \"../lib/shared.mjs\";\n\nconst ENV_PATH = resolve(projectRoot, \".env\");\nconst ENV_EXAMPLE_PATH = resolve(projectRoot, \".env.example\");\n\n/** 前端字段 -> .env 变量名映射 */\nconst FIELD_MAP = {\n apiBase: \"OPENAI_API_BASE\",\n apiKey: \"OPENAI_API_KEY\",\n defaultModel: \"MATH_AGENT_DEFAULT_MODEL\",\n strongModel: \"MATH_AGENT_STRONG_MODEL\",\n llmTimeout: \"MATH_AGENT_LLM_TIMEOUT\",\n maxModelIterations: \"MATH_AGENT_MAX_MODEL_ITERATIONS\",\n ragEnabled: \"MATH_AGENT_RAG_ENABLED\",\n ragEmbeddingModel: \"MATH_AGENT_RAG_EMBED\",\n ragTopK: \"MATH_AGENT_RAG_TOPK\",\n port: \"PORT\",\n};\n\n/** .env 变量名 -> 前端字段 反向映射 */\nconst ENV_TO_FIELD = Object.fromEntries(\n Object.entries(FIELD_MAP).map(([field, envKey]) => [envKey, field]),\n);\n\n/**\n * 解析 .env 文件为 key-value 对象。\n */\nfunction parseEnvFile(filePath) {\n if (!existsSync(filePath)) return {};\n const content = readFileSync(filePath, \"utf8\");\n const result = {};\n for (const line of content.split(\"\\n\")) {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(\"#\")) continue;\n const eqIndex = trimmed.indexOf(\"=\");\n if (eqIndex === -1) continue;\n const key = trimmed.slice(0, eqIndex).trim();\n const value = trimmed.slice(eqIndex + 1).trim();\n result[key] = value;\n }\n return result;\n}\n\n/**\n * 掩码 API 密钥:显示前3位 + *** + 后4位。\n */\nfunction maskApiKey(key) {\n if (!key || key.length < 8) return key ? \"***\" : \"\";\n return `${key.slice(0, 3)}***${key.slice(-4)}`;\n}\n\n/**\n * 更新 .env 文件,保留注释和格式,仅替换对应行的值。\n */\nfunction updateEnvFile(updates) {\n const templatePath = existsSync(ENV_PATH) ? ENV_PATH : ENV_EXAMPLE_PATH;\n const content = readFileSync(templatePath, \"utf8\");\n const lines = content.split(\"\\n\");\n const remaining = new Set(Object.keys(updates));\n\n const result = lines.map((line) => {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(\"#\")) return line;\n const eqIndex = trimmed.indexOf(\"=\");\n if (eqIndex === -1) return line;\n const key = trimmed.slice(0, eqIndex).trim();\n if (key in updates) {\n remaining.delete(key);\n return `${key}=${updates[key]}`;\n }\n return line;\n });\n\n // 追加 .env 中不存在的新 key\n for (const key of remaining) {\n result.push(`${key}=${updates[key]}`);\n }\n\n writeFileSync(ENV_PATH, result.join(\"\\n\"), \"utf8\");\n}\n\n/**\n * 处理 /api/config* 路由。\n */\nexport async function handleConfigRoutes(request, response, url) {\n // GET /api/config - 读取当前配置\n if (request.method === \"GET\" && url.pathname === \"/api/config\") {\n const envVars = parseEnvFile(ENV_PATH);\n const config = {\n apiBase: envVars.OPENAI_API_BASE || \"\",\n apiKey: maskApiKey(envVars.OPENAI_API_KEY || \"\"),\n hasApiKey: !!(envVars.OPENAI_API_KEY && envVars.OPENAI_API_KEY !== \"123456\"),\n defaultModel: envVars.MATH_AGENT_DEFAULT_MODEL || \"\",\n strongModel: envVars.MATH_AGENT_STRONG_MODEL || \"\",\n llmTimeout: Number(envVars.MATH_AGENT_LLM_TIMEOUT || 300),\n maxModelIterations: Number(envVars.MATH_AGENT_MAX_MODEL_ITERATIONS || 3),\n ragEnabled: envVars.MATH_AGENT_RAG_ENABLED === \"1\",\n ragEmbeddingModel: envVars.MATH_AGENT_RAG_EMBED || \"text-embedding-3-small\",\n ragTopK: Number(envVars.MATH_AGENT_RAG_TOPK || 4),\n port: Number(envVars.PORT || 5173),\n };\n sendJson(response, 200, config);\n return;\n }\n\n // POST /api/config - 保存配置到 .env\n if (request.method === \"POST\" && url.pathname === \"/api/config\") {\n const body = await readJsonBody(request);\n\n const updates = {};\n // 只更新提供的字段\n if (body.apiBase !== undefined) updates[FIELD_MAP.apiBase] = body.apiBase;\n // apiKey: 如果值包含 *** 表示是掩码值,跳过不更新\n if (body.apiKey !== undefined && !body.apiKey.includes(\"***\")) {\n updates[FIELD_MAP.apiKey] = body.apiKey;\n }\n if (body.defaultModel !== undefined) updates[FIELD_MAP.defaultModel] = body.defaultModel;\n if (body.strongModel !== undefined) updates[FIELD_MAP.strongModel] = body.strongModel;\n if (body.llmTimeout !== undefined) updates[FIELD_MAP.llmTimeout] = String(body.llmTimeout);\n if (body.maxModelIterations !== undefined) updates[FIELD_MAP.maxModelIterations] = String(body.maxModelIterations);\n if (body.ragEnabled !== undefined) updates[FIELD_MAP.ragEnabled] = body.ragEnabled ? \"1\" : \"0\";\n if (body.ragEmbeddingModel !== undefined) updates[FIELD_MAP.ragEmbeddingModel] = body.ragEmbeddingModel;\n if (body.ragTopK !== undefined) updates[FIELD_MAP.ragTopK] = String(body.ragTopK);\n if (body.port !== undefined) updates[FIELD_MAP.port] = String(body.port);\n\n if (Object.keys(updates).length === 0) {\n throw new HttpError(400, \"No config fields to update.\");\n }\n\n updateEnvFile(updates);\n sendJson(response, 200, { ok: true });\n return;\n }\n\n // POST /api/config/test-llm - 测试 LLM 连接\n if (request.method === \"POST\" && url.pathname === \"/api/config/test-llm\") {\n const body = await readJsonBody(request);\n const { apiBase, apiKey, model } = body;\n\n if (!apiBase || !apiKey || !model) {\n throw new HttpError(400, \"apiBase, apiKey, and model are required.\");\n }\n\n const startTime = Date.now();\n try {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n\n const res = await fetch(`${apiBase.replace(/\\/+$/, \"\")}/chat/completions`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n messages: [{ role: \"user\", content: \"hi\" }],\n max_tokens: 1,\n }),\n signal: controller.signal,\n });\n\n clearTimeout(timeout);\n const latency = Date.now() - startTime;\n\n if (res.ok) {\n sendJson(response, 200, { success: true, latency_ms: latency });\n } else {\n const errorText = await res.text().catch(() => \"\");\n let errorMsg = `HTTP ${res.status}`;\n if (res.status === 401) errorMsg = \"API 密钥无效\";\n else if (res.status === 404) errorMsg = \"模型不存在或端点错误\";\n else if (errorText) errorMsg = `${errorMsg}: ${errorText.slice(0, 200)}`;\n sendJson(response, 200, { success: false, latency_ms: latency, error: errorMsg });\n }\n } catch (error) {\n const latency = Date.now() - startTime;\n const errorMsg = error.name === \"AbortError\"\n ? \"连接超时(30秒)\"\n : `连接失败: ${error.message}`;\n sendJson(response, 200, { success: false, latency_ms: latency, error: errorMsg });\n }\n return;\n }\n\n sendJson(response, 404, { error: \"Unknown config route.\" });\n}\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add frontend/routes/config.mjs\ngit commit -m \"feat: add config read/write + LLM test route module\"\n```\n\n---\n\n### Task 5: 创建引导状态路由模块\n\n**Files:**\n- Create: `frontend/routes/onboarding.mjs`\n\n- [ ] **Step 1: 创建 `frontend/routes/onboarding.mjs`**\n\n```javascript\n// frontend/routes/onboarding.mjs\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { sendJson, projectRoot } from \"../lib/shared.mjs\";\n\nconst ENV_PATH = resolve(projectRoot, \".env\");\n\n/**\n * 判断是否需要引导。\n * @returns {{ needed: boolean, reason: \"no_env\" | \"no_api_key\" | \"done\" }}\n */\nexport function getOnboardingStatus() {\n if (!existsSync(ENV_PATH)) {\n return { needed: true, reason: \"no_env\" };\n }\n\n const content = readFileSync(ENV_PATH, \"utf8\");\n const apiKeyMatch = content.match(/^OPENAI_API_KEY\\s*=\\s*(.+)$/m);\n const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : \"\";\n\n if (!apiKey || apiKey === \"123456\") {\n return { needed: true, reason: \"no_api_key\" };\n }\n\n return { needed: false, reason: \"done\" };\n}\n\n/**\n * 处理 /api/onboarding/* 路由。\n */\nexport async function handleOnboardingRoutes(request, response, url) {\n // GET /api/onboarding/status\n if (request.method === \"GET\" && url.pathname === \"/api/onboarding/status\") {\n sendJson(response, 200, getOnboardingStatus());\n return;\n }\n\n sendJson(response, 404, { error: \"Unknown onboarding route.\" });\n}\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add frontend/routes/onboarding.mjs\ngit commit -m \"feat: add onboarding status route module\"\n```\n\n---\n\n### Task 6: 重构 server.mjs 接入新路由\n\n**Files:**\n- Modify: `frontend/server.mjs`\n\n- [ ] **Step 1: 在 server.mjs 顶部添加路由导入**\n\n在 `frontend/server.mjs` 第 9 行(`import { fileURLToPath }` 之后)添加:\n\n```javascript\nimport { handleEnvRoutes } from \"./routes/env.mjs\";\nimport { handleConfigRoutes } from \"./routes/config.mjs\";\nimport { handleOnboardingRoutes, getOnboardingStatus } from \"./routes/onboarding.mjs\";\n```\n\n- [ ] **Step 2: 在 handleApi 函数中添加新路由分发**\n\n在 `frontend/server.mjs` 的 `handleApi` 函数开头(第 249 行 `if (request.method === \"POST\" && url.pathname === \"/api/upload\")` 之前)插入:\n\n```javascript\n // --- 新增路由:环境检测、配置、引导 ---\n if (url.pathname.startsWith(\"/api/env/\") || url.pathname === \"/api/providers\") {\n return handleEnvRoutes(request, response, url);\n }\n if (url.pathname.startsWith(\"/api/config\")) {\n return handleConfigRoutes(request, response, url);\n }\n if (url.pathname.startsWith(\"/api/onboarding/\")) {\n return handleOnboardingRoutes(request, response, url);\n }\n```\n\n- [ ] **Step 3: 增强 health 端点,合并 onboarding 状态**\n\n在 `frontend/server.mjs` 中找到 health 端点(约第 313 行),替换为:\n\n```javascript\n if (request.method === \"GET\" && url.pathname === \"/api/health\") {\n const fixtures = await listFixtures().catch(() => []);\n const onboarding = getOnboardingStatus();\n sendJson(response, 200, {\n ok: true,\n projectRoot,\n mathAgentCommand: env.MATH_AGENT_COMMAND || \"uv run math-agent\",\n fixtures: fixtures.length,\n onboarding,\n });\n return;\n }\n```\n\n- [ ] **Step 4: 验证服务器能启动**\n\nRun: `node -e \"import('./frontend/lib/shared.mjs').then(() => console.log('shared OK'))\" && node -e \"import('./frontend/routes/env.mjs').then(() => console.log('env OK'))\" && node -e \"import('./frontend/routes/config.mjs').then(() => console.log('config OK'))\" && node -e \"import('./frontend/routes/onboarding.mjs').then(() => console.log('onboarding OK'))\"`\nExpected: 四行 `OK`\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add frontend/server.mjs\ngit commit -m \"feat: wire env/config/onboarding routes into server.mjs + enhance health\"\n```\n\n---\n\n### Task 7: 编写后端 API 集成测试\n\n**Files:**\n- Modify: `frontend/server.test.mjs`\n\n- [ ] **Step 1: 在 `frontend/server.test.mjs` 末尾追加新测试**\n\n在文件末尾(第 172 行之后)追加:\n\n```javascript\ntest(\"GET /api/env/check 返回环境检测结果\", async () => {\n const response = await fetch(`${base}/api/env/check`);\n assert.equal(response.status, 200);\n const data = await response.json();\n assert.ok(data.python);\n assert.ok(data.node);\n assert.ok(data.uv);\n assert.equal(typeof data.allOk, \"boolean\");\n});\n\ntest(\"GET /api/onboarding/status 返回引导状态\", async () => {\n const response = await fetch(`${base}/api/onboarding/status`);\n assert.equal(response.status, 200);\n const data = await response.json();\n assert.ok([\"no_env\", \"no_api_key\", \"done\"].includes(data.reason));\n assert.equal(typeof data.needed, \"boolean\");\n});\n\ntest(\"GET /api/config 返回配置(密钥掩码)\", async () => {\n const response = await fetch(`${base}/api/config`);\n assert.equal(response.status, 200);\n const data = await response.json();\n assert.ok(data.apiBase !== undefined);\n assert.ok(data.defaultModel !== undefined);\n assert.ok(data.strongModel !== undefined);\n // apiKey 不应包含明文(如果存在的话应被掩码)\n if (data.apiKey) {\n assert.ok(data.apiKey.includes(\"***\") || data.apiKey.length === 0);\n }\n});\n\ntest(\"GET /api/health 包含 onboarding 字段\", async () => {\n const response = await fetch(`${base}/api/health`);\n const data = await response.json();\n assert.ok(data.onboarding);\n assert.ok([\"no_env\", \"no_api_key\", \"done\"].includes(data.onboarding.reason));\n});\n\ntest(\"POST /api/config/test-llm 拒绝缺少参数的请求\", async () => {\n const response = await fetch(`${base}/api/config/test-llm`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ apiBase: \"https://example.com/v1\" }),\n });\n assert.equal(response.status, 400);\n});\n\ntest(\"GET /api/providers 返回提供商列表\", async () => {\n const response = await fetch(`${base}/api/providers`);\n assert.equal(response.status, 200);\n const data = await response.json();\n assert.ok(Array.isArray(data));\n assert.ok(data.length >= 5);\n assert.ok(data.some((p) => p.id === \"deepseek\"));\n});\n```\n\n- [ ] **Step 2: 运行测试**\n\nRun: `node --test frontend/server.test.mjs`\nExpected: 所有测试 PASS(包括原有测试)\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add frontend/server.test.mjs\ngit commit -m \"test: add integration tests for env/config/onboarding APIs\"\n```\n\n---\n\n### Task 8: 添加引导向导和设置页面的 HTML\n\n**Files:**\n- Modify: `frontend/index.html`\n\n- [ ] **Step 1: 在侧边栏导航添加\"设置\"入口**\n\n在 `frontend/index.html` 第 23 行(`<a href=\"#knowledge\">知识库</a>` 之后)添加:\n\n```html\n <a href=\"#settings\">设置</a>\n```\n\n- [ ] **Step 2: 在 `</main>` 之前添加设置页面容器**\n\n在 `frontend/index.html` 找到 `</main>`(第 230 行),在其之前添加:\n\n```html\n <section class=\"panel settings-panel\" id=\"settings\" hidden aria-labelledby=\"settings-title\">\n <div class=\"panel-heading\">\n <div>\n <p class=\"eyebrow\">Configuration</p>\n <h2 id=\"settings-title\">设置</h2>\n </div>\n </div>\n <div class=\"settings-layout\">\n <nav class=\"settings-nav\" aria-label=\"设置分类\">\n <button class=\"active\" type=\"button\" data-settings-tab=\"api\">API 配置</button>\n <button type=\"button\" data-settings-tab=\"models\">模型选择</button>\n <button type=\"button\" data-settings-tab=\"rag\">RAG 知识库</button>\n <button type=\"button\" data-settings-tab=\"advanced\">高级设置</button>\n </nav>\n <div class=\"settings-content\" id=\"settingsContent\"></div>\n </div>\n <div class=\"settings-footer\">\n <button class=\"ghost-button\" type=\"button\" id=\"settingsReset\">重置默认</button>\n <button class=\"primary-button\" type=\"button\" id=\"settingsSave\">💾 保存配置</button>\n </div>\n </section>\n```\n\n- [ ] **Step 3: 在 `<div class=\"toast\"` 之前添加引导向导覆盖层**\n\n在 `frontend/index.html` 找到 `<div class=\"toast\"` (第 233 行),在其之前添加:\n\n```html\n <div class=\"onboarding-overlay\" id=\"onboardingOverlay\" hidden>\n <div class=\"onboarding-card\">\n <div class=\"onboarding-header\">\n <h2 id=\"onboardingTitle\">欢迎使用 Beacon</h2>\n <p id=\"onboardingSubtitle\">让我们花几分钟完成初始配置</p>\n <div class=\"onboarding-steps\">\n <span class=\"step-dot active\" data-step=\"1\"></span>\n <span class=\"step-dot\" data-step=\"2\"></span>\n <span class=\"step-dot\" data-step=\"3\"></span>\n <span class=\"step-dot\" data-step=\"4\"></span>\n <span class=\"step-dot\" data-step=\"5\"></span>\n </div>\n </div>\n <div class=\"onboarding-body\" id=\"onboardingBody\"></div>\n <div class=\"onboarding-footer\">\n <button class=\"ghost-button\" type=\"button\" id=\"onboardingPrev\" disabled>上一步</button>\n <button class=\"primary-button\" type=\"button\" id=\"onboardingNext\">下一步</button>\n </div>\n </div>\n </div>\n```\n\n- [ ] **Step 4: Commit**\n\n```bash\ngit add frontend/index.html\ngit commit -m \"feat: add onboarding overlay + settings panel to index.html\"\n```\n\n---\n\n### Task 9: 添加引导向导和设置页面的 CSS\n\n**Files:**\n- Modify: `frontend/styles.css`\n\n- [ ] **Step 1: 在 `frontend/styles.css` 末尾追加引导向导和设置页面样式**\n\n```css\n/* ---- 引导向导覆盖层 ---- */\n\n.onboarding-overlay {\n position: fixed;\n inset: 0;\n z-index: 100;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(7, 27, 58, 0.6);\n backdrop-filter: blur(6px);\n}\n\n.onboarding-overlay[hidden] {\n display: none;\n}\n\n.onboarding-card {\n width: min(620px, calc(100vw - 48px));\n max-height: calc(100vh - 48px);\n display: flex;\n flex-direction: column;\n padding: 32px;\n border-radius: 16px;\n background: var(--white);\n box-shadow: 0 40px 80px rgba(7, 27, 58, 0.25);\n overflow-y: auto;\n}\n\n.onboarding-header h2 {\n margin: 0;\n font-size: 1.5rem;\n}\n\n.onboarding-header p {\n margin: 6px 0 0;\n color: var(--muted);\n font-size: 0.92rem;\n}\n\n.onboarding-steps {\n display: flex;\n gap: 8px;\n margin-top: 16px;\n}\n\n.step-dot {\n flex: 1;\n height: 4px;\n border-radius: 999px;\n background: var(--line);\n transition: background 200ms ease;\n}\n\n.step-dot.active {\n background: var(--gold);\n}\n\n.step-dot.done {\n background: var(--green);\n}\n\n.onboarding-body {\n flex: 1;\n min-height: 200px;\n margin: 24px 0;\n}\n\n.onboarding-body h3 {\n margin: 0 0 12px;\n font-size: 1.1rem;\n}\n\n.onboarding-body p {\n color: var(--ink-soft);\n line-height: 1.6;\n}\n\n.onboarding-footer {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n}\n\n/* 引导步骤内容 */\n\n.env-check-list {\n display: grid;\n gap: 10px;\n}\n\n.env-check-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 14px 16px;\n border: 1px solid var(--line);\n border-radius: 8px;\n background: #fbfcfe;\n}\n\n.env-check-item.ok {\n border-color: var(--green);\n background: #f0faf6;\n}\n\n.env-check-item.missing {\n border-color: var(--rose);\n background: #fff0f3;\n}\n\n.env-check-item .env-name {\n font-weight: 700;\n color: var(--ink);\n}\n\n.env-check-item .env-version {\n display: block;\n margin-top: 2px;\n font-size: 0.84rem;\n color: var(--muted);\n}\n\n.env-check-item .env-status-ok {\n color: var(--green);\n font-weight: 700;\n}\n\n.env-check-item .env-status-missing {\n color: var(--rose);\n font-weight: 700;\n}\n\n.env-install-btn {\n min-height: 34px;\n padding: 0 14px;\n border: none;\n border-radius: 6px;\n background: var(--gold);\n color: var(--ink);\n font-weight: 700;\n cursor: pointer;\n}\n\n.env-install-btn:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n}\n\n.env-install-btn.installing {\n background: var(--muted);\n}\n\n.provider-grid {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 10px;\n}\n\n.provider-card {\n padding: 14px;\n border: 2px solid var(--line);\n border-radius: 8px;\n background: #fbfcfe;\n cursor: pointer;\n transition: border-color 150ms ease;\n}\n\n.provider-card:hover {\n border-color: var(--blue);\n}\n\n.provider-card.selected {\n border-color: var(--gold);\n background: var(--gold-soft);\n}\n\n.provider-card strong {\n display: block;\n font-size: 1rem;\n}\n\n.provider-card span {\n display: block;\n margin-top: 4px;\n color: var(--muted);\n font-size: 0.82rem;\n}\n\n/* ---- 设置页面 ---- */\n\n.settings-panel[hidden] {\n display: none;\n}\n\n.settings-layout {\n display: flex;\n gap: 20px;\n min-height: 320px;\n}\n\n.settings-nav {\n display: flex;\n flex-direction: column;\n gap: 4px;\n width: 140px;\n flex-shrink: 0;\n}\n\n.settings-nav button {\n padding: 10px 14px;\n text-align: left;\n border: none;\n border-left: 3px solid transparent;\n border-radius: 0;\n background: transparent;\n color: var(--muted);\n font-weight: 600;\n cursor: pointer;\n}\n\n.settings-nav button.active {\n color: var(--blue);\n font-weight: 800;\n border-left-color: var(--blue);\n background: #f0f6ff;\n}\n\n.settings-content {\n flex: 1;\n display: grid;\n gap: 14px;\n align-content: start;\n}\n\n.settings-content .field {\n margin-bottom: 0;\n}\n\n.settings-content .field span {\n font-size: 0.86rem;\n}\n\n.settings-content .field input,\n.settings-content .field select {\n width: 100%;\n height: 42px;\n padding: 0 12px;\n border: 1px solid var(--line);\n border-radius: 8px;\n color: var(--ink);\n background: var(--white);\n font-size: 14px;\n outline: 0;\n}\n\n.settings-content .field input:focus,\n.settings-content .field select:focus {\n border-color: var(--blue);\n box-shadow: 0 0 0 3px rgba(21, 94, 150, 0.13);\n}\n\n.settings-content .field-hint {\n color: var(--muted);\n font-size: 0.8rem;\n}\n\n.settings-content .test-result {\n padding: 8px 12px;\n border-radius: 6px;\n font-size: 0.84rem;\n}\n\n.settings-content .test-result.ok {\n background: #dff6eb;\n color: var(--green);\n}\n\n.settings-content .test-result.fail {\n background: #fff0f3;\n color: var(--rose);\n}\n\n.settings-content .toggle-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 12px;\n border: 1px solid var(--line);\n border-radius: 8px;\n background: #fbfcfe;\n}\n\n.toggle-switch {\n position: relative;\n width: 44px;\n height: 24px;\n border-radius: 12px;\n background: var(--line);\n cursor: pointer;\n transition: background 200ms ease;\n}\n\n.toggle-switch.on {\n background: var(--green);\n}\n\n.toggle-switch::after {\n content: \"\";\n position: absolute;\n top: 2px;\n left: 2px;\n width: 20px;\n height: 20px;\n border-radius: 50%;\n background: var(--white);\n transition: transform 200ms ease;\n}\n\n.toggle-switch.on::after {\n transform: translateX(20px);\n}\n\n.settings-footer {\n display: flex;\n justify-content: flex-end;\n gap: 10px;\n margin-top: 16px;\n padding-top: 14px;\n border-top: 1px solid var(--line);\n}\n\n@media (max-width: 860px) {\n .settings-layout {\n flex-direction: column;\n }\n .settings-nav {\n flex-direction: row;\n width: 100%;\n overflow-x: auto;\n }\n .settings-nav button {\n border-left: none;\n border-bottom: 3px solid transparent;\n white-space: nowrap;\n }\n .settings-nav button.active {\n border-left: none;\n border-bottom-color: var(--blue);\n }\n .provider-grid {\n grid-template-columns: 1fr;\n }\n}\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add frontend/styles.css\ngit commit -m \"feat: add onboarding overlay + settings page styles\"\n```\n\n---\n\n### Task 10: 添加引导向导和设置页面的 JS 逻辑\n\n**Files:**\n- Modify: `frontend/app.js`\n\n- [ ] **Step 1: 在 `frontend/app.js` 开头添加 DOM 引用**\n\n在第 35 行(`const pipelineItems = ...` 之后)添加:\n\n```javascript\nconst onboardingOverlay = document.querySelector(\"#onboardingOverlay\");\nconst onboardingBody = document.querySelector(\"#onboardingBody\");\nconst onboardingTitle = document.querySelector(\"#onboardingTitle\");\nconst onboardingSubtitle = document.querySelector(\"#onboardingSubtitle\");\nconst onboardingPrev = document.querySelector(\"#onboardingPrev\");\nconst onboardingNext = document.querySelector(\"#onboardingNext\");\nconst stepDots = document.querySelectorAll(\".step-dot\");\nconst settingsPanel = document.querySelector(\"#settings\");\nconst settingsContent = document.querySelector(\"#settingsContent\");\nconst settingsSave = document.querySelector(\"#settingsSave\");\nconst settingsReset = document.querySelector(\"#settingsReset\");\nconst settingsNavButtons = document.querySelectorAll(\"[data-settings-tab]\");\n```\n\n- [ ] **Step 2: 在 `frontend/app.js` 末尾添加引导向导逻辑**\n\n在文件末尾追加:\n\n```javascript\n// ============ 引导向导 ============\n\nlet onboardingStep = 1;\nlet onboardingData = {\n provider: null,\n apiBase: \"\",\n apiKey: \"\",\n defaultModel: \"\",\n strongModel: \"\",\n};\n\nconst onboardingSteps = [\n { title: \"欢迎使用 Beacon\", subtitle: \"让我们花几分钟完成初始配置\" },\n { title: \"环境检测\", subtitle: \"检查运行所需依赖是否已安装\" },\n { title: \"选择 API 提供商\", subtitle: \"选择你的 LLM 服务提供商\" },\n { title: \"API 密钥 & 端点配置\", subtitle: \"填写 API 密钥和访问端点\" },\n { title: \"模型选择 & 连接测试\", subtitle: \"选择模型并验证连接\" },\n { title: \"配置完成\", subtitle: \"一切就绪,可以开始使用了\" },\n];\n\nfunction showOnboarding() {\n onboardingOverlay.hidden = false;\n onboardingStep = 1;\n renderOnboardingStep();\n}\n\nfunction hideOnboarding() {\n onboardingOverlay.hidden = true;\n}\n\nfunction updateStepDots() {\n stepDots.forEach((dot, i) => {\n dot.classList.toggle(\"active\", i + 1 === onboardingStep);\n dot.classList.toggle(\"done\", i + 1 < onboardingStep);\n });\n}\n\nfunction renderOnboardingStep() {\n const step = onboardingSteps[onboardingStep - 1];\n onboardingTitle.textContent = step.title;\n onboardingSubtitle.textContent = step.subtitle;\n updateStepDots();\n onboardingPrev.disabled = onboardingStep === 1;\n onboardingNext.textContent = onboardingStep === 5 ? \"进入工作台\" : \"下一步\";\n\n if (onboardingStep === 1) renderEnvCheckStep();\n else if (onboardingStep === 2) renderProviderStep();\n else if (onboardingStep === 3) renderApiKeyStep();\n else if (onboardingStep === 4) renderModelTestStep();\n else if (onboardingStep === 5) renderCompleteStep();\n}\n\nasync function renderEnvCheckStep() {\n onboardingBody.innerHTML = `\n <h3>环境检测</h3>\n <p>正在检测运行所需依赖...</p>\n <div class=\"env-check-list\" id=\"envCheckList\"></div>\n `;\n try {\n const data = await api(\"/api/env/check\");\n const list = document.querySelector(\"#envCheckList\");\n list.innerHTML = renderEnvItem(\"Python\", \"≥ 3.11\", data.python) +\n renderEnvItem(\"Node.js\", \"≥ 18\", data.node) +\n renderEnvItem(\"uv\", \"Python 包管理器\", data.uv);\n onboardingNext.disabled = !data.allOk;\n if (!data.allOk) {\n list.querySelectorAll(\".env-install-btn\").forEach((btn) => {\n btn.addEventListener(\"click\", () => installTool(btn.dataset.tool, btn));\n });\n }\n } catch (error) {\n onboardingBody.innerHTML = `<p>检测失败: ${escapeHtml(error.message)}</p>`;\n }\n}\n\nfunction renderEnvItem(name, requirement, info) {\n const ok = info.ok;\n const status = ok\n ? `<span class=\"env-status-ok\">✓ ${info.version}</span>`\n : `<span class=\"env-status-missing\">✗ ${info.installed ? info.version + \" 版本过低\" : \"未检测到\"}</span>`;\n const action = ok ? \"\" : `<button class=\"env-install-btn\" data-tool=\"${name.toLowerCase().replace(\".\", \"\")}\">一键安装</button>`;\n // 修正 tool 名称映射\n const toolName = name === \"Python\" ? \"python\" : name === \"Node.js\" ? \"node\" : name === \"uv\" ? \"uv\" : \"\";\n const actionBtn = ok ? \"\" : `<button class=\"env-install-btn\" data-tool=\"${toolName}\">一键安装</button>`;\n return `\n <div class=\"env-check-item ${ok ? \"ok\" : \"missing\"}\">\n <div>\n <span class=\"env-name\">${escapeHtml(name)}</span>\n <span class=\"env-version\">${escapeHtml(requirement)}</span>\n </div>\n <div style=\"display:flex;align-items:center;gap:10px;\">\n ${status}\n ${actionBtn}\n </div>\n </div>\n `;\n}\n\nasync function installTool(tool, btn) {\n btn.disabled = true;\n btn.classList.add(\"installing\");\n btn.textContent = \"安装中...\";\n try {\n const result = await api(\"/api/env/install\", {\n method: \"POST\",\n body: JSON.stringify({ tool }),\n });\n if (result.status === \"done\") {\n showToast(`${tool} 安装完成`);\n renderEnvCheckStep();\n } else {\n btn.disabled = false;\n btn.classList.remove(\"installing\");\n btn.textContent = \"重试\";\n showToast(`${tool} 安装失败: ${result.message}`);\n if (result.downloadUrl) {\n onboardingBody.insertAdjacentHTML(\"beforeend\",\n `<p style=\"margin-top:12px;color:var(--rose);\">手动安装: <a href=\"${result.downloadUrl}\" target=\"_blank\">${result.downloadUrl}</a></p>`);\n }\n }\n } catch (error) {\n btn.disabled = false;\n btn.classList.remove(\"installing\");\n btn.textContent = \"重试\";\n showToast(`安装失败: ${error.message}`);\n }\n}\n\nasync function renderProviderStep() {\n onboardingNext.disabled = true;\n onboardingBody.innerHTML = `\n <h3>选择 API 提供商</h3>\n <p>选择你的 LLM 服务提供商,选择后会自动填入端点和推荐模型。</p>\n <div class=\"provider-grid\" id=\"providerGrid\"></div>\n `;\n try {\n const providers = await api(\"/api/providers\");\n const grid = document.querySelector(\"#providerGrid\");\n grid.innerHTML = providers.map((p) => `\n <div class=\"provider-card\" data-provider=\"${escapeHtml(p.id)}\">\n <strong>${escapeHtml(p.name)}</strong>\n <span>${escapeHtml(p.description)}</span>\n </div>\n `).join(\"\");\n grid.querySelectorAll(\".provider-card\").forEach((card) => {\n card.addEventListener(\"click\", () => {\n grid.querySelectorAll(\".provider-card\").forEach((c) => c.classList.remove(\"selected\"));\n card.classList.add(\"selected\");\n const provider = providers.find((p) => p.id === card.dataset.provider);\n onboardingData.provider = provider.id;\n onboardingData.apiBase = provider.apiBase;\n onboardingData.defaultModel = provider.defaultModel;\n onboardingData.strongModel = provider.strongModel;\n onboardingNext.disabled = false;\n });\n });\n } catch (error) {\n onboardingBody.innerHTML = `<p>加载提供商列表失败: ${escapeHtml(error.message)}</p>`;\n }\n}\n\nfunction renderApiKeyStep() {\n onboardingBody.innerHTML = `\n <h3>API 密钥 & 端点配置</h3>\n <p>填写你的 API 密钥。端点已根据所选提供商自动填入,可修改。</p>\n <div class=\"field\">\n <span>API 端点(Base URL)</span>\n <input id=\"obApiBase\" value=\"${escapeHtml(onboardingData.apiBase)}\" placeholder=\"https://api.deepseek.com/v1\" style=\"width:100%;height:42px;padding:0 12px;border:1px solid var(--line);border-radius:8px;font-size:14px;color:var(--ink);background:var(--white);outline:0;\" />\n </div>\n <div class=\"field\">\n <span>API 密钥</span>\n <div style=\"display:flex;gap:8px;\">\n <input id=\"obApiKey\" type=\"password\" value=\"${escapeHtml(onboardingData.apiKey)}\" placeholder=\"sk-...\" style=\"flex:1;height:42px;padding:0 12px;border:1px solid var(--line);border-radius:8px;font-size:14px;color:var(--ink);background:var(--white);outline:0;\" />\n <button class=\"ghost-button\" type=\"button\" id=\"obToggleKey\">👁 显示</button>\n </div>\n </div>\n `;\n const apiBaseInput = document.querySelector(\"#obApiBase\");\n const apiKeyInput = document.querySelector(\"#obApiKey\");\n const toggleBtn = document.querySelector(\"#obToggleKey\");\n\n apiBaseInput.addEventListener(\"input\", () => { onboardingData.apiBase = apiBaseInput.value; });\n apiKeyInput.addEventListener(\"input\", () => { onboardingData.apiKey = apiKeyInput.value; });\n toggleBtn.addEventListener(\"click\", () => {\n if (apiKeyInput.type === \"password\") {\n apiKeyInput.type = \"text\";\n toggleBtn.textContent = \"👁 隐藏\";\n } else {\n apiKeyInput.type = \"password\";\n toggleBtn.textContent = \"👁 显示\";\n }\n });\n onboardingNext.disabled = false;\n}\n\nfunction renderModelTestStep() {\n onboardingBody.innerHTML = `\n <h3>模型选择 & 连接测试</h3>\n <p>选择主力模型和强力模型,然后测试连接。</p>\n <div class=\"field\">\n <span>主力模型(编码、灵敏度分析)</span>\n <input id=\"obDefaultModel\" value=\"${escapeHtml(onboardingData.defaultModel)}\" style=\"width:100%;height:42px;padding:0 12px;border:1px solid var(--line);border-radius:8px;font-size:14px;color:var(--ink);background:var(--white);outline:0;\" />\n </div>\n <div class=\"field\">\n <span>强力模型(分析、建模、写作、评审)</span>\n <input id=\"obStrongModel\" value=\"${escapeHtml(onboardingData.strongModel)}\" style=\"width:100%;height:42px;padding:0 12px;border:1px solid var(--line);border-radius:8px;font-size:14px;color:var(--ink);background:var(--white);outline:0;\" />\n </div>\n <button class=\"primary-button\" type=\"button\" id=\"obTestBtn\">🔍 测试连接</button>\n <div id=\"obTestResult\"></div>\n `;\n const dmInput = document.querySelector(\"#obDefaultModel\");\n const smInput = document.querySelector(\"#obStrongModel\");\n dmInput.addEventListener(\"input\", () => { onboardingData.defaultModel = dmInput.value; });\n smInput.addEventListener(\"input\", () => { onboardingData.strongModel = smInput.value; });\n\n let testOk = false;\n document.querySelector(\"#obTestBtn\").addEventListener(\"click\", async () => {\n const result = document.querySelector(\"#obTestResult\");\n result.innerHTML = '<p style=\"color:var(--muted);\">测试中...</p>';\n try {\n const [r1, r2] = await Promise.all([\n testLlm(onboardingData.apiBase, onboardingData.apiKey, onboardingData.defaultModel),\n testLlm(onboardingData.apiBase, onboardingData.apiKey, onboardingData.strongModel),\n ]);\n const html = renderTestResult(\"主力模型\", r1) + renderTestResult(\"强力模型\", r2);\n result.innerHTML = html;\n testOk = r1.success && r2.success;\n onboardingNext.disabled = !testOk;\n } catch (error) {\n result.innerHTML = `<div class=\"test-result fail\">测试失败: ${escapeHtml(error.message)}</div>`;\n }\n });\n onboardingNext.disabled = true;\n}\n\nasync function testLlm(apiBase, apiKey, model) {\n const res = await api(\"/api/config/test-llm\", {\n method: \"POST\",\n body: JSON.stringify({ apiBase, apiKey, model }),\n });\n return res;\n}\n\nfunction renderTestResult(label, result) {\n const cls = result.success ? \"ok\" : \"fail\";\n const text = result.success\n ? `✓ ${label} 连接成功 · ${result.latency_ms}ms`\n : `✗ ${label} 失败: ${escapeHtml(result.error || \"未知错误\")}`;\n return `<div class=\"test-result ${cls}\">${text}</div>`;\n}\n\nasync function renderCompleteStep() {\n // 保存配置\n try {\n await api(\"/api/config\", {\n method: \"POST\",\n body: JSON.stringify({\n apiBase: onboardingData.apiBase,\n apiKey: onboardingData.apiKey,\n defaultModel: onboardingData.defaultModel,\n strongModel: onboardingData.strongModel,\n }),\n });\n } catch (error) {\n showToast(`保存配置失败: ${error.message}`);\n }\n\n const providerName = onboardingData.provider === \"custom\" ? \"自定义\" : onboardingData.provider;\n onboardingBody.innerHTML = `\n <div style=\"text-align:center;padding:20px 0;\">\n <div style=\"font-size:48px;margin-bottom:12px;\">🎉</div>\n <h3 style=\"color:var(--green);\">配置完成!</h3>\n <p>配置已自动保存到 .env 文件</p>\n <div style=\"margin:16px auto;padding:14px;border:1px solid var(--line);border-radius:8px;background:#fbfcfe;text-align:left;font-size:0.86rem;max-width:400px;\">\n <p><strong>端点:</strong> ${escapeHtml(onboardingData.apiBase)}</p>\n <p><strong>主力模型:</strong> ${escapeHtml(onboardingData.defaultModel)}</p>\n <p><strong>强力模型:</strong> ${escapeHtml(onboardingData.strongModel)}</p>\n </div>\n </div>\n `;\n}\n\nonboardingPrev?.addEventListener(\"click\", () => {\n if (onboardingStep > 1) {\n onboardingStep--;\n renderOnboardingStep();\n }\n});\n\nonboardingNext?.addEventListener(\"click\", () => {\n if (onboardingStep < 5) {\n onboardingStep++;\n renderOnboardingStep();\n } else {\n hideOnboarding();\n showToast(\"配置完成,欢迎使用 Beacon!\");\n }\n});\n```\n\n- [ ] **Step 3: 在 `frontend/app.js` 末尾添加设置页面逻辑**\n\n```javascript\n// ============ 设置页面 ============\n\nlet settingsTab = \"api\";\nlet settingsConfig = null;\n\nasync function loadSettings() {\n try {\n settingsConfig = await api(\"/api/config\");\n renderSettings();\n } catch (error) {\n settingsContent.innerHTML = `<p>加载配置失败: ${escapeHtml(error.message)}</p>`;\n }\n}\n\nfunction renderSettings() {\n if (!settingsConfig) return;\n if (settingsTab === \"api\") renderSettingsApi();\n else if (settingsTab === \"models\") renderSettingsModels();\n else if (settingsTab === \"rag\") renderSettingsRag();\n else if (settingsTab === \"advanced\") renderSettingsAdvanced();\n}\n\nfunction renderSettingsApi() {\n settingsContent.innerHTML = `\n <div class=\"field\">\n <span>API 端点</span>\n <input id=\"setApiBase\" value=\"${escapeHtml(settingsConfig.apiBase)}\" />\n <span class=\"field-hint\">OpenAI 兼容端点地址</span>\n </div>\n <div class=\"field\">\n <span>API 密钥</span>\n <div style=\"display:flex;gap:8px;\">\n <input id=\"setApiKey\" type=\"password\" value=\"${escapeHtml(settingsConfig.apiKey)}\" style=\"flex:1;\" />\n <button class=\"ghost-button\" type=\"button\" id=\"setToggleKey\">👁 显示</button>\n <button class=\"primary-button\" type=\"button\" id=\"setTestBtn\">🔍 测试</button>\n </div>\n <div id=\"setTestResult\"></div>\n </div>\n `;\n document.querySelector(\"#setToggleKey\")?.addEventListener(\"click\", (e) => {\n const input = document.querySelector(\"#setApiKey\");\n if (input.type === \"password\") { input.type = \"text\"; e.target.textContent = \"👁 隐藏\"; }\n else { input.type = \"password\"; e.target.textContent = \"👁 显示\"; }\n });\n document.querySelector(\"#setTestBtn\")?.addEventListener(\"click\", async () => {\n const apiBase = document.querySelector(\"#setApiBase\").value;\n const apiKey = document.querySelector(\"#setApiKey\").value;\n const model = settingsConfig.defaultModel || \"test\";\n const result = document.querySelector(\"#setTestResult\");\n result.innerHTML = '<p class=\"field-hint\">测试中...</p>';\n try {\n const r = await testLlm(apiBase, apiKey.includes(\"***\") ? \"\" : apiKey, model);\n result.innerHTML = r.success\n ? `<div class=\"test-result ok\">✓ 连接成功 · ${r.latency_ms}ms</div>`\n : `<div class=\"test-result fail\">✗ ${escapeHtml(r.error)}</div>`;\n } catch (error) {\n result.innerHTML = `<div class=\"test-result fail\">✗ ${escapeHtml(error.message)}</div>`;\n }\n });\n}\n\nfunction renderSettingsModels() {\n settingsContent.innerHTML = `\n <div class=\"field\">\n <span>主力模型(编码、灵敏度分析)</span>\n <input id=\"setDefaultModel\" value=\"${escapeHtml(settingsConfig.defaultModel)}\" />\n <span class=\"field-hint\">用于 routine 节点,格式:provider/model</span>\n </div>\n <div class=\"field\">\n <span>强力模型(分析、建模、写作、评审)</span>\n <input id=\"setStrongModel\" value=\"${escapeHtml(settingsConfig.strongModel)}\" />\n <span class=\"field-hint\">用于核心节点</span>\n </div>\n `;\n}\n\nfunction renderSettingsRag() {\n settingsContent.innerHTML = `\n <div class=\"toggle-row\">\n <div>\n <strong>启用 RAG 知识库</strong>\n <p class=\"field-hint\">启用后注入经典模型模式到提示词</p>\n </div>\n <div class=\"toggle-switch ${settingsConfig.ragEnabled ? \"on\" : \"\"}\" id=\"setRagToggle\"></div>\n </div>\n <div class=\"field\">\n <span>Embedding 模型</span>\n <input id=\"setRagEmbed\" value=\"${escapeHtml(settingsConfig.ragEmbeddingModel)}\" />\n </div>\n <div class=\"field\">\n <span>检索 Top-K</span>\n <input id=\"setRagTopK\" type=\"number\" value=\"${settingsConfig.ragTopK}\" />\n </div>\n `;\n document.querySelector(\"#setRagToggle\")?.addEventListener(\"click\", (e) => {\n e.target.classList.toggle(\"on\");\n });\n}\n\nfunction renderSettingsAdvanced() {\n settingsContent.innerHTML = `\n <div class=\"field\">\n <span>模型迭代轮次上限</span>\n <div style=\"display:flex;align-items:center;gap:12px;\">\n <input id=\"setIterations\" type=\"range\" min=\"1\" max=\"5\" value=\"${settingsConfig.maxModelIterations}\" style=\"flex:1;\" />\n <span id=\"setIterationsVal\" style=\"font-weight:700;color:var(--blue);\">${settingsConfig.maxModelIterations}</span>\n </div>\n </div>\n <div class=\"field\">\n <span>LLM 超时(秒)</span>\n <input id=\"setLlmTimeout\" type=\"number\" value=\"${settingsConfig.llmTimeout}\" />\n </div>\n <div class=\"field\">\n <span>前端端口</span>\n <input id=\"setPort\" type=\"number\" value=\"${settingsConfig.port}\" />\n <span class=\"field-hint\">修改后需重启服务生效</span>\n </div>\n `;\n document.querySelector(\"#setIterations\")?.addEventListener(\"input\", (e) => {\n document.querySelector(\"#setIterationsVal\").textContent = e.target.value;\n });\n}\n\nfunction collectSettings() {\n const config = {};\n const apiBase = document.querySelector(\"#setApiBase\");\n const apiKey = document.querySelector(\"#setApiKey\");\n const defaultModel = document.querySelector(\"#setDefaultModel\");\n const strongModel = document.querySelector(\"#setStrongModel\");\n const ragToggle = document.querySelector(\"#setRagToggle\");\n const ragEmbed = document.querySelector(\"#setRagEmbed\");\n const ragTopK = document.querySelector(\"#setRagTopK\");\n const iterations = document.querySelector(\"#setIterations\");\n const llmTimeout = document.querySelector(\"#setLlmTimeout\");\n const port = document.querySelector(\"#setPort\");\n\n if (apiBase) config.apiBase = apiBase.value;\n if (apiKey && !apiKey.value.includes(\"***\")) config.apiKey = apiKey.value;\n if (defaultModel) config.defaultModel = defaultModel.value;\n if (strongModel) config.strongModel = strongModel.value;\n if (ragToggle) config.ragEnabled = ragToggle.classList.contains(\"on\");\n if (ragEmbed) config.ragEmbeddingModel = ragEmbed.value;\n if (ragTopK) config.ragTopK = Number(ragTopK.value);\n if (iterations) config.maxModelIterations = Number(iterations.value);\n if (llmTimeout) config.llmTimeout = Number(llmTimeout.value);\n if (port) config.port = Number(port.value);\n return config;\n}\n\nsettingsNavButtons.forEach((btn) => {\n btn.addEventListener(\"click\", () => {\n settingsNavButtons.forEach((b) => b.classList.remove(\"active\"));\n btn.classList.add(\"active\");\n settingsTab = btn.dataset.settingsTab;\n renderSettings();\n });\n});\n\nsettingsSave?.addEventListener(\"click\", async () => {\n try {\n const config = collectSettings();\n await api(\"/api/config\", { method: \"POST\", body: JSON.stringify(config) });\n showToast(\"配置已保存\");\n await loadSettings();\n } catch (error) {\n showToast(`保存失败: ${error.message}`);\n }\n});\n\nsettingsReset?.addEventListener(\"click\", async () => {\n if (!confirm(\"确定要重置为 .env.example 的默认值吗?\")) return;\n try {\n // 读取 .env.example 的默认值\n const { readFile } = await import(\"node:fs/promises\");\n // 通过 API 保存默认值\n await api(\"/api/config\", {\n method: \"POST\",\n body: JSON.stringify({\n apiBase: \"http://localhost:20128/v1\",\n apiKey: \"123456\",\n defaultModel: \"deepseek-v4-flash-free\",\n strongModel: \"deepseek-v4-flash-free\",\n llmTimeout: 300,\n maxModelIterations: 3,\n ragEnabled: false,\n ragEmbeddingModel: \"text-embedding-3-small\",\n ragTopK: 4,\n port: 5173,\n }),\n });\n showToast(\"已重置为默认值\");\n await loadSettings();\n } catch (error) {\n showToast(`重置失败: ${error.message}`);\n }\n});\n```\n\n- [ ] **Step 4: 修改启动逻辑,检查 onboarding 状态**\n\n在 `frontend/app.js` 末尾找到现有的 health 检查(约第 656 行):\n\n```javascript\napi(\"/api/health\")\n .then((health) => showToast(`已连接项目:${health.fixtures} 个示例题`))\n .catch((error) => showToast(`项目 API 未连接:${error.message}`));\n```\n\n替换为:\n\n```javascript\napi(\"/api/health\")\n .then((health) => {\n if (health.onboarding && health.onboarding.needed) {\n showOnboarding();\n } else {\n showToast(`已连接项目:${health.fixtures} 个示例题`);\n }\n })\n .catch((error) => showToast(`项目 API 未连接:${error.message}`));\n```\n\n- [ ] **Step 5: 修改导航逻辑,支持设置页面切换**\n\n在 `frontend/app.js` 中找到 `navLinks.forEach` 行(约第 509 行),在其之前添加设置页面切换逻辑:\n\n```javascript\n// 设置页面导航\ndocument.querySelectorAll(\".nav-list a\").forEach((link) => {\n link.addEventListener(\"click\", (e) => {\n if (link.getAttribute(\"href\") === \"#settings\") {\n e.preventDefault();\n document.querySelectorAll(\".nav-list a\").forEach((l) => l.classList.remove(\"active\"));\n link.classList.add(\"active\");\n // 隐藏工作台,显示设置\n document.querySelector(\"#workspace\").style.display = \"none\";\n settingsPanel.hidden = false;\n loadSettings();\n } else {\n // 显示工作台,隐藏设置\n document.querySelector(\"#workspace\").style.display = \"\";\n settingsPanel.hidden = true;\n }\n });\n});\n```\n\n- [ ] **Step 6: Commit**\n\n```bash\ngit add frontend/app.js\ngit commit -m \"feat: add onboarding wizard + settings page JS logic\"\n```\n\n---\n\n### Task 11: 增强 start.bat\n\n**Files:**\n- Modify: `scripts/start.bat`\n\n- [ ] **Step 1: 替换 `scripts/start.bat` 全部内容**\n\n```batch\n@echo off\nchcp 65001 >nul\nsetlocal EnableDelayedExpansion\n\necho ============================================\necho Beacon Math Agent - 一键启动 (Windows)\necho ============================================\necho.\n\ncd /d \"%~dp0..\"\n\nREM --- 检查 Node.js(唯一硬依赖,没有它无法启动服务器)---\nwhere node >nul 2>nul\nif %errorlevel% neq 0 (\n echo [错误] 未找到 Node.js,请先安装:https://nodejs.org\n echo 安装后重新运行此脚本。\n pause\n exit /b 1\n)\nfor /f \"delims=\" %%v in ('node --version') do set \"node_version=%%v\"\necho [OK] Node.js %node_version% 已就绪\n\nREM --- 检查 .env ---\nif not exist \".env\" (\n if exist \".env.example\" (\n echo [初始化] 从 .env.example 创建 .env\n copy \".env.example\" \".env\" >nul\n echo 启动后可通过引导向导配置 API 密钥\n )\n)\n\nREM --- 安装 Node.js 依赖 ---\nif not exist \"node_modules\" (\n echo.\n echo [安装] Node.js 依赖...\n call npm install\n if !errorlevel! neq 0 (\n echo [错误] npm install 失败\n pause\n exit /b 1\n )\n)\n\nREM --- 检查 uv(不阻断启动,引导向导会处理安装)---\nwhere uv >nul 2>nul\nif %errorlevel% neq 0 (\n echo [提示] 未找到 uv,Python 后端暂不可用\n echo 启动后可在引导向导中一键安装 uv\n) else (\n echo [OK] uv 已就绪\n REM --- 安装 Python 依赖 ---\n echo [检查] Python 依赖...\n uv sync 2>nul\n if !errorlevel! neq 0 (\n echo [警告] uv sync 失败,Python 后端可能无法启动\n )\n)\n\nREM --- 启动 ---\necho.\necho ============================================\necho 正在启动服务...\necho 浏览器访问 http://localhost:5173\necho 首次使用会自动进入配置引导\necho 按 Ctrl+C 停止\necho ============================================\necho.\n\nREM 等待 2 秒后自动打开浏览器\nstart \"\" /b cmd /c \"timeout /t 2 >nul && start http://localhost:5173\"\n\ncall npm start\npause\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add scripts/start.bat\ngit commit -m \"feat: enhance start.bat - non-blocking uv check + onboarding hint\"\n```\n\n---\n\n### Task 12: 端到端验证\n\n- [ ] **Step 1: 运行全部测试**\n\nRun: `node --test frontend/server.test.mjs`\nExpected: 所有测试 PASS\n\n- [ ] **Step 2: 启动服务器手动验证**\n\nRun: `npm start`\n\n验证项:\n1. 打开 `http://localhost:5173`,如果 .env 没有 API 密钥,应自动弹出引导向导\n2. 引导向导步骤1应显示 Python/Node/uv 检测结果\n3. 点击侧边栏\"设置\"应进入设置页面\n4. 设置页面四个分类切换正常\n5. 保存配置后 `.env` 文件应被更新\n\n- [ ] **Step 3: 最终 Commit**\n\n```bash\ngit add -A\ngit commit -m \"feat: complete onboarding wizard + settings page + start.bat enhancement\"\n```\n\n---\n\n## 自查\n\n**1. Spec 覆盖:**\n- ✓ 引导向导5步:Task 8 (HTML) + Task 9 (CSS) + Task 10 (JS)\n- ✓ 设置页面4个分类:Task 8 + Task 10\n- ✓ 启动脚本增强:Task 11\n- ✓ 环境检测 + winget 安装:Task 3\n- ✓ .env 读写:Task 4\n- ✓ LLM 连接测试:Task 4\n- ✓ 引导状态判断:Task 5\n- ✓ providers.json 预设:Task 2\n- ✓ server.mjs 路由分发:Task 6\n- ✓ health 端点增强:Task 6\n- ✓ 错误处理(winget不可用/超时/失败):Task 3\n- ✓ 测试:Task 7\n\n**2. 占位符扫描:** 无 TBD/TODO,所有步骤都有完整代码。✓\n\n**3. 类型一致性:**\n- `handleEnvRoutes(request, response, url)` 签名在 Task 3 和 Task 6 一致 ✓\n- `handleConfigRoutes(request, response, url)` 签名在 Task 4 和 Task 6 一致 ✓\n- `handleOnboardingRoutes(request, response, url)` 签名在 Task 5 和 Task 6 一致 ✓\n- `getOnboardingStatus()` 在 Task 5 定义,Task 6 导入使用 ✓\n- 前端 API 路径 `/api/env/check`、`/api/config`、`/api/config/test-llm`、`/api/onboarding/status`、`/api/providers` 在前后端一致 ✓\n- `testLlm(apiBase, apiKey, model)` 在 Task 10 定义并在多处使用 ✓\n"}, "05990eb9bf14055753eab2c1e27447eb85357573": {"t": 1785761234.5016177, "n": 55718, "h": "2a2861eb9233b37a", "c": "# Beacon 打包与使用门槛降低 - 实现计划\n\n> **历史方案记录(非现行事实源)**:路径、状态、命令和待办保留当时上下文;当前实现与限制见 [`../../README.md`](../../README.md)。\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** 在现有 Beacon 架构上新增引导向导(5步)、设置页面、启动脚本增强,让用户无需手动编辑 .env 或安装命令行工具即可使用。\n\n**Architecture:** Node 服务器全权负责环境检测/winget 安装/.env 读写/LLM 连接测试。将 server.mjs 的共享工具提取到 `frontend/lib/shared.mjs`,新增路由模块挂载到 `/api/env/*`、`/api/config/*`、`/api/onboarding/*`。前端新增引导向导覆盖层和设置页面。Python 后端完全不改。\n\n**Tech Stack:** Node.js (原生 http)、vanilla HTML/CSS/JS、winget (Windows 包管理)、dotenv\n\n---\n\n## 文件结构\n\n| 文件 | 操作 | 职责 |\n|------|------|------|\n| `frontend/lib/shared.mjs` | 新建 | 共享工具:sendJson、readJsonBody、safeProjectPath、HttpError、projectRoot、env 解析 |\n| `frontend/providers.json` | 新建 | 6 个 API 提供商预设数据 |\n| `frontend/routes/env.mjs` | 新建 | 环境检测 + winget 安装 |\n| `frontend/routes/config.mjs` | 新建 | .env 读写 + LLM 连接测试 |\n| `frontend/routes/onboarding.mjs` | 新建 | 引导状态判断 |\n| `frontend/server.mjs` | 修改 | 路由分发 + health 增强 |\n| `frontend/server.test.mjs` | 修改 | 新增 API 测试 |\n| `frontend/index.html` | 修改 | 引导向导覆盖层 + 设置页入口 |\n| `frontend/styles.css` | 修改 | 引导/设置样式 |\n| `frontend/app.js` | 修改 | 引导/设置交互逻辑 |\n| `scripts/start.bat` | 修改 | 不阻断启动 + 友好提示 |\n\n---\n\n### Task 1: 创建共享工具模块\n\n**Files:**\n- Create: `frontend/lib/shared.mjs`\n\n- [ ] **Step 1: 创建 `frontend/lib/shared.mjs`**\n\n从 `server.mjs` 提取共享工具,供路由模块复用。\n\n```javascript\n// frontend/lib/shared.mjs\nimport { resolve } from \"node:path\";\nimport { isAbsolute, relative, sep } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nexport const root = resolve(fileURLToPath(new URL(\".\", import.meta.url)));\nexport const projectRoot = resolve(root, \"..\", \"..\");\nexport const env = globalThis.process?.env || {};\n\nexport class HttpError extends Error {\n constructor(status, message) {\n super(message);\n this.status = status;\n }\n}\n\nexport function sendJson(response, status, payload) {\n response.writeHead(status, { \"Content-Type\": \"application/json; charset=utf-8\" });\n response.end(JSON.stringify(payload));\n}\n\nexport function safeProjectPath(inputPath) {\n const target = resolve(projectRoot, inputPath || \".\");\n const rel = relative(projectRoot, target);\n if (isAbsolute(rel) || rel === \"..\" || rel.startsWith(`..${sep}`)) {\n throw new HttpError(403, \"Path is outside the project workspace.\");\n }\n return target;\n}\n\nexport async function readJsonBody(request) {\n const chunks = [];\n let size = 0;\n for await (const chunk of request) {\n size += chunk.length;\n if (size > 1024 * 1024) throw new HttpError(413, \"Request body exceeds 1 MiB.\");\n chunks.push(chunk);\n }\n const text = Buffer.concat(chunks).toString(\"utf8\");\n if (!text) return {};\n try {\n return JSON.parse(text);\n } catch (error) {\n throw new HttpError(400, `Invalid JSON body: ${error.message}`);\n }\n}\n```\n\n- [ ] **Step 2: 验证模块可被导入**\n\nRun: `node -e \"import('./frontend/lib/shared.mjs').then(m => console.log('OK', m.projectRoot))\"`\nExpected: 输出 `OK` + 项目根路径\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add frontend/lib/shared.mjs\ngit commit -m \"feat: extract shared utilities to lib/shared.mjs\"\n```\n\n---\n\n### Task 2: 创建 API 提供商预设数据\n\n**Files:**\n- Create: `frontend/providers.json`\n\n- [ ] **Step 1: 创建 `frontend/providers.json`**\n\n```json\n[\n {\n \"id\": \"deepseek\",\n \"name\": \"DeepSeek\",\n \"apiBase\": \"https://api.deepseek.com/v1\",\n \"defaultModel\": \"deepseek-chat\",\n \"strongModel\": \"deepseek-reasoner\",\n \"website\": \"https://platform.deepseek.com\",\n \"description\": \"性价比高,适合数学建模\"\n },\n {\n \"id\": \"openai\",\n \"name\": \"OpenAI\",\n \"apiBase\": \"https://api.openai.com/v1\",\n \"defaultModel\": \"openai/gpt-4o-mini\",\n \"strongModel\": \"openai/gpt-4o\",\n \"website\": \"https://platform.openai.com\",\n \"description\": \"GPT 系列,能力全面\"\n },\n {\n \"id\": \"zhipu\",\n \"name\": \"智谱 AI\",\n \"apiBase\": \"https://open.bigmodel.cn/api/paas/v4\",\n \"defaultModel\": \"glm-4-flash\",\n \"strongModel\": \"glm-4\",\n \"website\": \"https://open.bigmodel.cn\",\n \"description\": \"GLM 系列,国产大模型\"\n },\n {\n \"id\": \"moonshot\",\n \"name\": \"月之暗面\",\n \"apiBase\": \"https://api.moonshot.cn/v1\",\n \"defaultModel\": \"moonshot-v1-8k\",\n \"strongModel\": \"moonshot-v1-32k\",\n \"website\": \"https://platform.moonshot.cn\",\n \"description\": \"Kimi,长上下文优势\"\n },\n {\n \"id\": \"ollama\",\n \"name\": \"本地 Ollama\",\n \"apiBase\": \"http://localhost:11434/v1\",\n \"defaultModel\": \"llama3\",\n \"strongModel\": \"llama3\",\n \"website\": \"https://ollama.com\",\n \"description\": \"本地部署,离线可用\"\n },\n {\n \"id\": \"custom\",\n \"name\": \"自定义\",\n \"apiBase\": \"\",\n \"defaultModel\": \"\",\n \"strongModel\": \"\",\n \"website\": \"\",\n \"description\": \"手动填写端点和模型\"\n }\n]\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add frontend/providers.json\ngit commit -m \"feat: add API provider presets (providers.json)\"\n```\n\n---\n\n### Task 3: 创建环境检测路由模块\n\n**Files:**\n- Create: `frontend/routes/env.mjs`\n\n- [ ] **Step 1: 创建 `frontend/routes/env.mjs`**\n\n```javascript\n// frontend/routes/env.mjs\nimport { execSync } from \"node:child_process\";\nimport { sendJson, readJsonBody, HttpError } from \"../lib/shared.mjs\";\n\n/**\n * 检测单个工具的版本信息。\n * @param {string} command - 命令名 (如 \"python\", \"node\", \"uv\")\n * @returns {{ installed: boolean, version: string }}\n */\nfunction checkTool(command) {\n try {\n const output = execSync(`${command} --version`, {\n encoding: \"utf8\",\n timeout: 10_000,\n windowsHide: true,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n }).trim();\n // 提取版本号:取输出中第一个 x.y.z 格式的字符串\n const match = output.match(/(\\d+\\.\\d+(?:\\.\\d+)?)/);\n return { installed: true, version: match ? match[1] : output };\n } catch {\n return { installed: false, version: \"\" };\n }\n}\n\n/**\n * 比较版本号是否达标。\n * @param {string} version - 实际版本 \"3.12.3\"\n * @param {string} minVersion - 最低要求 \"3.11\"\n * @returns {boolean}\n */\nfunction versionOk(version, minVersion) {\n if (!version) return false;\n const actual = version.split(\".\").map(Number);\n const required = minVersion.split(\".\").map(Number);\n for (let i = 0; i < required.length; i++) {\n const a = actual[i] || 0;\n const r = required[i] || 0;\n if (a > r) return true;\n if (a < r) return false;\n }\n return true;\n}\n\nconst WINGET_PACKAGES = {\n python: \"Python.Python.3.12\",\n node: \"OpenJS.NodeJS.LTS\",\n uv: \"astral-sh.uv\",\n};\n\n/**\n * 处理 /api/env/* 路由。\n */\nexport async function handleEnvRoutes(request, response, url) {\n // GET /api/env/check - 检测环境\n if (request.method === \"GET\" && url.pathname === \"/api/env/check\") {\n const python = checkTool(\"python\");\n const python3 = !python.installed ? checkTool(\"python3\") : null;\n const node = checkTool(\"node\");\n const uv = checkTool(\"uv\");\n\n const pythonResult = python.installed\n ? python\n : python3 && python3.installed\n ? python3\n : { installed: false, version: \"\" };\n\n sendJson(response, 200, {\n python: {\n installed: pythonResult.installed,\n version: pythonResult.version,\n ok: pythonResult.installed && versionOk(pythonResult.version, \"3.11\"),\n },\n node: {\n installed: node.installed,\n version: node.version,\n ok: node.installed && versionOk(node.version, \"18\"),\n },\n uv: {\n installed: uv.installed,\n version: uv.version,\n ok: uv.installed,\n },\n allOk:\n pythonResult.installed &&\n versionOk(pythonResult.version, \"3.11\") &&\n node.installed &&\n versionOk(node.version, \"18\") &&\n uv.installed,\n });\n return;\n }\n\n // POST /api/env/install - winget 安装\n if (request.method === \"POST\" && url.pathname === \"/api/env/install\") {\n const body = await readJsonBody(request);\n const tool = body.tool;\n if (!tool || !WINGET_PACKAGES[tool]) {\n throw new HttpError(400, `Invalid tool. Must be one of: ${Object.keys(WINGET_PACKAGES).join(\", \")}`);\n }\n\n // 检查 winget 是否可用\n try {\n execSync(\"winget --version\", { encoding: \"utf8\", timeout: 10_000, windowsHide: true, stdio: [\"pipe\", \"pipe\", \"pipe\"] });\n } catch {\n sendJson(response, 200, {\n tool,\n status: \"failed\",\n message: \"winget 不可用,请手动安装\",\n downloadUrl: getDownloadUrl(tool),\n });\n return;\n }\n\n // 阻塞式安装\n const pkg = WINGET_PACKAGES[tool];\n try {\n const output = execSync(\n `winget install ${pkg} --silent --accept-package-agreements --accept-source-agreements`,\n {\n encoding: \"utf8\",\n timeout: 300_000, // 5 分钟\n windowsHide: true,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n },\n );\n sendJson(response, 200, {\n tool,\n status: \"done\",\n message: \"安装完成\",\n output: output.slice(-500),\n });\n } catch (error) {\n sendJson(response, 200, {\n tool,\n status: \"failed\",\n message: error.killed ? \"安装超时(超过5分钟)\" : \"安装失败\",\n output: String(error.stderr || error.stdout || error.message).slice(-500),\n downloadUrl: getDownloadUrl(tool),\n });\n }\n return;\n }\n\n // GET /api/providers - 返回提供商预设\n if (request.method === \"GET\" && url.pathname === \"/api/providers\") {\n const { readFile } = await import(\"node:fs/promises\");\n const { resolve } = await import(\"node:path\");\n const { projectRoot } = await import(\"../lib/shared.mjs\");\n try {\n const raw = await readFile(resolve(projectRoot, \"frontend/providers.json\"), \"utf8\");\n sendJson(response, 200, JSON.parse(raw));\n } catch {\n sendJson(response, 200, []);\n }\n return;\n }\n\n sendJson(response, 404, { error: \"Unknown env route.\" });\n}\n\nfunction getDownloadUrl(tool) {\n const urls = {\n python: \"https://www.python.org/downloads/\",\n node: \"https://nodejs.org/\",\n uv: \"https://docs.astral.sh/uv/getting-started/installation/\",\n };\n return urls[tool] || \"\";\n}\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add frontend/routes/env.mjs\ngit commit -m \"feat: add env check + winget install route module\"\n```\n\n---\n\n### Task 4: 创建配置读写路由模块\n\n**Files:**\n- Create: `frontend/routes/config.mjs`\n\n- [ ] **Step 1: 创建 `frontend/routes/config.mjs`**\n\n```javascript\n// frontend/routes/config.mjs\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { sendJson, readJsonBody, HttpError, projectRoot } from \"../lib/shared.mjs\";\n\nconst ENV_PATH = resolve(projectRoot, \".env\");\nconst ENV_EXAMPLE_PATH = resolve(projectRoot, \".env.example\");\n\n/** 前端字段 -> .env 变量名映射 */\nconst FIELD_MAP = {\n apiBase: \"OPENAI_API_BASE\",\n apiKey: \"OPENAI_API_KEY\",\n defaultModel: \"MATH_AGENT_DEFAULT_MODEL\",\n strongModel: \"MATH_AGENT_STRONG_MODEL\",\n llmTimeout: \"MATH_AGENT_LLM_TIMEOUT\",\n maxModelIterations: \"MATH_AGENT_MAX_MODEL_ITERATIONS\",\n ragEnabled: \"MATH_AGENT_RAG_ENABLED\",\n ragEmbeddingModel: \"MATH_AGENT_RAG_EMBED\",\n ragTopK: \"MATH_AGENT_RAG_TOPK\",\n port: \"PORT\",\n};\n\n/** .env 变量名 -> 前端字段 反向映射 */\nconst ENV_TO_FIELD = Object.fromEntries(\n Object.entries(FIELD_MAP).map(([field, envKey]) => [envKey, field]),\n);\n\n/**\n * 解析 .env 文件为 key-value 对象。\n */\nfunction parseEnvFile(filePath) {\n if (!existsSync(filePath)) return {};\n const content = readFileSync(filePath, \"utf8\");\n const result = {};\n for (const line of content.split(\"\\n\")) {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(\"#\")) continue;\n const eqIndex = trimmed.indexOf(\"=\");\n if (eqIndex === -1) continue;\n const key = trimmed.slice(0, eqIndex).trim();\n const value = trimmed.slice(eqIndex + 1).trim();\n result[key] = value;\n }\n return result;\n}\n\n/**\n * 掩码 API 密钥:显示前3位 + *** + 后4位。\n */\nfunction maskApiKey(key) {\n if (!key || key.length < 8) return key ? \"***\" : \"\";\n return `${key.slice(0, 3)}***${key.slice(-4)}`;\n}\n\n/**\n * 更新 .env 文件,保留注释和格式,仅替换对应行的值。\n */\nfunction updateEnvFile(updates) {\n const templatePath = existsSync(ENV_PATH) ? ENV_PATH : ENV_EXAMPLE_PATH;\n const content = readFileSync(templatePath, \"utf8\");\n const lines = content.split(\"\\n\");\n const remaining = new Set(Object.keys(updates));\n\n const result = lines.map((line) => {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(\"#\")) return line;\n const eqIndex = trimmed.indexOf(\"=\");\n if (eqIndex === -1) return line;\n const key = trimmed.slice(0, eqIndex).trim();\n if (key in updates) {\n remaining.delete(key);\n return `${key}=${updates[key]}`;\n }\n return line;\n });\n\n // 追加 .env 中不存在的新 key\n for (const key of remaining) {\n result.push(`${key}=${updates[key]}`);\n }\n\n writeFileSync(ENV_PATH, result.join(\"\\n\"), \"utf8\");\n}\n\n/**\n * 处理 /api/config* 路由。\n */\nexport async function handleConfigRoutes(request, response, url) {\n // GET /api/config - 读取当前配置\n if (request.method === \"GET\" && url.pathname === \"/api/config\") {\n const envVars = parseEnvFile(ENV_PATH);\n const config = {\n apiBase: envVars.OPENAI_API_BASE || \"\",\n apiKey: maskApiKey(envVars.OPENAI_API_KEY || \"\"),\n hasApiKey: !!(envVars.OPENAI_API_KEY && envVars.OPENAI_API_KEY !== \"123456\"),\n defaultModel: envVars.MATH_AGENT_DEFAULT_MODEL || \"\",\n strongModel: envVars.MATH_AGENT_STRONG_MODEL || \"\",\n llmTimeout: Number(envVars.MATH_AGENT_LLM_TIMEOUT || 300),\n maxModelIterations: Number(envVars.MATH_AGENT_MAX_MODEL_ITERATIONS || 3),\n ragEnabled: envVars.MATH_AGENT_RAG_ENABLED === \"1\",\n ragEmbeddingModel: envVars.MATH_AGENT_RAG_EMBED || \"text-embedding-3-small\",\n ragTopK: Number(envVars.MATH_AGENT_RAG_TOPK || 4),\n port: Number(envVars.PORT || 5173),\n };\n sendJson(response, 200, config);\n return;\n }\n\n // POST /api/config - 保存配置到 .env\n if (request.method === \"POST\" && url.pathname === \"/api/config\") {\n const body = await readJsonBody(request);\n\n const updates = {};\n // 只更新提供的字段\n if (body.apiBase !== undefined) updates[FIELD_MAP.apiBase] = body.apiBase;\n // apiKey: 如果值包含 *** 表示是掩码值,跳过不更新\n if (body.apiKey !== undefined && !body.apiKey.includes(\"***\")) {\n updates[FIELD_MAP.apiKey] = body.apiKey;\n }\n if (body.defaultModel !== undefined) updates[FIELD_MAP.defaultModel] = body.defaultModel;\n if (body.strongModel !== undefined) updates[FIELD_MAP.strongModel] = body.strongModel;\n if (body.llmTimeout !== undefined) updates[FIELD_MAP.llmTimeout] = String(body.llmTimeout);\n if (body.maxModelIterations !== undefined) updates[FIELD_MAP.maxModelIterations] = String(body.maxModelIterations);\n if (body.ragEnabled !== undefined) updates[FIELD_MAP.ragEnabled] = body.ragEnabled ? \"1\" : \"0\";\n if (body.ragEmbeddingModel !== undefined) updates[FIELD_MAP.ragEmbeddingModel] = body.ragEmbeddingModel;\n if (body.ragTopK !== undefined) updates[FIELD_MAP.ragTopK] = String(body.ragTopK);\n if (body.port !== undefined) updates[FIELD_MAP.port] = String(body.port);\n\n if (Object.keys(updates).length === 0) {\n throw new HttpError(400, \"No config fields to update.\");\n }\n\n updateEnvFile(updates);\n sendJson(response, 200, { ok: true });\n return;\n }\n\n // POST /api/config/test-llm - 测试 LLM 连接\n if (request.method === \"POST\" && url.pathname === \"/api/config/test-llm\") {\n const body = await readJsonBody(request);\n const { apiBase, apiKey, model } = body;\n\n if (!apiBase || !apiKey || !model) {\n throw new HttpError(400, \"apiBase, apiKey, and model are required.\");\n }\n\n const startTime = Date.now();\n try {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n\n const res = await fetch(`${apiBase.replace(/\\/+$/, \"\")}/chat/completions`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n messages: [{ role: \"user\", content: \"hi\" }],\n max_tokens: 1,\n }),\n signal: controller.signal,\n });\n\n clearTimeout(timeout);\n const latency = Date.now() - startTime;\n\n if (res.ok) {\n sendJson(response, 200, { success: true, latency_ms: latency });\n } else {\n const errorText = await res.text().catch(() => \"\");\n let errorMsg = `HTTP ${res.status}`;\n if (res.status === 401) errorMsg = \"API 密钥无效\";\n else if (res.status === 404) errorMsg = \"模型不存在或端点错误\";\n else if (errorText) errorMsg = `${errorMsg}: ${errorText.slice(0, 200)}`;\n sendJson(response, 200, { success: false, latency_ms: latency, error: errorMsg });\n }\n } catch (error) {\n const latency = Date.now() - startTime;\n const errorMsg = error.name === \"AbortError\"\n ? \"连接超时(30秒)\"\n : `连接失败: ${error.message}`;\n sendJson(response, 200, { success: false, latency_ms: latency, error: errorMsg });\n }\n return;\n }\n\n sendJson(response, 404, { error: \"Unknown config route.\" });\n}\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add frontend/routes/config.mjs\ngit commit -m \"feat: add config read/write + LLM test route module\"\n```\n\n---\n\n### Task 5: 创建引导状态路由模块\n\n**Files:**\n- Create: `frontend/routes/onboarding.mjs`\n\n- [ ] **Step 1: 创建 `frontend/routes/onboarding.mjs`**\n\n```javascript\n// frontend/routes/onboarding.mjs\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { sendJson, projectRoot } from \"../lib/shared.mjs\";\n\nconst ENV_PATH = resolve(projectRoot, \".env\");\n\n/**\n * 判断是否需要引导。\n * @returns {{ needed: boolean, reason: \"no_env\" | \"no_api_key\" | \"done\" }}\n */\nexport function getOnboardingStatus() {\n if (!existsSync(ENV_PATH)) {\n return { needed: true, reason: \"no_env\" };\n }\n\n const content = readFileSync(ENV_PATH, \"utf8\");\n const apiKeyMatch = content.match(/^OPENAI_API_KEY\\s*=\\s*(.+)$/m);\n const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : \"\";\n\n if (!apiKey || apiKey === \"123456\") {\n return { needed: true, reason: \"no_api_key\" };\n }\n\n return { needed: false, reason: \"done\" };\n}\n\n/**\n * 处理 /api/onboarding/* 路由。\n */\nexport async function handleOnboardingRoutes(request, response, url) {\n // GET /api/onboarding/status\n if (request.method === \"GET\" && url.pathname === \"/api/onboarding/status\") {\n sendJson(response, 200, getOnboardingStatus());\n return;\n }\n\n sendJson(response, 404, { error: \"Unknown onboarding route.\" });\n}\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add frontend/routes/onboarding.mjs\ngit commit -m \"feat: add onboarding status route module\"\n```\n\n---\n\n### Task 6: 重构 server.mjs 接入新路由\n\n**Files:**\n- Modify: `frontend/server.mjs`\n\n- [ ] **Step 1: 在 server.mjs 顶部添加路由导入**\n\n在 `frontend/server.mjs` 第 9 行(`import { fileURLToPath }` 之后)添加:\n\n```javascript\nimport { handleEnvRoutes } from \"./routes/env.mjs\";\nimport { handleConfigRoutes } from \"./routes/config.mjs\";\nimport { handleOnboardingRoutes, getOnboardingStatus } from \"./routes/onboarding.mjs\";\n```\n\n- [ ] **Step 2: 在 handleApi 函数中添加新路由分发**\n\n在 `frontend/server.mjs` 的 `handleApi` 函数开头(第 249 行 `if (request.method === \"POST\" && url.pathname === \"/api/upload\")` 之前)插入:\n\n```javascript\n // --- 新增路由:环境检测、配置、引导 ---\n if (url.pathname.startsWith(\"/api/env/\") || url.pathname === \"/api/providers\") {\n return handleEnvRoutes(request, response, url);\n }\n if (url.pathname.startsWith(\"/api/config\")) {\n return handleConfigRoutes(request, response, url);\n }\n if (url.pathname.startsWith(\"/api/onboarding/\")) {\n return handleOnboardingRoutes(request, response, url);\n }\n```\n\n- [ ] **Step 3: 增强 health 端点,合并 onboarding 状态**\n\n在 `frontend/server.mjs` 中找到 health 端点(约第 313 行),替换为:\n\n```javascript\n if (request.method === \"GET\" && url.pathname === \"/api/health\") {\n const fixtures = await listFixtures().catch(() => []);\n const onboarding = getOnboardingStatus();\n sendJson(response, 200, {\n ok: true,\n projectRoot,\n mathAgentCommand: env.MATH_AGENT_COMMAND || \"uv run math-agent\",\n fixtures: fixtures.length,\n onboarding,\n });\n return;\n }\n```\n\n- [ ] **Step 4: 验证服务器能启动**\n\nRun: `node -e \"import('./frontend/lib/shared.mjs').then(() => console.log('shared OK'))\" && node -e \"import('./frontend/routes/env.mjs').then(() => console.log('env OK'))\" && node -e \"import('./frontend/routes/config.mjs').then(() => console.log('config OK'))\" && node -e \"import('./frontend/routes/onboarding.mjs').then(() => console.log('onboarding OK'))\"`\nExpected: 四行 `OK`\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add frontend/server.mjs\ngit commit -m \"feat: wire env/config/onboarding routes into server.mjs + enhance health\"\n```\n\n---\n\n### Task 7: 编写后端 API 集成测试\n\n**Files:**\n- Modify: `frontend/server.test.mjs`\n\n- [ ] **Step 1: 在 `frontend/server.test.mjs` 末尾追加新测试**\n\n在文件末尾(第 172 行之后)追加:\n\n```javascript\ntest(\"GET /api/env/check 返回环境检测结果\", async () => {\n const response = await fetch(`${base}/api/env/check`);\n assert.equal(response.status, 200);\n const data = await response.json();\n assert.ok(data.python);\n assert.ok(data.node);\n assert.ok(data.uv);\n assert.equal(typeof data.allOk, \"boolean\");\n});\n\ntest(\"GET /api/onboarding/status 返回引导状态\", async () => {\n const response = await fetch(`${base}/api/onboarding/status`);\n assert.equal(response.status, 200);\n const data = await response.json();\n assert.ok([\"no_env\", \"no_api_key\", \"done\"].includes(data.reason));\n assert.equal(typeof data.needed, \"boolean\");\n});\n\ntest(\"GET /api/config 返回配置(密钥掩码)\", async () => {\n const response = await fetch(`${base}/api/config`);\n assert.equal(response.status, 200);\n const data = await response.json();\n assert.ok(data.apiBase !== undefined);\n assert.ok(data.defaultModel !== undefined);\n assert.ok(data.strongModel !== undefined);\n // apiKey 不应包含明文(如果存在的话应被掩码)\n if (data.apiKey) {\n assert.ok(data.apiKey.includes(\"***\") || data.apiKey.length === 0);\n }\n});\n\ntest(\"GET /api/health 包含 onboarding 字段\", async () => {\n const response = await fetch(`${base}/api/health`);\n const data = await response.json();\n assert.ok(data.onboarding);\n assert.ok([\"no_env\", \"no_api_key\", \"done\"].includes(data.onboarding.reason));\n});\n\ntest(\"POST /api/config/test-llm 拒绝缺少参数的请求\", async () => {\n const response = await fetch(`${base}/api/config/test-llm`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ apiBase: \"https://example.com/v1\" }),\n });\n assert.equal(response.status, 400);\n});\n\ntest(\"GET /api/providers 返回提供商列表\", async () => {\n const response = await fetch(`${base}/api/providers`);\n assert.equal(response.status, 200);\n const data = await response.json();\n assert.ok(Array.isArray(data));\n assert.ok(data.length >= 5);\n assert.ok(data.some((p) => p.id === \"deepseek\"));\n});\n```\n\n- [ ] **Step 2: 运行测试**\n\nRun: `node --test frontend/server.test.mjs`\nExpected: 所有测试 PASS(包括原有测试)\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add frontend/server.test.mjs\ngit commit -m \"test: add integration tests for env/config/onboarding APIs\"\n```\n\n---\n\n### Task 8: 添加引导向导和设置页面的 HTML\n\n**Files:**\n- Modify: `frontend/index.html`\n\n- [ ] **Step 1: 在侧边栏导航添加\"设置\"入口**\n\n在 `frontend/index.html` 第 23 行(`<a href=\"#knowledge\">知识库</a>` 之后)添加:\n\n```html\n <a href=\"#settings\">设置</a>\n```\n\n- [ ] **Step 2: 在 `</main>` 之前添加设置页面容器**\n\n在 `frontend/index.html` 找到 `</main>`(第 230 行),在其之前添加:\n\n```html\n <section class=\"panel settings-panel\" id=\"settings\" hidden aria-labelledby=\"settings-title\">\n <div class=\"panel-heading\">\n <div>\n <p class=\"eyebrow\">Configuration</p>\n <h2 id=\"settings-title\">设置</h2>\n </div>\n </div>\n <div class=\"settings-layout\">\n <nav class=\"settings-nav\" aria-label=\"设置分类\">\n <button class=\"active\" type=\"button\" data-settings-tab=\"api\">API 配置</button>\n <button type=\"button\" data-settings-tab=\"models\">模型选择</button>\n <button type=\"button\" data-settings-tab=\"rag\">RAG 知识库</button>\n <button type=\"button\" data-settings-tab=\"advanced\">高级设置</button>\n </nav>\n <div class=\"settings-content\" id=\"settingsContent\"></div>\n </div>\n <div class=\"settings-footer\">\n <button class=\"ghost-button\" type=\"button\" id=\"settingsReset\">重置默认</button>\n <button class=\"primary-button\" type=\"button\" id=\"settingsSave\">💾 保存配置</button>\n </div>\n </section>\n```\n\n- [ ] **Step 3: 在 `<div class=\"toast\"` 之前添加引导向导覆盖层**\n\n在 `frontend/index.html` 找到 `<div class=\"toast\"` (第 233 行),在其之前添加:\n\n```html\n <div class=\"onboarding-overlay\" id=\"onboardingOverlay\" hidden>\n <div class=\"onboarding-card\">\n <div class=\"onboarding-header\">\n <h2 id=\"onboardingTitle\">欢迎使用 Beacon</h2>\n <p id=\"onboardingSubtitle\">让我们花几分钟完成初始配置</p>\n <div class=\"onboarding-steps\">\n <span class=\"step-dot active\" data-step=\"1\"></span>\n <span class=\"step-dot\" data-step=\"2\"></span>\n <span class=\"step-dot\" data-step=\"3\"></span>\n <span class=\"step-dot\" data-step=\"4\"></span>\n <span class=\"step-dot\" data-step=\"5\"></span>\n </div>\n </div>\n <div class=\"onboarding-body\" id=\"onboardingBody\"></div>\n <div class=\"onboarding-footer\">\n <button class=\"ghost-button\" type=\"button\" id=\"onboardingPrev\" disabled>上一步</button>\n <button class=\"primary-button\" type=\"button\" id=\"onboardingNext\">下一步</button>\n </div>\n </div>\n </div>\n```\n\n- [ ] **Step 4: Commit**\n\n```bash\ngit add frontend/index.html\ngit commit -m \"feat: add onboarding overlay + settings panel to index.html\"\n```\n\n---\n\n### Task 9: 添加引导向导和设置页面的 CSS\n\n**Files:**\n- Modify: `frontend/styles.css`\n\n- [ ] **Step 1: 在 `frontend/styles.css` 末尾追加引导向导和设置页面样式**\n\n```css\n/* ---- 引导向导覆盖层 ---- */\n\n.onboarding-overlay {\n position: fixed;\n inset: 0;\n z-index: 100;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(7, 27, 58, 0.6);\n backdrop-filter: blur(6px);\n}\n\n.onboarding-overlay[hidden] {\n display: none;\n}\n\n.onboarding-card {\n width: min(620px, calc(100vw - 48px));\n max-height: calc(100vh - 48px);\n display: flex;\n flex-direction: column;\n padding: 32px;\n border-radius: 16px;\n background: var(--white);\n box-shadow: 0 40px 80px rgba(7, 27, 58, 0.25);\n overflow-y: auto;\n}\n\n.onboarding-header h2 {\n margin: 0;\n font-size: 1.5rem;\n}\n\n.onboarding-header p {\n margin: 6px 0 0;\n color: var(--muted);\n font-size: 0.92rem;\n}\n\n.onboarding-steps {\n display: flex;\n gap: 8px;\n margin-top: 16px;\n}\n\n.step-dot {\n flex: 1;\n height: 4px;\n border-radius: 999px;\n background: var(--line);\n transition: background 200ms ease;\n}\n\n.step-dot.active {\n background: var(--gold);\n}\n\n.step-dot.done {\n background: var(--green);\n}\n\n.onboarding-body {\n flex: 1;\n min-height: 200px;\n margin: 24px 0;\n}\n\n.onboarding-body h3 {\n margin: 0 0 12px;\n font-size: 1.1rem;\n}\n\n.onboarding-body p {\n color: var(--ink-soft);\n line-height: 1.6;\n}\n\n.onboarding-footer {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n}\n\n/* 引导步骤内容 */\n\n.env-check-list {\n display: grid;\n gap: 10px;\n}\n\n.env-check-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 14px 16px;\n border: 1px solid var(--line);\n border-radius: 8px;\n background: #fbfcfe;\n}\n\n.env-check-item.ok {\n border-color: var(--green);\n background: #f0faf6;\n}\n\n.env-check-item.missing {\n border-color: var(--rose);\n background: #fff0f3;\n}\n\n.env-check-item .env-name {\n font-weight: 700;\n color: var(--ink);\n}\n\n.env-check-item .env-version {\n display: block;\n margin-top: 2px;\n font-size: 0.84rem;\n color: var(--muted);\n}\n\n.env-check-item .env-status-ok {\n color: var(--green);\n font-weight: 700;\n}\n\n.env-check-item .env-status-missing {\n color: var(--rose);\n font-weight: 700;\n}\n\n.env-install-btn {\n min-height: 34px;\n padding: 0 14px;\n border: none;\n border-radius: 6px;\n background: var(--gold);\n color: var(--ink);\n font-weight: 700;\n cursor: pointer;\n}\n\n.env-install-btn:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n}\n\n.env-install-btn.installing {\n background: var(--muted);\n}\n\n.provider-grid {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 10px;\n}\n\n.provider-card {\n padding: 14px;\n border: 2px solid var(--line);\n border-radius: 8px;\n background: #fbfcfe;\n cursor: pointer;\n transition: border-color 150ms ease;\n}\n\n.provider-card:hover {\n border-color: var(--blue);\n}\n\n.provider-card.selected {\n border-color: var(--gold);\n background: var(--gold-soft);\n}\n\n.provider-card strong {\n display: block;\n font-size: 1rem;\n}\n\n.provider-card span {\n display: block;\n margin-top: 4px;\n color: var(--muted);\n font-size: 0.82rem;\n}\n\n/* ---- 设置页面 ---- */\n\n.settings-panel[hidden] {\n display: none;\n}\n\n.settings-layout {\n display: flex;\n gap: 20px;\n min-height: 320px;\n}\n\n.settings-nav {\n display: flex;\n flex-direction: column;\n gap: 4px;\n width: 140px;\n flex-shrink: 0;\n}\n\n.settings-nav button {\n padding: 10px 14px;\n text-align: left;\n border: none;\n border-left: 3px solid transparent;\n border-radius: 0;\n background: transparent;\n color: var(--muted);\n font-weight: 600;\n cursor: pointer;\n}\n\n.settings-nav button.active {\n color: var(--blue);\n font-weight: 800;\n border-left-color: var(--blue);\n background: #f0f6ff;\n}\n\n.settings-content {\n flex: 1;\n display: grid;\n gap: 14px;\n align-content: start;\n}\n\n.settings-content .field {\n margin-bottom: 0;\n}\n\n.settings-content .field span {\n font-size: 0.86rem;\n}\n\n.settings-content .field input,\n.settings-content .field select {\n width: 100%;\n height: 42px;\n padding: 0 12px;\n border: 1px solid var(--line);\n border-radius: 8px;\n color: var(--ink);\n background: var(--white);\n font-size: 14px;\n outline: 0;\n}\n\n.settings-content .field input:focus,\n.settings-content .field select:focus {\n border-color: var(--blue);\n box-shadow: 0 0 0 3px rgba(21, 94, 150, 0.13);\n}\n\n.settings-content .field-hint {\n color: var(--muted);\n font-size: 0.8rem;\n}\n\n.settings-content .test-result {\n padding: 8px 12px;\n border-radius: 6px;\n font-size: 0.84rem;\n}\n\n.settings-content .test-result.ok {\n background: #dff6eb;\n color: var(--green);\n}\n\n.settings-content .test-result.fail {\n background: #fff0f3;\n color: var(--rose);\n}\n\n.settings-content .toggle-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 12px;\n border: 1px solid var(--line);\n border-radius: 8px;\n background: #fbfcfe;\n}\n\n.toggle-switch {\n position: relative;\n width: 44px;\n height: 24px;\n border-radius: 12px;\n background: var(--line);\n cursor: pointer;\n transition: background 200ms ease;\n}\n\n.toggle-switch.on {\n background: var(--green);\n}\n\n.toggle-switch::after {\n content: \"\";\n position: absolute;\n top: 2px;\n left: 2px;\n width: 20px;\n height: 20px;\n border-radius: 50%;\n background: var(--white);\n transition: transform 200ms ease;\n}\n\n.toggle-switch.on::after {\n transform: translateX(20px);\n}\n\n.settings-footer {\n display: flex;\n justify-content: flex-end;\n gap: 10px;\n margin-top: 16px;\n padding-top: 14px;\n border-top: 1px solid var(--line);\n}\n\n@media (max-width: 860px) {\n .settings-layout {\n flex-direction: column;\n }\n .settings-nav {\n flex-direction: row;\n width: 100%;\n overflow-x: auto;\n }\n .settings-nav button {\n border-left: none;\n border-bottom: 3px solid transparent;\n white-space: nowrap;\n }\n .settings-nav button.active {\n border-left: none;\n border-bottom-color: var(--blue);\n }\n .provider-grid {\n grid-template-columns: 1fr;\n }\n}\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add frontend/styles.css\ngit commit -m \"feat: add onboarding overlay + settings page styles\"\n```\n\n---\n\n### Task 10: 添加引导向导和设置页面的 JS 逻辑\n\n**Files:**\n- Modify: `frontend/app.js`\n\n- [ ] **Step 1: 在 `frontend/app.js` 开头添加 DOM 引用**\n\n在第 35 行(`const pipelineItems = ...` 之后)添加:\n\n```javascript\nconst onboardingOverlay = document.querySelector(\"#onboardingOverlay\");\nconst onboardingBody = document.querySelector(\"#onboardingBody\");\nconst onboardingTitle = document.querySelector(\"#onboardingTitle\");\nconst onboardingSubtitle = document.querySelector(\"#onboardingSubtitle\");\nconst onboardingPrev = document.querySelector(\"#onboardingPrev\");\nconst onboardingNext = document.querySelector(\"#onboardingNext\");\nconst stepDots = document.querySelectorAll(\".step-dot\");\nconst settingsPanel = document.querySelector(\"#settings\");\nconst settingsContent = document.querySelector(\"#settingsContent\");\nconst settingsSave = document.querySelector(\"#settingsSave\");\nconst settingsReset = document.querySelector(\"#settingsReset\");\nconst settingsNavButtons = document.querySelectorAll(\"[data-settings-tab]\");\n```\n\n- [ ] **Step 2: 在 `frontend/app.js` 末尾添加引导向导逻辑**\n\n在文件末尾追加:\n\n```javascript\n// ============ 引导向导 ============\n\nlet onboardingStep = 1;\nlet onboardingData = {\n provider: null,\n apiBase: \"\",\n apiKey: \"\",\n defaultModel: \"\",\n strongModel: \"\",\n};\n\nconst onboardingSteps = [\n { title: \"欢迎使用 Beacon\", subtitle: \"让我们花几分钟完成初始配置\" },\n { title: \"环境检测\", subtitle: \"检查运行所需依赖是否已安装\" },\n { title: \"选择 API 提供商\", subtitle: \"选择你的 LLM 服务提供商\" },\n { title: \"API 密钥 & 端点配置\", subtitle: \"填写 API 密钥和访问端点\" },\n { title: \"模型选择 & 连接测试\", subtitle: \"选择模型并验证连接\" },\n { title: \"配置完成\", subtitle: \"一切就绪,可以开始使用了\" },\n];\n\nfunction showOnboarding() {\n onboardingOverlay.hidden = false;\n onboardingStep = 1;\n renderOnboardingStep();\n}\n\nfunction hideOnboarding() {\n onboardingOverlay.hidden = true;\n}\n\nfunction updateStepDots() {\n stepDots.forEach((dot, i) => {\n dot.classList.toggle(\"active\", i + 1 === onboardingStep);\n dot.classList.toggle(\"done\", i + 1 < onboardingStep);\n });\n}\n\nfunction renderOnboardingStep() {\n const step = onboardingSteps[onboardingStep - 1];\n onboardingTitle.textContent = step.title;\n onboardingSubtitle.textContent = step.subtitle;\n updateStepDots();\n onboardingPrev.disabled = onboardingStep === 1;\n onboardingNext.textContent = onboardingStep === 5 ? \"进入工作台\" : \"下一步\";\n\n if (onboardingStep === 1) renderEnvCheckStep();\n else if (onboardingStep === 2) renderProviderStep();\n else if (onboardingStep === 3) renderApiKeyStep();\n else if (onboardingStep === 4) renderModelTestStep();\n else if (onboardingStep === 5) renderCompleteStep();\n}\n\nasync function renderEnvCheckStep() {\n onboardingBody.innerHTML = `\n <h3>环境检测</h3>\n <p>正在检测运行所需依赖...</p>\n <div class=\"env-check-list\" id=\"envCheckList\"></div>\n `;\n try {\n const data = await api(\"/api/env/check\");\n const list = document.querySelector(\"#envCheckList\");\n list.innerHTML = renderEnvItem(\"Python\", \"≥ 3.11\", data.python) +\n renderEnvItem(\"Node.js\", \"≥ 18\", data.node) +\n renderEnvItem(\"uv\", \"Python 包管理器\", data.uv);\n onboardingNext.disabled = !data.allOk;\n if (!data.allOk) {\n list.querySelectorAll(\".env-install-btn\").forEach((btn) => {\n btn.addEventListener(\"click\", () => installTool(btn.dataset.tool, btn));\n });\n }\n } catch (error) {\n onboardingBody.innerHTML = `<p>检测失败: ${escapeHtml(error.message)}</p>`;\n }\n}\n\nfunction renderEnvItem(name, requirement, info) {\n const ok = info.ok;\n const status = ok\n ? `<span class=\"env-status-ok\">✓ ${info.version}</span>`\n : `<span class=\"env-status-missing\">✗ ${info.installed ? info.version + \" 版本过低\" : \"未检测到\"}</span>`;\n const action = ok ? \"\" : `<button class=\"env-install-btn\" data-tool=\"${name.toLowerCase().replace(\".\", \"\")}\">一键安装</button>`;\n // 修正 tool 名称映射\n const toolName = name === \"Python\" ? \"python\" : name === \"Node.js\" ? \"node\" : name === \"uv\" ? \"uv\" : \"\";\n const actionBtn = ok ? \"\" : `<button class=\"env-install-btn\" data-tool=\"${toolName}\">一键安装</button>`;\n return `\n <div class=\"env-check-item ${ok ? \"ok\" : \"missing\"}\">\n <div>\n <span class=\"env-name\">${escapeHtml(name)}</span>\n <span class=\"env-version\">${escapeHtml(requirement)}</span>\n </div>\n <div style=\"display:flex;align-items:center;gap:10px;\">\n ${status}\n ${actionBtn}\n </div>\n </div>\n `;\n}\n\nasync function installTool(tool, btn) {\n btn.disabled = true;\n btn.classList.add(\"installing\");\n btn.textContent = \"安装中...\";\n try {\n const result = await api(\"/api/env/install\", {\n method: \"POST\",\n body: JSON.stringify({ tool }),\n });\n if (result.status === \"done\") {\n showToast(`${tool} 安装完成`);\n renderEnvCheckStep();\n } else {\n btn.disabled = false;\n btn.classList.remove(\"installing\");\n btn.textContent = \"重试\";\n showToast(`${tool} 安装失败: ${result.message}`);\n if (result.downloadUrl) {\n onboardingBody.insertAdjacentHTML(\"beforeend\",\n `<p style=\"margin-top:12px;color:var(--rose);\">手动安装: <a href=\"${result.downloadUrl}\" target=\"_blank\">${result.downloadUrl}</a></p>`);\n }\n }\n } catch (error) {\n btn.disabled = false;\n btn.classList.remove(\"installing\");\n btn.textContent = \"重试\";\n showToast(`安装失败: ${error.message}`);\n }\n}\n\nasync function renderProviderStep() {\n onboardingNext.disabled = true;\n onboardingBody.innerHTML = `\n <h3>选择 API 提供商</h3>\n <p>选择你的 LLM 服务提供商,选择后会自动填入端点和推荐模型。</p>\n <div class=\"provider-grid\" id=\"providerGrid\"></div>\n `;\n try {\n const providers = await api(\"/api/providers\");\n const grid = document.querySelector(\"#providerGrid\");\n grid.innerHTML = providers.map((p) => `\n <div class=\"provider-card\" data-provider=\"${escapeHtml(p.id)}\">\n <strong>${escapeHtml(p.name)}</strong>\n <span>${escapeHtml(p.description)}</span>\n </div>\n `).join(\"\");\n grid.querySelectorAll(\".provider-card\").forEach((card) => {\n card.addEventListener(\"click\", () => {\n grid.querySelectorAll(\".provider-card\").forEach((c) => c.classList.remove(\"selected\"));\n card.classList.add(\"selected\");\n const provider = providers.find((p) => p.id === card.dataset.provider);\n onboardingData.provider = provider.id;\n onboardingData.apiBase = provider.apiBase;\n onboardingData.defaultModel = provider.defaultModel;\n onboardingData.strongModel = provider.strongModel;\n onboardingNext.disabled = false;\n });\n });\n } catch (error) {\n onboardingBody.innerHTML = `<p>加载提供商列表失败: ${escapeHtml(error.message)}</p>`;\n }\n}\n\nfunction renderApiKeyStep() {\n onboardingBody.innerHTML = `\n <h3>API 密钥 & 端点配置</h3>\n <p>填写你的 API 密钥。端点已根据所选提供商自动填入,可修改。</p>\n <div class=\"field\">\n <span>API 端点(Base URL)</span>\n <input id=\"obApiBase\" value=\"${escapeHtml(onboardingData.apiBase)}\" placeholder=\"https://api.deepseek.com/v1\" style=\"width:100%;height:42px;padding:0 12px;border:1px solid var(--line);border-radius:8px;font-size:14px;color:var(--ink);background:var(--white);outline:0;\" />\n </div>\n <div class=\"field\">\n <span>API 密钥</span>\n <div style=\"display:flex;gap:8px;\">\n <input id=\"obApiKey\" type=\"password\" value=\"${escapeHtml(onboardingData.apiKey)}\" placeholder=\"sk-...\" style=\"flex:1;height:42px;padding:0 12px;border:1px solid var(--line);border-radius:8px;font-size:14px;color:var(--ink);background:var(--white);outline:0;\" />\n <button class=\"ghost-button\" type=\"button\" id=\"obToggleKey\">👁 显示</button>\n </div>\n </div>\n `;\n const apiBaseInput = document.querySelector(\"#obApiBase\");\n const apiKeyInput = document.querySelector(\"#obApiKey\");\n const toggleBtn = document.querySelector(\"#obToggleKey\");\n\n apiBaseInput.addEventListener(\"input\", () => { onboardingData.apiBase = apiBaseInput.value; });\n apiKeyInput.addEventListener(\"input\", () => { onboardingData.apiKey = apiKeyInput.value; });\n toggleBtn.addEventListener(\"click\", () => {\n if (apiKeyInput.type === \"password\") {\n apiKeyInput.type = \"text\";\n toggleBtn.textContent = \"👁 隐藏\";\n } else {\n apiKeyInput.type = \"password\";\n toggleBtn.textContent = \"👁 显示\";\n }\n });\n onboardingNext.disabled = false;\n}\n\nfunction renderModelTestStep() {\n onboardingBody.innerHTML = `\n <h3>模型选择 & 连接测试</h3>\n <p>选择主力模型和强力模型,然后测试连接。</p>\n <div class=\"field\">\n <span>主力模型(编码、灵敏度分析)</span>\n <input id=\"obDefaultModel\" value=\"${escapeHtml(onboardingData.defaultModel)}\" style=\"width:100%;height:42px;padding:0 12px;border:1px solid var(--line);border-radius:8px;font-size:14px;color:var(--ink);background:var(--white);outline:0;\" />\n </div>\n <div class=\"field\">\n <span>强力模型(分析、建模、写作、评审)</span>\n <input id=\"obStrongModel\" value=\"${escapeHtml(onboardingData.strongModel)}\" style=\"width:100%;height:42px;padding:0 12px;border:1px solid var(--line);border-radius:8px;font-size:14px;color:var(--ink);background:var(--white);outline:0;\" />\n </div>\n <button class=\"primary-button\" type=\"button\" id=\"obTestBtn\">🔍 测试连接</button>\n <div id=\"obTestResult\"></div>\n `;\n const dmInput = document.querySelector(\"#obDefaultModel\");\n const smInput = document.querySelector(\"#obStrongModel\");\n dmInput.addEventListener(\"input\", () => { onboardingData.defaultModel = dmInput.value; });\n smInput.addEventListener(\"input\", () => { onboardingData.strongModel = smInput.value; });\n\n let testOk = false;\n document.querySelector(\"#obTestBtn\").addEventListener(\"click\", async () => {\n const result = document.querySelector(\"#obTestResult\");\n result.innerHTML = '<p style=\"color:var(--muted);\">测试中...</p>';\n try {\n const [r1, r2] = await Promise.all([\n testLlm(onboardingData.apiBase, onboardingData.apiKey, onboardingData.defaultModel),\n testLlm(onboardingData.apiBase, onboardingData.apiKey, onboardingData.strongModel),\n ]);\n const html = renderTestResult(\"主力模型\", r1) + renderTestResult(\"强力模型\", r2);\n result.innerHTML = html;\n testOk = r1.success && r2.success;\n onboardingNext.disabled = !testOk;\n } catch (error) {\n result.innerHTML = `<div class=\"test-result fail\">测试失败: ${escapeHtml(error.message)}</div>`;\n }\n });\n onboardingNext.disabled = true;\n}\n\nasync function testLlm(apiBase, apiKey, model) {\n const res = await api(\"/api/config/test-llm\", {\n method: \"POST\",\n body: JSON.stringify({ apiBase, apiKey, model }),\n });\n return res;\n}\n\nfunction renderTestResult(label, result) {\n const cls = result.success ? \"ok\" : \"fail\";\n const text = result.success\n ? `✓ ${label} 连接成功 · ${result.latency_ms}ms`\n : `✗ ${label} 失败: ${escapeHtml(result.error || \"未知错误\")}`;\n return `<div class=\"test-result ${cls}\">${text}</div>`;\n}\n\nasync function renderCompleteStep() {\n // 保存配置\n try {\n await api(\"/api/config\", {\n method: \"POST\",\n body: JSON.stringify({\n apiBase: onboardingData.apiBase,\n apiKey: onboardingData.apiKey,\n defaultModel: onboardingData.defaultModel,\n strongModel: onboardingData.strongModel,\n }),\n });\n } catch (error) {\n showToast(`保存配置失败: ${error.message}`);\n }\n\n const providerName = onboardingData.provider === \"custom\" ? \"自定义\" : onboardingData.provider;\n onboardingBody.innerHTML = `\n <div style=\"text-align:center;padding:20px 0;\">\n <div style=\"font-size:48px;margin-bottom:12px;\">🎉</div>\n <h3 style=\"color:var(--green);\">配置完成!</h3>\n <p>配置已自动保存到 .env 文件</p>\n <div style=\"margin:16px auto;padding:14px;border:1px solid var(--line);border-radius:8px;background:#fbfcfe;text-align:left;font-size:0.86rem;max-width:400px;\">\n <p><strong>端点:</strong> ${escapeHtml(onboardingData.apiBase)}</p>\n <p><strong>主力模型:</strong> ${escapeHtml(onboardingData.defaultModel)}</p>\n <p><strong>强力模型:</strong> ${escapeHtml(onboardingData.strongModel)}</p>\n </div>\n </div>\n `;\n}\n\nonboardingPrev?.addEventListener(\"click\", () => {\n if (onboardingStep > 1) {\n onboardingStep--;\n renderOnboardingStep();\n }\n});\n\nonboardingNext?.addEventListener(\"click\", () => {\n if (onboardingStep < 5) {\n onboardingStep++;\n renderOnboardingStep();\n } else {\n hideOnboarding();\n showToast(\"配置完成,欢迎使用 Beacon!\");\n }\n});\n```\n\n- [ ] **Step 3: 在 `frontend/app.js` 末尾添加设置页面逻辑**\n\n```javascript\n// ============ 设置页面 ============\n\nlet settingsTab = \"api\";\nlet settingsConfig = null;\n\nasync function loadSettings() {\n try {\n settingsConfig = await api(\"/api/config\");\n renderSettings();\n } catch (error) {\n settingsContent.innerHTML = `<p>加载配置失败: ${escapeHtml(error.message)}</p>`;\n }\n}\n\nfunction renderSettings() {\n if (!settingsConfig) return;\n if (settingsTab === \"api\") renderSettingsApi();\n else if (settingsTab === \"models\") renderSettingsModels();\n else if (settingsTab === \"rag\") renderSettingsRag();\n else if (settingsTab === \"advanced\") renderSettingsAdvanced();\n}\n\nfunction renderSettingsApi() {\n settingsContent.innerHTML = `\n <div class=\"field\">\n <span>API 端点</span>\n <input id=\"setApiBase\" value=\"${escapeHtml(settingsConfig.apiBase)}\" />\n <span class=\"field-hint\">OpenAI 兼容端点地址</span>\n </div>\n <div class=\"field\">\n <span>API 密钥</span>\n <div style=\"display:flex;gap:8px;\">\n <input id=\"setApiKey\" type=\"password\" value=\"${escapeHtml(settingsConfig.apiKey)}\" style=\"flex:1;\" />\n <button class=\"ghost-button\" type=\"button\" id=\"setToggleKey\">👁 显示</button>\n <button class=\"primary-button\" type=\"button\" id=\"setTestBtn\">🔍 测试</button>\n </div>\n <div id=\"setTestResult\"></div>\n </div>\n `;\n document.querySelector(\"#setToggleKey\")?.addEventListener(\"click\", (e) => {\n const input = document.querySelector(\"#setApiKey\");\n if (input.type === \"password\") { input.type = \"text\"; e.target.textContent = \"👁 隐藏\"; }\n else { input.type = \"password\"; e.target.textContent = \"👁 显示\"; }\n });\n document.querySelector(\"#setTestBtn\")?.addEventListener(\"click\", async () => {\n const apiBase = document.querySelector(\"#setApiBase\").value;\n const apiKey = document.querySelector(\"#setApiKey\").value;\n const model = settingsConfig.defaultModel || \"test\";\n const result = document.querySelector(\"#setTestResult\");\n result.innerHTML = '<p class=\"field-hint\">测试中...</p>';\n try {\n const r = await testLlm(apiBase, apiKey.includes(\"***\") ? \"\" : apiKey, model);\n result.innerHTML = r.success\n ? `<div class=\"test-result ok\">✓ 连接成功 · ${r.latency_ms}ms</div>`\n : `<div class=\"test-result fail\">✗ ${escapeHtml(r.error)}</div>`;\n } catch (error) {\n result.innerHTML = `<div class=\"test-result fail\">✗ ${escapeHtml(error.message)}</div>`;\n }\n });\n}\n\nfunction renderSettingsModels() {\n settingsContent.innerHTML = `\n <div class=\"field\">\n <span>主力模型(编码、灵敏度分析)</span>\n <input id=\"setDefaultModel\" value=\"${escapeHtml(settingsConfig.defaultModel)}\" />\n <span class=\"field-hint\">用于 routine 节点,格式:provider/model</span>\n </div>\n <div class=\"field\">\n <span>强力模型(分析、建模、写作、评审)</span>\n <input id=\"setStrongModel\" value=\"${escapeHtml(settingsConfig.strongModel)}\" />\n <span class=\"field-hint\">用于核心节点</span>\n </div>\n `;\n}\n\nfunction renderSettingsRag() {\n settingsContent.innerHTML = `\n <div class=\"toggle-row\">\n <div>\n <strong>启用 RAG 知识库</strong>\n <p class=\"field-hint\">启用后注入经典模型模式到提示词</p>\n </div>\n <div class=\"toggle-switch ${settingsConfig.ragEnabled ? \"on\" : \"\"}\" id=\"setRagToggle\"></div>\n </div>\n <div class=\"field\">\n <span>Embedding 模型</span>\n <input id=\"setRagEmbed\" value=\"${escapeHtml(settingsConfig.ragEmbeddingModel)}\" />\n </div>\n <div class=\"field\">\n <span>检索 Top-K</span>\n <input id=\"setRagTopK\" type=\"number\" value=\"${settingsConfig.ragTopK}\" />\n </div>\n `;\n document.querySelector(\"#setRagToggle\")?.addEventListener(\"click\", (e) => {\n e.target.classList.toggle(\"on\");\n });\n}\n\nfunction renderSettingsAdvanced() {\n settingsContent.innerHTML = `\n <div class=\"field\">\n <span>模型迭代轮次上限</span>\n <div style=\"display:flex;align-items:center;gap:12px;\">\n <input id=\"setIterations\" type=\"range\" min=\"1\" max=\"5\" value=\"${settingsConfig.maxModelIterations}\" style=\"flex:1;\" />\n <span id=\"setIterationsVal\" style=\"font-weight:700;color:var(--blue);\">${settingsConfig.maxModelIterations}</span>\n </div>\n </div>\n <div class=\"field\">\n <span>LLM 超时(秒)</span>\n <input id=\"setLlmTimeout\" type=\"number\" value=\"${settingsConfig.llmTimeout}\" />\n </div>\n <div class=\"field\">\n <span>前端端口</span>\n <input id=\"setPort\" type=\"number\" value=\"${settingsConfig.port}\" />\n <span class=\"field-hint\">修改后需重启服务生效</span>\n </div>\n `;\n document.querySelector(\"#setIterations\")?.addEventListener(\"input\", (e) => {\n document.querySelector(\"#setIterationsVal\").textContent = e.target.value;\n });\n}\n\nfunction collectSettings() {\n const config = {};\n const apiBase = document.querySelector(\"#setApiBase\");\n const apiKey = document.querySelector(\"#setApiKey\");\n const defaultModel = document.querySelector(\"#setDefaultModel\");\n const strongModel = document.querySelector(\"#setStrongModel\");\n const ragToggle = document.querySelector(\"#setRagToggle\");\n const ragEmbed = document.querySelector(\"#setRagEmbed\");\n const ragTopK = document.querySelector(\"#setRagTopK\");\n const iterations = document.querySelector(\"#setIterations\");\n const llmTimeout = document.querySelector(\"#setLlmTimeout\");\n const port = document.querySelector(\"#setPort\");\n\n if (apiBase) config.apiBase = apiBase.value;\n if (apiKey && !apiKey.value.includes(\"***\")) config.apiKey = apiKey.value;\n if (defaultModel) config.defaultModel = defaultModel.value;\n if (strongModel) config.strongModel = strongModel.value;\n if (ragToggle) config.ragEnabled = ragToggle.classList.contains(\"on\");\n if (ragEmbed) config.ragEmbeddingModel = ragEmbed.value;\n if (ragTopK) config.ragTopK = Number(ragTopK.value);\n if (iterations) config.maxModelIterations = Number(iterations.value);\n if (llmTimeout) config.llmTimeout = Number(llmTimeout.value);\n if (port) config.port = Number(port.value);\n return config;\n}\n\nsettingsNavButtons.forEach((btn) => {\n btn.addEventListener(\"click\", () => {\n settingsNavButtons.forEach((b) => b.classList.remove(\"active\"));\n btn.classList.add(\"active\");\n settingsTab = btn.dataset.settingsTab;\n renderSettings();\n });\n});\n\nsettingsSave?.addEventListener(\"click\", async () => {\n try {\n const config = collectSettings();\n await api(\"/api/config\", { method: \"POST\", body: JSON.stringify(config) });\n showToast(\"配置已保存\");\n await loadSettings();\n } catch (error) {\n showToast(`保存失败: ${error.message}`);\n }\n});\n\nsettingsReset?.addEventListener(\"click\", async () => {\n if (!confirm(\"确定要重置为 .env.example 的默认值吗?\")) return;\n try {\n // 读取 .env.example 的默认值\n const { readFile } = await import(\"node:fs/promises\");\n // 通过 API 保存默认值\n await api(\"/api/config\", {\n method: \"POST\",\n body: JSON.stringify({\n apiBase: \"http://localhost:20128/v1\",\n apiKey: \"123456\",\n defaultModel: \"deepseek-v4-flash-free\",\n strongModel: \"deepseek-v4-flash-free\",\n llmTimeout: 300,\n maxModelIterations: 3,\n ragEnabled: false,\n ragEmbeddingModel: \"text-embedding-3-small\",\n ragTopK: 4,\n port: 5173,\n }),\n });\n showToast(\"已重置为默认值\");\n await loadSettings();\n } catch (error) {\n showToast(`重置失败: ${error.message}`);\n }\n});\n```\n\n- [ ] **Step 4: 修改启动逻辑,检查 onboarding 状态**\n\n在 `frontend/app.js` 末尾找到现有的 health 检查(约第 656 行):\n\n```javascript\napi(\"/api/health\")\n .then((health) => showToast(`已连接项目:${health.fixtures} 个示例题`))\n .catch((error) => showToast(`项目 API 未连接:${error.message}`));\n```\n\n替换为:\n\n```javascript\napi(\"/api/health\")\n .then((health) => {\n if (health.onboarding && health.onboarding.needed) {\n showOnboarding();\n } else {\n showToast(`已连接项目:${health.fixtures} 个示例题`);\n }\n })\n .catch((error) => showToast(`项目 API 未连接:${error.message}`));\n```\n\n- [ ] **Step 5: 修改导航逻辑,支持设置页面切换**\n\n在 `frontend/app.js` 中找到 `navLinks.forEach` 行(约第 509 行),在其之前添加设置页面切换逻辑:\n\n```javascript\n// 设置页面导航\ndocument.querySelectorAll(\".nav-list a\").forEach((link) => {\n link.addEventListener(\"click\", (e) => {\n if (link.getAttribute(\"href\") === \"#settings\") {\n e.preventDefault();\n document.querySelectorAll(\".nav-list a\").forEach((l) => l.classList.remove(\"active\"));\n link.classList.add(\"active\");\n // 隐藏工作台,显示设置\n document.querySelector(\"#workspace\").style.display = \"none\";\n settingsPanel.hidden = false;\n loadSettings();\n } else {\n // 显示工作台,隐藏设置\n document.querySelector(\"#workspace\").style.display = \"\";\n settingsPanel.hidden = true;\n }\n });\n});\n```\n\n- [ ] **Step 6: Commit**\n\n```bash\ngit add frontend/app.js\ngit commit -m \"feat: add onboarding wizard + settings page JS logic\"\n```\n\n---\n\n### Task 11: 增强 start.bat\n\n**Files:**\n- Modify: `scripts/start.bat`\n\n- [ ] **Step 1: 替换 `scripts/start.bat` 全部内容**\n\n```batch\n@echo off\nchcp 65001 >nul\nsetlocal EnableDelayedExpansion\n\necho ============================================\necho Beacon Math Agent - 一键启动 (Windows)\necho ============================================\necho.\n\ncd /d \"%~dp0..\"\n\nREM --- 检查 Node.js(唯一硬依赖,没有它无法启动服务器)---\nwhere node >nul 2>nul\nif %errorlevel% neq 0 (\n echo [错误] 未找到 Node.js,请先安装:https://nodejs.org\n echo 安装后重新运行此脚本。\n pause\n exit /b 1\n)\nfor /f \"delims=\" %%v in ('node --version') do set \"node_version=%%v\"\necho [OK] Node.js %node_version% 已就绪\n\nREM --- 检查 .env ---\nif not exist \".env\" (\n if exist \".env.example\" (\n echo [初始化] 从 .env.example 创建 .env\n copy \".env.example\" \".env\" >nul\n echo 启动后可通过引导向导配置 API 密钥\n )\n)\n\nREM --- 安装 Node.js 依赖 ---\nif not exist \"node_modules\" (\n echo.\n echo [安装] Node.js 依赖...\n call npm install\n if !errorlevel! neq 0 (\n echo [错误] npm install 失败\n pause\n exit /b 1\n )\n)\n\nREM --- 检查 uv(不阻断启动,引导向导会处理安装)---\nwhere uv >nul 2>nul\nif %errorlevel% neq 0 (\n echo [提示] 未找到 uv,Python 后端暂不可用\n echo 启动后可在引导向导中一键安装 uv\n) else (\n echo [OK] uv 已就绪\n REM --- 安装 Python 依赖 ---\n echo [检查] Python 依赖...\n uv sync 2>nul\n if !errorlevel! neq 0 (\n echo [警告] uv sync 失败,Python 后端可能无法启动\n )\n)\n\nREM --- 启动 ---\necho.\necho ============================================\necho 正在启动服务...\necho 浏览器访问 http://localhost:5173\necho 首次使用会自动进入配置引导\necho 按 Ctrl+C 停止\necho ============================================\necho.\n\nREM 等待 2 秒后自动打开浏览器\nstart \"\" /b cmd /c \"timeout /t 2 >nul && start http://localhost:5173\"\n\ncall npm start\npause\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add scripts/start.bat\ngit commit -m \"feat: enhance start.bat - non-blocking uv check + onboarding hint\"\n```\n\n---\n\n### Task 12: 端到端验证\n\n- [ ] **Step 1: 运行全部测试**\n\nRun: `node --test frontend/server.test.mjs`\nExpected: 所有测试 PASS\n\n- [ ] **Step 2: 启动服务器手动验证**\n\nRun: `npm start`\n\n验证项:\n1. 打开 `http://localhost:5173`,如果 .env 没有 API 密钥,应自动弹出引导向导\n2. 引导向导步骤1应显示 Python/Node/uv 检测结果\n3. 点击侧边栏\"设置\"应进入设置页面\n4. 设置页面四个分类切换正常\n5. 保存配置后 `.env` 文件应被更新\n\n- [ ] **Step 3: 最终 Commit**\n\n```bash\ngit add -A\ngit commit -m \"feat: complete onboarding wizard + settings page + start.bat enhancement\"\n```\n\n---\n\n## 自查\n\n**1. Spec 覆盖:**\n- ✓ 引导向导5步:Task 8 (HTML) + Task 9 (CSS) + Task 10 (JS)\n- ✓ 设置页面4个分类:Task 8 + Task 10\n- ✓ 启动脚本增强:Task 11\n- ✓ 环境检测 + winget 安装:Task 3\n- ✓ .env 读写:Task 4\n- ✓ LLM 连接测试:Task 4\n- ✓ 引导状态判断:Task 5\n- ✓ providers.json 预设:Task 2\n- ✓ server.mjs 路由分发:Task 6\n- ✓ health 端点增强:Task 6\n- ✓ 错误处理(winget不可用/超时/失败):Task 3\n- ✓ 测试:Task 7\n\n**2. 占位符扫描:** 无 TBD/TODO,所有步骤都有完整代码。✓\n\n**3. 类型一致性:**\n- `handleEnvRoutes(request, response, url)` 签名在 Task 3 和 Task 6 一致 ✓\n- `handleConfigRoutes(request, response, url)` 签名在 Task 4 和 Task 6 一致 ✓\n- `handleOnboardingRoutes(request, response, url)` 签名在 Task 5 和 Task 6 一致 ✓\n- `getOnboardingStatus()` 在 Task 5 定义,Task 6 导入使用 ✓\n- 前端 API 路径 `/api/env/check`、`/api/config`、`/api/config/test-llm`、`/api/onboarding/status`、`/api/providers` 在前后端一致 ✓\n- `testLlm(apiBase, apiKey, model)` 在 Task 10 定义并在多处使用 ✓\n"}, "14cbc5c32e7b0d7e75343cf7d45dc90c934eb9f1": {"t": 1785761235.9769497, "n": 55630, "h": "a95254975419b94c", "c": "# Beacon 打包与使用门槛降低 - 实现计划\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** 在现有 Beacon 架构上新增引导向导(5步)、设置页面、启动脚本增强,让用户无需手动编辑 .env 或安装命令行工具即可使用。\n\n**Architecture:** Node 服务器全权负责环境检测/winget 安装/.env 读写/LLM 连接测试。将 server.mjs 的共享工具提取到 `frontend/lib/shared.mjs`,新增路由模块挂载到 `/api/env/*`、`/api/config/*`、`/api/onboarding/*`。前端新增引导向导覆盖层和设置页面。Python 后端完全不改。\n\n**Tech Stack:** Node.js (原生 http)、vanilla HTML/CSS/JS、winget (Windows 包管理)、dotenv\n\n---\n\n## 文件结构\n\n| 文件 | 操作 | 职责 |\n|------|------|------|\n| `frontend/lib/shared.mjs` | 新建 | 共享工具:sendJson、readJsonBody、safeProjectPath、HttpError、projectRoot、env 解析 |\n| `frontend/providers.json` | 新建 | 6 个 API 提供商预设数据 |\n| `frontend/routes/env.mjs` | 新建 | 环境检测 + winget 安装 |\n| `frontend/routes/config.mjs` | 新建 | .env 读写 + LLM 连接测试 |\n| `frontend/routes/onboarding.mjs` | 新建 | 引导状态判断 |\n| `frontend/server.mjs` | 修改 | 路由分发 + health 增强 |\n| `frontend/server.test.mjs` | 修改 | 新增 API 测试 |\n| `frontend/index.html` | 修改 | 引导向导覆盖层 + 设置页入口 |\n| `frontend/styles.css` | 修改 | 引导/设置样式 |\n| `frontend/app.js` | 修改 | 引导/设置交互逻辑 |\n| `scripts/start.bat` | 修改 | 不阻断启动 + 友好提示 |\n\n---\n\n### Task 1: 创建共享工具模块\n\n**Files:**\n- Create: `frontend/lib/shared.mjs`\n\n- [ ] **Step 1: 创建 `frontend/lib/shared.mjs`**\n\n从 `server.mjs` 提取共享工具,供路由模块复用。\n\n```javascript\n// frontend/lib/shared.mjs\nimport { resolve } from \"node:path\";\nimport { isAbsolute, relative, sep } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nexport const root = resolve(fileURLToPath(new URL(\".\", import.meta.url)));\nexport const projectRoot = resolve(root, \"..\", \"..\");\nexport const env = globalThis.process?.env || {};\n\nexport class HttpError extends Error {\n constructor(status, message) {\n super(message);\n this.status = status;\n }\n}\n\nexport function sendJson(response, status, payload) {\n response.writeHead(status, { \"Content-Type\": \"application/json; charset=utf-8\" });\n response.end(JSON.stringify(payload));\n}\n\nexport function safeProjectPath(inputPath) {\n const target = resolve(projectRoot, inputPath || \".\");\n const rel = relative(projectRoot, target);\n if (isAbsolute(rel) || rel === \"..\" || rel.startsWith(`..${sep}`)) {\n throw new HttpError(403, \"Path is outside the project workspace.\");\n }\n return target;\n}\n\nexport async function readJsonBody(request) {\n const chunks = [];\n let size = 0;\n for await (const chunk of request) {\n size += chunk.length;\n if (size > 1024 * 1024) throw new HttpError(413, \"Request body exceeds 1 MiB.\");\n chunks.push(chunk);\n }\n const text = Buffer.concat(chunks).toString(\"utf8\");\n if (!text) return {};\n try {\n return JSON.parse(text);\n } catch (error) {\n throw new HttpError(400, `Invalid JSON body: ${error.message}`);\n }\n}\n```\n\n- [ ] **Step 2: 验证模块可被导入**\n\nRun: `node -e \"import('./frontend/lib/shared.mjs').then(m => console.log('OK', m.projectRoot))\"`\nExpected: 输出 `OK` + 项目根路径\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add frontend/lib/shared.mjs\ngit commit -m \"feat: extract shared utilities to lib/shared.mjs\"\n```\n\n---\n\n### Task 2: 创建 API 提供商预设数据\n\n**Files:**\n- Create: `frontend/providers.json`\n\n- [ ] **Step 1: 创建 `frontend/providers.json`**\n\n```json\n[\n {\n \"id\": \"deepseek\",\n \"name\": \"DeepSeek\",\n \"apiBase\": \"https://api.deepseek.com/v1\",\n \"defaultModel\": \"deepseek-chat\",\n \"strongModel\": \"deepseek-reasoner\",\n \"website\": \"https://platform.deepseek.com\",\n \"description\": \"性价比高,适合数学建模\"\n },\n {\n \"id\": \"openai\",\n \"name\": \"OpenAI\",\n \"apiBase\": \"https://api.openai.com/v1\",\n \"defaultModel\": \"openai/gpt-4o-mini\",\n \"strongModel\": \"openai/gpt-4o\",\n \"website\": \"https://platform.openai.com\",\n \"description\": \"GPT 系列,能力全面\"\n },\n {\n \"id\": \"zhipu\",\n \"name\": \"智谱 AI\",\n \"apiBase\": \"https://open.bigmodel.cn/api/paas/v4\",\n \"defaultModel\": \"glm-4-flash\",\n \"strongModel\": \"glm-4\",\n \"website\": \"https://open.bigmodel.cn\",\n \"description\": \"GLM 系列,国产大模型\"\n },\n {\n \"id\": \"moonshot\",\n \"name\": \"月之暗面\",\n \"apiBase\": \"https://api.moonshot.cn/v1\",\n \"defaultModel\": \"moonshot-v1-8k\",\n \"strongModel\": \"moonshot-v1-32k\",\n \"website\": \"https://platform.moonshot.cn\",\n \"description\": \"Kimi,长上下文优势\"\n },\n {\n \"id\": \"ollama\",\n \"name\": \"本地 Ollama\",\n \"apiBase\": \"http://localhost:11434/v1\",\n \"defaultModel\": \"llama3\",\n \"strongModel\": \"llama3\",\n \"website\": \"https://ollama.com\",\n \"description\": \"本地部署,离线可用\"\n },\n {\n \"id\": \"custom\",\n \"name\": \"自定义\",\n \"apiBase\": \"\",\n \"defaultModel\": \"\",\n \"strongModel\": \"\",\n \"website\": \"\",\n \"description\": \"手动填写端点和模型\"\n }\n]\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add frontend/providers.json\ngit commit -m \"feat: add API provider presets (providers.json)\"\n```\n\n---\n\n### Task 3: 创建环境检测路由模块\n\n**Files:**\n- Create: `frontend/routes/env.mjs`\n\n- [ ] **Step 1: 创建 `frontend/routes/env.mjs`**\n\n```javascript\n// frontend/routes/env.mjs\nimport { execSync } from \"node:child_process\";\nimport { sendJson, readJsonBody, HttpError } from \"../lib/shared.mjs\";\n\n/**\n * 检测单个工具的版本信息。\n * @param {string} command - 命令名 (如 \"python\", \"node\", \"uv\")\n * @returns {{ installed: boolean, version: string }}\n */\nfunction checkTool(command) {\n try {\n const output = execSync(`${command} --version`, {\n encoding: \"utf8\",\n timeout: 10_000,\n windowsHide: true,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n }).trim();\n // 提取版本号:取输出中第一个 x.y.z 格式的字符串\n const match = output.match(/(\\d+\\.\\d+(?:\\.\\d+)?)/);\n return { installed: true, version: match ? match[1] : output };\n } catch {\n return { installed: false, version: \"\" };\n }\n}\n\n/**\n * 比较版本号是否达标。\n * @param {string} version - 实际版本 \"3.12.3\"\n * @param {string} minVersion - 最低要求 \"3.11\"\n * @returns {boolean}\n */\nfunction versionOk(version, minVersion) {\n if (!version) return false;\n const actual = version.split(\".\").map(Number);\n const required = minVersion.split(\".\").map(Number);\n for (let i = 0; i < required.length; i++) {\n const a = actual[i] || 0;\n const r = required[i] || 0;\n if (a > r) return true;\n if (a < r) return false;\n }\n return true;\n}\n\nconst WINGET_PACKAGES = {\n python: \"Python.Python.3.12\",\n node: \"OpenJS.NodeJS.LTS\",\n uv: \"astral-sh.uv\",\n};\n\n/**\n * 处理 /api/env/* 路由。\n */\nexport async function handleEnvRoutes(request, response, url) {\n // GET /api/env/check - 检测环境\n if (request.method === \"GET\" && url.pathname === \"/api/env/check\") {\n const python = checkTool(\"python\");\n const python3 = !python.installed ? checkTool(\"python3\") : null;\n const node = checkTool(\"node\");\n const uv = checkTool(\"uv\");\n\n const pythonResult = python.installed\n ? python\n : python3 && python3.installed\n ? python3\n : { installed: false, version: \"\" };\n\n sendJson(response, 200, {\n python: {\n installed: pythonResult.installed,\n version: pythonResult.version,\n ok: pythonResult.installed && versionOk(pythonResult.version, \"3.11\"),\n },\n node: {\n installed: node.installed,\n version: node.version,\n ok: node.installed && versionOk(node.version, \"18\"),\n },\n uv: {\n installed: uv.installed,\n version: uv.version,\n ok: uv.installed,\n },\n allOk:\n pythonResult.installed &&\n versionOk(pythonResult.version, \"3.11\") &&\n node.installed &&\n versionOk(node.version, \"18\") &&\n uv.installed,\n });\n return;\n }\n\n // POST /api/env/install - winget 安装\n if (request.method === \"POST\" && url.pathname === \"/api/env/install\") {\n const body = await readJsonBody(request);\n const tool = body.tool;\n if (!tool || !WINGET_PACKAGES[tool]) {\n throw new HttpError(400, `Invalid tool. Must be one of: ${Object.keys(WINGET_PACKAGES).join(\", \")}`);\n }\n\n // 检查 winget 是否可用\n try {\n execSync(\"winget --version\", { encoding: \"utf8\", timeout: 10_000, windowsHide: true, stdio: [\"pipe\", \"pipe\", \"pipe\"] });\n } catch {\n sendJson(response, 200, {\n tool,\n status: \"failed\",\n message: \"winget 不可用,请手动安装\",\n downloadUrl: getDownloadUrl(tool),\n });\n return;\n }\n\n // 阻塞式安装\n const pkg = WINGET_PACKAGES[tool];\n try {\n const output = execSync(\n `winget install ${pkg} --silent --accept-package-agreements --accept-source-agreements`,\n {\n encoding: \"utf8\",\n timeout: 300_000, // 5 分钟\n windowsHide: true,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n },\n );\n sendJson(response, 200, {\n tool,\n status: \"done\",\n message: \"安装完成\",\n output: output.slice(-500),\n });\n } catch (error) {\n sendJson(response, 200, {\n tool,\n status: \"failed\",\n message: error.killed ? \"安装超时(超过5分钟)\" : \"安装失败\",\n output: String(error.stderr || error.stdout || error.message).slice(-500),\n downloadUrl: getDownloadUrl(tool),\n });\n }\n return;\n }\n\n // GET /api/providers - 返回提供商预设\n if (request.method === \"GET\" && url.pathname === \"/api/providers\") {\n const { readFile } = await import(\"node:fs/promises\");\n const { resolve } = await import(\"node:path\");\n const { projectRoot } = await import(\"../lib/shared.mjs\");\n try {\n const raw = await readFile(resolve(projectRoot, \"frontend/providers.json\"), \"utf8\");\n sendJson(response, 200, JSON.parse(raw));\n } catch {\n sendJson(response, 200, []);\n }\n return;\n }\n\n sendJson(response, 404, { error: \"Unknown env route.\" });\n}\n\nfunction getDownloadUrl(tool) {\n const urls = {\n python: \"https://www.python.org/downloads/\",\n node: \"https://nodejs.org/\",\n uv: \"https://docs.astral.sh/uv/getting-started/installation/\",\n };\n return urls[tool] || \"\";\n}\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add frontend/routes/env.mjs\ngit commit -m \"feat: add env check + winget install route module\"\n```\n\n---\n\n### Task 4: 创建配置读写路由模块\n\n**Files:**\n- Create: `frontend/routes/config.mjs`\n\n- [ ] **Step 1: 创建 `frontend/routes/config.mjs`**\n\n```javascript\n// frontend/routes/config.mjs\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { sendJson, readJsonBody, HttpError, projectRoot } from \"../lib/shared.mjs\";\n\nconst ENV_PATH = resolve(projectRoot, \".env\");\nconst ENV_EXAMPLE_PATH = resolve(projectRoot, \".env.example\");\n\n/** 前端字段 -> .env 变量名映射 */\nconst FIELD_MAP = {\n apiBase: \"OPENAI_API_BASE\",\n apiKey: \"OPENAI_API_KEY\",\n defaultModel: \"MATH_AGENT_DEFAULT_MODEL\",\n strongModel: \"MATH_AGENT_STRONG_MODEL\",\n llmTimeout: \"MATH_AGENT_LLM_TIMEOUT\",\n maxModelIterations: \"MATH_AGENT_MAX_MODEL_ITERATIONS\",\n ragEnabled: \"MATH_AGENT_RAG_ENABLED\",\n ragEmbeddingModel: \"MATH_AGENT_RAG_EMBED\",\n ragTopK: \"MATH_AGENT_RAG_TOPK\",\n port: \"PORT\",\n};\n\n/** .env 变量名 -> 前端字段 反向映射 */\nconst ENV_TO_FIELD = Object.fromEntries(\n Object.entries(FIELD_MAP).map(([field, envKey]) => [envKey, field]),\n);\n\n/**\n * 解析 .env 文件为 key-value 对象。\n */\nfunction parseEnvFile(filePath) {\n if (!existsSync(filePath)) return {};\n const content = readFileSync(filePath, \"utf8\");\n const result = {};\n for (const line of content.split(\"\\n\")) {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(\"#\")) continue;\n const eqIndex = trimmed.indexOf(\"=\");\n if (eqIndex === -1) continue;\n const key = trimmed.slice(0, eqIndex).trim();\n const value = trimmed.slice(eqIndex + 1).trim();\n result[key] = value;\n }\n return result;\n}\n\n/**\n * 掩码 API 密钥:显示前3位 + *** + 后4位。\n */\nfunction maskApiKey(key) {\n if (!key || key.length < 8) return key ? \"***\" : \"\";\n return `${key.slice(0, 3)}***${key.slice(-4)}`;\n}\n\n/**\n * 更新 .env 文件,保留注释和格式,仅替换对应行的值。\n */\nfunction updateEnvFile(updates) {\n const templatePath = existsSync(ENV_PATH) ? ENV_PATH : ENV_EXAMPLE_PATH;\n const content = readFileSync(templatePath, \"utf8\");\n const lines = content.split(\"\\n\");\n const remaining = new Set(Object.keys(updates));\n\n const result = lines.map((line) => {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(\"#\")) return line;\n const eqIndex = trimmed.indexOf(\"=\");\n if (eqIndex === -1) return line;\n const key = trimmed.slice(0, eqIndex).trim();\n if (key in updates) {\n remaining.delete(key);\n return `${key}=${updates[key]}`;\n }\n return line;\n });\n\n // 追加 .env 中不存在的新 key\n for (const key of remaining) {\n result.push(`${key}=${updates[key]}`);\n }\n\n writeFileSync(ENV_PATH, result.join(\"\\n\"), \"utf8\");\n}\n\n/**\n * 处理 /api/config* 路由。\n */\nexport async function handleConfigRoutes(request, response, url) {\n // GET /api/config - 读取当前配置\n if (request.method === \"GET\" && url.pathname === \"/api/config\") {\n const envVars = parseEnvFile(ENV_PATH);\n const config = {\n apiBase: envVars.OPENAI_API_BASE || \"\",\n apiKey: maskApiKey(envVars.OPENAI_API_KEY || \"\"),\n hasApiKey: !!(envVars.OPENAI_API_KEY && envVars.OPENAI_API_KEY !== \"123456\"),\n defaultModel: envVars.MATH_AGENT_DEFAULT_MODEL || \"\",\n strongModel: envVars.MATH_AGENT_STRONG_MODEL || \"\",\n llmTimeout: Number(envVars.MATH_AGENT_LLM_TIMEOUT || 300),\n maxModelIterations: Number(envVars.MATH_AGENT_MAX_MODEL_ITERATIONS || 3),\n ragEnabled: envVars.MATH_AGENT_RAG_ENABLED === \"1\",\n ragEmbeddingModel: envVars.MATH_AGENT_RAG_EMBED || \"text-embedding-3-small\",\n ragTopK: Number(envVars.MATH_AGENT_RAG_TOPK || 4),\n port: Number(envVars.PORT || 5173),\n };\n sendJson(response, 200, config);\n return;\n }\n\n // POST /api/config - 保存配置到 .env\n if (request.method === \"POST\" && url.pathname === \"/api/config\") {\n const body = await readJsonBody(request);\n\n const updates = {};\n // 只更新提供的字段\n if (body.apiBase !== undefined) updates[FIELD_MAP.apiBase] = body.apiBase;\n // apiKey: 如果值包含 *** 表示是掩码值,跳过不更新\n if (body.apiKey !== undefined && !body.apiKey.includes(\"***\")) {\n updates[FIELD_MAP.apiKey] = body.apiKey;\n }\n if (body.defaultModel !== undefined) updates[FIELD_MAP.defaultModel] = body.defaultModel;\n if (body.strongModel !== undefined) updates[FIELD_MAP.strongModel] = body.strongModel;\n if (body.llmTimeout !== undefined) updates[FIELD_MAP.llmTimeout] = String(body.llmTimeout);\n if (body.maxModelIterations !== undefined) updates[FIELD_MAP.maxModelIterations] = String(body.maxModelIterations);\n if (body.ragEnabled !== undefined) updates[FIELD_MAP.ragEnabled] = body.ragEnabled ? \"1\" : \"0\";\n if (body.ragEmbeddingModel !== undefined) updates[FIELD_MAP.ragEmbeddingModel] = body.ragEmbeddingModel;\n if (body.ragTopK !== undefined) updates[FIELD_MAP.ragTopK] = String(body.ragTopK);\n if (body.port !== undefined) updates[FIELD_MAP.port] = String(body.port);\n\n if (Object.keys(updates).length === 0) {\n throw new HttpError(400, \"No config fields to update.\");\n }\n\n updateEnvFile(updates);\n sendJson(response, 200, { ok: true });\n return;\n }\n\n // POST /api/config/test-llm - 测试 LLM 连接\n if (request.method === \"POST\" && url.pathname === \"/api/config/test-llm\") {\n const body = await readJsonBody(request);\n const { apiBase, apiKey, model } = body;\n\n if (!apiBase || !apiKey || !model) {\n throw new HttpError(400, \"apiBase, apiKey, and model are required.\");\n }\n\n const startTime = Date.now();\n try {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n\n const res = await fetch(`${apiBase.replace(/\\/+$/, \"\")}/chat/completions`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n messages: [{ role: \"user\", content: \"hi\" }],\n max_tokens: 1,\n }),\n signal: controller.signal,\n });\n\n clearTimeout(timeout);\n const latency = Date.now() - startTime;\n\n if (res.ok) {\n sendJson(response, 200, { success: true, latency_ms: latency });\n } else {\n const errorText = await res.text().catch(() => \"\");\n let errorMsg = `HTTP ${res.status}`;\n if (res.status === 401) errorMsg = \"API 密钥无效\";\n else if (res.status === 404) errorMsg = \"模型不存在或端点错误\";\n else if (errorText) errorMsg = `${errorMsg}: ${errorText.slice(0, 200)}`;\n sendJson(response, 200, { success: false, latency_ms: latency, error: errorMsg });\n }\n } catch (error) {\n const latency = Date.now() - startTime;\n const errorMsg = error.name === \"AbortError\"\n ? \"连接超时(30秒)\"\n : `连接失败: ${error.message}`;\n sendJson(response, 200, { success: false, latency_ms: latency, error: errorMsg });\n }\n return;\n }\n\n sendJson(response, 404, { error: \"Unknown config route.\" });\n}\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add frontend/routes/config.mjs\ngit commit -m \"feat: add config read/write + LLM test route module\"\n```\n\n---\n\n### Task 5: 创建引导状态路由模块\n\n**Files:**\n- Create: `frontend/routes/onboarding.mjs`\n\n- [ ] **Step 1: 创建 `frontend/routes/onboarding.mjs`**\n\n```javascript\n// frontend/routes/onboarding.mjs\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { sendJson, projectRoot } from \"../lib/shared.mjs\";\n\nconst ENV_PATH = resolve(projectRoot, \".env\");\n\n/**\n * 判断是否需要引导。\n * @returns {{ needed: boolean, reason: \"no_env\" | \"no_api_key\" | \"done\" }}\n */\nexport function getOnboardingStatus() {\n if (!existsSync(ENV_PATH)) {\n return { needed: true, reason: \"no_env\" };\n }\n\n const content = readFileSync(ENV_PATH, \"utf8\");\n const apiKeyMatch = content.match(/^OPENAI_API_KEY\\s*=\\s*(.+)$/m);\n const apiKey = apiKeyMatch ? apiKeyMatch[1].trim() : \"\";\n\n if (!apiKey || apiKey === \"123456\") {\n return { needed: true, reason: \"no_api_key\" };\n }\n\n return { needed: false, reason: \"done\" };\n}\n\n/**\n * 处理 /api/onboarding/* 路由。\n */\nexport async function handleOnboardingRoutes(request, response, url) {\n // GET /api/onboarding/status\n if (request.method === \"GET\" && url.pathname === \"/api/onboarding/status\") {\n sendJson(response, 200, getOnboardingStatus());\n return;\n }\n\n sendJson(response, 404, { error: \"Unknown onboarding route.\" });\n}\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add frontend/routes/onboarding.mjs\ngit commit -m \"feat: add onboarding status route module\"\n```\n\n---\n\n### Task 6: 重构 server.mjs 接入新路由\n\n**Files:**\n- Modify: `frontend/server.mjs`\n\n- [ ] **Step 1: 在 server.mjs 顶部添加路由导入**\n\n在 `frontend/server.mjs` 第 9 行(`import { fileURLToPath }` 之后)添加:\n\n```javascript\nimport { handleEnvRoutes } from \"./routes/env.mjs\";\nimport { handleConfigRoutes } from \"./routes/config.mjs\";\nimport { handleOnboardingRoutes, getOnboardingStatus } from \"./routes/onboarding.mjs\";\n```\n\n- [ ] **Step 2: 在 handleApi 函数中添加新路由分发**\n\n在 `frontend/server.mjs` 的 `handleApi` 函数开头(第 249 行 `if (request.method === \"POST\" && url.pathname === \"/api/upload\")` 之前)插入:\n\n```javascript\n // --- 新增路由:环境检测、配置、引导 ---\n if (url.pathname.startsWith(\"/api/env/\") || url.pathname === \"/api/providers\") {\n return handleEnvRoutes(request, response, url);\n }\n if (url.pathname.startsWith(\"/api/config\")) {\n return handleConfigRoutes(request, response, url);\n }\n if (url.pathname.startsWith(\"/api/onboarding/\")) {\n return handleOnboardingRoutes(request, response, url);\n }\n```\n\n- [ ] **Step 3: 增强 health 端点,合并 onboarding 状态**\n\n在 `frontend/server.mjs` 中找到 health 端点(约第 313 行),替换为:\n\n```javascript\n if (request.method === \"GET\" && url.pathname === \"/api/health\") {\n const fixtures = await listFixtures().catch(() => []);\n const onboarding = getOnboardingStatus();\n sendJson(response, 200, {\n ok: true,\n projectRoot,\n mathAgentCommand: env.MATH_AGENT_COMMAND || \"uv run math-agent\",\n fixtures: fixtures.length,\n onboarding,\n });\n return;\n }\n```\n\n- [ ] **Step 4: 验证服务器能启动**\n\nRun: `node -e \"import('./frontend/lib/shared.mjs').then(() => console.log('shared OK'))\" && node -e \"import('./frontend/routes/env.mjs').then(() => console.log('env OK'))\" && node -e \"import('./frontend/routes/config.mjs').then(() => console.log('config OK'))\" && node -e \"import('./frontend/routes/onboarding.mjs').then(() => console.log('onboarding OK'))\"`\nExpected: 四行 `OK`\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add frontend/server.mjs\ngit commit -m \"feat: wire env/config/onboarding routes into server.mjs + enhance health\"\n```\n\n---\n\n### Task 7: 编写后端 API 集成测试\n\n**Files:**\n- Modify: `frontend/server.test.mjs`\n\n- [ ] **Step 1: 在 `frontend/server.test.mjs` 末尾追加新测试**\n\n在文件末尾(第 172 行之后)追加:\n\n```javascript\ntest(\"GET /api/env/check 返回环境检测结果\", async () => {\n const response = await fetch(`${base}/api/env/check`);\n assert.equal(response.status, 200);\n const data = await response.json();\n assert.ok(data.python);\n assert.ok(data.node);\n assert.ok(data.uv);\n assert.equal(typeof data.allOk, \"boolean\");\n});\n\ntest(\"GET /api/onboarding/status 返回引导状态\", async () => {\n const response = await fetch(`${base}/api/onboarding/status`);\n assert.equal(response.status, 200);\n const data = await response.json();\n assert.ok([\"no_env\", \"no_api_key\", \"done\"].includes(data.reason));\n assert.equal(typeof data.needed, \"boolean\");\n});\n\ntest(\"GET /api/config 返回配置(密钥掩码)\", async () => {\n const response = await fetch(`${base}/api/config`);\n assert.equal(response.status, 200);\n const data = await response.json();\n assert.ok(data.apiBase !== undefined);\n assert.ok(data.defaultModel !== undefined);\n assert.ok(data.strongModel !== undefined);\n // apiKey 不应包含明文(如果存在的话应被掩码)\n if (data.apiKey) {\n assert.ok(data.apiKey.includes(\"***\") || data.apiKey.length === 0);\n }\n});\n\ntest(\"GET /api/health 包含 onboarding 字段\", async () => {\n const response = await fetch(`${base}/api/health`);\n const data = await response.json();\n assert.ok(data.onboarding);\n assert.ok([\"no_env\", \"no_api_key\", \"done\"].includes(data.onboarding.reason));\n});\n\ntest(\"POST /api/config/test-llm 拒绝缺少参数的请求\", async () => {\n const response = await fetch(`${base}/api/config/test-llm`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ apiBase: \"https://example.com/v1\" }),\n });\n assert.equal(response.status, 400);\n});\n\ntest(\"GET /api/providers 返回提供商列表\", async () => {\n const response = await fetch(`${base}/api/providers`);\n assert.equal(response.status, 200);\n const data = await response.json();\n assert.ok(Array.isArray(data));\n assert.ok(data.length >= 5);\n assert.ok(data.some((p) => p.id === \"deepseek\"));\n});\n```\n\n- [ ] **Step 2: 运行测试**\n\nRun: `node --test frontend/server.test.mjs`\nExpected: 所有测试 PASS(包括原有测试)\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add frontend/server.test.mjs\ngit commit -m \"test: add integration tests for env/config/onboarding APIs\"\n```\n\n---\n\n### Task 8: 添加引导向导和设置页面的 HTML\n\n**Files:**\n- Modify: `frontend/index.html`\n\n- [ ] **Step 1: 在侧边栏导航添加\"设置\"入口**\n\n在 `frontend/index.html` 第 23 行(`<a href=\"#knowledge\">知识库</a>` 之后)添加:\n\n```html\n <a href=\"#settings\">设置</a>\n```\n\n- [ ] **Step 2: 在 `</main>` 之前添加设置页面容器**\n\n在 `frontend/index.html` 找到 `</main>`(第 230 行),在其之前添加:\n\n```html\n <section class=\"panel settings-panel\" id=\"settings\" hidden aria-labelledby=\"settings-title\">\n <div class=\"panel-heading\">\n <div>\n <p class=\"eyebrow\">Configuration</p>\n <h2 id=\"settings-title\">设置</h2>\n </div>\n </div>\n <div class=\"settings-layout\">\n <nav class=\"settings-nav\" aria-label=\"设置分类\">\n <button class=\"active\" type=\"button\" data-settings-tab=\"api\">API 配置</button>\n <button type=\"button\" data-settings-tab=\"models\">模型选择</button>\n <button type=\"button\" data-settings-tab=\"rag\">RAG 知识库</button>\n <button type=\"button\" data-settings-tab=\"advanced\">高级设置</button>\n </nav>\n <div class=\"settings-content\" id=\"settingsContent\"></div>\n </div>\n <div class=\"settings-footer\">\n <button class=\"ghost-button\" type=\"button\" id=\"settingsReset\">重置默认</button>\n <button class=\"primary-button\" type=\"button\" id=\"settingsSave\">💾 保存配置</button>\n </div>\n </section>\n```\n\n- [ ] **Step 3: 在 `<div class=\"toast\"` 之前添加引导向导覆盖层**\n\n在 `frontend/index.html` 找到 `<div class=\"toast\"` (第 233 行),在其之前添加:\n\n```html\n <div class=\"onboarding-overlay\" id=\"onboardingOverlay\" hidden>\n <div class=\"onboarding-card\">\n <div class=\"onboarding-header\">\n <h2 id=\"onboardingTitle\">欢迎使用 Beacon</h2>\n <p id=\"onboardingSubtitle\">让我们花几分钟完成初始配置</p>\n <div class=\"onboarding-steps\">\n <span class=\"step-dot active\" data-step=\"1\"></span>\n <span class=\"step-dot\" data-step=\"2\"></span>\n <span class=\"step-dot\" data-step=\"3\"></span>\n <span class=\"step-dot\" data-step=\"4\"></span>\n <span class=\"step-dot\" data-step=\"5\"></span>\n </div>\n </div>\n <div class=\"onboarding-body\" id=\"onboardingBody\"></div>\n <div class=\"onboarding-footer\">\n <button class=\"ghost-button\" type=\"button\" id=\"onboardingPrev\" disabled>上一步</button>\n <button class=\"primary-button\" type=\"button\" id=\"onboardingNext\">下一步</button>\n </div>\n </div>\n </div>\n```\n\n- [ ] **Step 4: Commit**\n\n```bash\ngit add frontend/index.html\ngit commit -m \"feat: add onboarding overlay + settings panel to index.html\"\n```\n\n---\n\n### Task 9: 添加引导向导和设置页面的 CSS\n\n**Files:**\n- Modify: `frontend/styles.css`\n\n- [ ] **Step 1: 在 `frontend/styles.css` 末尾追加引导向导和设置页面样式**\n\n```css\n/* ---- 引导向导覆盖层 ---- */\n\n.onboarding-overlay {\n position: fixed;\n inset: 0;\n z-index: 100;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(7, 27, 58, 0.6);\n backdrop-filter: blur(6px);\n}\n\n.onboarding-overlay[hidden] {\n display: none;\n}\n\n.onboarding-card {\n width: min(620px, calc(100vw - 48px));\n max-height: calc(100vh - 48px);\n display: flex;\n flex-direction: column;\n padding: 32px;\n border-radius: 16px;\n background: var(--white);\n box-shadow: 0 40px 80px rgba(7, 27, 58, 0.25);\n overflow-y: auto;\n}\n\n.onboarding-header h2 {\n margin: 0;\n font-size: 1.5rem;\n}\n\n.onboarding-header p {\n margin: 6px 0 0;\n color: var(--muted);\n font-size: 0.92rem;\n}\n\n.onboarding-steps {\n display: flex;\n gap: 8px;\n margin-top: 16px;\n}\n\n.step-dot {\n flex: 1;\n height: 4px;\n border-radius: 999px;\n background: var(--line);\n transition: background 200ms ease;\n}\n\n.step-dot.active {\n background: var(--gold);\n}\n\n.step-dot.done {\n background: var(--green);\n}\n\n.onboarding-body {\n flex: 1;\n min-height: 200px;\n margin: 24px 0;\n}\n\n.onboarding-body h3 {\n margin: 0 0 12px;\n font-size: 1.1rem;\n}\n\n.onboarding-body p {\n color: var(--ink-soft);\n line-height: 1.6;\n}\n\n.onboarding-footer {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n}\n\n/* 引导步骤内容 */\n\n.env-check-list {\n display: grid;\n gap: 10px;\n}\n\n.env-check-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 14px 16px;\n border: 1px solid var(--line);\n border-radius: 8px;\n background: #fbfcfe;\n}\n\n.env-check-item.ok {\n border-color: var(--green);\n background: #f0faf6;\n}\n\n.env-check-item.missing {\n border-color: var(--rose);\n background: #fff0f3;\n}\n\n.env-check-item .env-name {\n font-weight: 700;\n color: var(--ink);\n}\n\n.env-check-item .env-version {\n display: block;\n margin-top: 2px;\n font-size: 0.84rem;\n color: var(--muted);\n}\n\n.env-check-item .env-status-ok {\n color: var(--green);\n font-weight: 700;\n}\n\n.env-check-item .env-status-missing {\n color: var(--rose);\n font-weight: 700;\n}\n\n.env-install-btn {\n min-height: 34px;\n padding: 0 14px;\n border: none;\n border-radius: 6px;\n background: var(--gold);\n color: var(--ink);\n font-weight: 700;\n cursor: pointer;\n}\n\n.env-install-btn:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n}\n\n.env-install-btn.installing {\n background: var(--muted);\n}\n\n.provider-grid {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 10px;\n}\n\n.provider-card {\n padding: 14px;\n border: 2px solid var(--line);\n border-radius: 8px;\n background: #fbfcfe;\n cursor: pointer;\n transition: border-color 150ms ease;\n}\n\n.provider-card:hover {\n border-color: var(--blue);\n}\n\n.provider-card.selected {\n border-color: var(--gold);\n background: var(--gold-soft);\n}\n\n.provider-card strong {\n display: block;\n font-size: 1rem;\n}\n\n.provider-card span {\n display: block;\n margin-top: 4px;\n color: var(--muted);\n font-size: 0.82rem;\n}\n\n/* ---- 设置页面 ---- */\n\n.settings-panel[hidden] {\n display: none;\n}\n\n.settings-layout {\n display: flex;\n gap: 20px;\n min-height: 320px;\n}\n\n.settings-nav {\n display: flex;\n flex-direction: column;\n gap: 4px;\n width: 140px;\n flex-shrink: 0;\n}\n\n.settings-nav button {\n padding: 10px 14px;\n text-align: left;\n border: none;\n border-left: 3px solid transparent;\n border-radius: 0;\n background: transparent;\n color: var(--muted);\n font-weight: 600;\n cursor: pointer;\n}\n\n.settings-nav button.active {\n color: var(--blue);\n font-weight: 800;\n border-left-color: var(--blue);\n background: #f0f6ff;\n}\n\n.settings-content {\n flex: 1;\n display: grid;\n gap: 14px;\n align-content: start;\n}\n\n.settings-content .field {\n margin-bottom: 0;\n}\n\n.settings-content .field span {\n font-size: 0.86rem;\n}\n\n.settings-content .field input,\n.settings-content .field select {\n width: 100%;\n height: 42px;\n padding: 0 12px;\n border: 1px solid var(--line);\n border-radius: 8px;\n color: var(--ink);\n background: var(--white);\n font-size: 14px;\n outline: 0;\n}\n\n.settings-content .field input:focus,\n.settings-content .field select:focus {\n border-color: var(--blue);\n box-shadow: 0 0 0 3px rgba(21, 94, 150, 0.13);\n}\n\n.settings-content .field-hint {\n color: var(--muted);\n font-size: 0.8rem;\n}\n\n.settings-content .test-result {\n padding: 8px 12px;\n border-radius: 6px;\n font-size: 0.84rem;\n}\n\n.settings-content .test-result.ok {\n background: #dff6eb;\n color: var(--green);\n}\n\n.settings-content .test-result.fail {\n background: #fff0f3;\n color: var(--rose);\n}\n\n.settings-content .toggle-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 12px;\n border: 1px solid var(--line);\n border-radius: 8px;\n background: #fbfcfe;\n}\n\n.toggle-switch {\n position: relative;\n width: 44px;\n height: 24px;\n border-radius: 12px;\n background: var(--line);\n cursor: pointer;\n transition: background 200ms ease;\n}\n\n.toggle-switch.on {\n background: var(--green);\n}\n\n.toggle-switch::after {\n content: \"\";\n position: absolute;\n top: 2px;\n left: 2px;\n width: 20px;\n height: 20px;\n border-radius: 50%;\n background: var(--white);\n transition: transform 200ms ease;\n}\n\n.toggle-switch.on::after {\n transform: translateX(20px);\n}\n\n.settings-footer {\n display: flex;\n justify-content: flex-end;\n gap: 10px;\n margin-top: 16px;\n padding-top: 14px;\n border-top: 1px solid var(--line);\n}\n\n@media (max-width: 860px) {\n .settings-layout {\n flex-direction: column;\n }\n .settings-nav {\n flex-direction: row;\n width: 100%;\n overflow-x: auto;\n }\n .settings-nav button {\n border-left: none;\n border-bottom: 3px solid transparent;\n white-space: nowrap;\n }\n .settings-nav button.active {\n border-left: none;\n border-bottom-color: var(--blue);\n }\n .provider-grid {\n grid-template-columns: 1fr;\n }\n}\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add frontend/styles.css\ngit commit -m \"feat: add onboarding overlay + settings page styles\"\n```\n\n---\n\n### Task 10: 添加引导向导和设置页面的 JS 逻辑\n\n**Files:**\n- Modify: `frontend/app.js`\n\n- [ ] **Step 1: 在 `frontend/app.js` 开头添加 DOM 引用**\n\n在第 35 行(`const pipelineItems = ...` 之后)添加:\n\n```javascript\nconst onboardingOverlay = document.querySelector(\"#onboardingOverlay\");\nconst onboardingBody = document.querySelector(\"#onboardingBody\");\nconst onboardingTitle = document.querySelector(\"#onboardingTitle\");\nconst onboardingSubtitle = document.querySelector(\"#onboardingSubtitle\");\nconst onboardingPrev = document.querySelector(\"#onboardingPrev\");\nconst onboardingNext = document.querySelector(\"#onboardingNext\");\nconst stepDots = document.querySelectorAll(\".step-dot\");\nconst settingsPanel = document.querySelector(\"#settings\");\nconst settingsContent = document.querySelector(\"#settingsContent\");\nconst settingsSave = document.querySelector(\"#settingsSave\");\nconst settingsReset = document.querySelector(\"#settingsReset\");\nconst settingsNavButtons = document.querySelectorAll(\"[data-settings-tab]\");\n```\n\n- [ ] **Step 2: 在 `frontend/app.js` 末尾添加引导向导逻辑**\n\n在文件末尾追加:\n\n```javascript\n// ============ 引导向导 ============\n\nlet onboardingStep = 1;\nlet onboardingData = {\n provider: null,\n apiBase: \"\",\n apiKey: \"\",\n defaultModel: \"\",\n strongModel: \"\",\n};\n\nconst onboardingSteps = [\n { title: \"欢迎使用 Beacon\", subtitle: \"让我们花几分钟完成初始配置\" },\n { title: \"环境检测\", subtitle: \"检查运行所需依赖是否已安装\" },\n { title: \"选择 API 提供商\", subtitle: \"选择你的 LLM 服务提供商\" },\n { title: \"API 密钥 & 端点配置\", subtitle: \"填写 API 密钥和访问端点\" },\n { title: \"模型选择 & 连接测试\", subtitle: \"选择模型并验证连接\" },\n { title: \"配置完成\", subtitle: \"一切就绪,可以开始使用了\" },\n];\n\nfunction showOnboarding() {\n onboardingOverlay.hidden = false;\n onboardingStep = 1;\n renderOnboardingStep();\n}\n\nfunction hideOnboarding() {\n onboardingOverlay.hidden = true;\n}\n\nfunction updateStepDots() {\n stepDots.forEach((dot, i) => {\n dot.classList.toggle(\"active\", i + 1 === onboardingStep);\n dot.classList.toggle(\"done\", i + 1 < onboardingStep);\n });\n}\n\nfunction renderOnboardingStep() {\n const step = onboardingSteps[onboardingStep - 1];\n onboardingTitle.textContent = step.title;\n onboardingSubtitle.textContent = step.subtitle;\n updateStepDots();\n onboardingPrev.disabled = onboardingStep === 1;\n onboardingNext.textContent = onboardingStep === 5 ? \"进入工作台\" : \"下一步\";\n\n if (onboardingStep === 1) renderEnvCheckStep();\n else if (onboardingStep === 2) renderProviderStep();\n else if (onboardingStep === 3) renderApiKeyStep();\n else if (onboardingStep === 4) renderModelTestStep();\n else if (onboardingStep === 5) renderCompleteStep();\n}\n\nasync function renderEnvCheckStep() {\n onboardingBody.innerHTML = `\n <h3>环境检测</h3>\n <p>正在检测运行所需依赖...</p>\n <div class=\"env-check-list\" id=\"envCheckList\"></div>\n `;\n try {\n const data = await api(\"/api/env/check\");\n const list = document.querySelector(\"#envCheckList\");\n list.innerHTML = renderEnvItem(\"Python\", \"≥ 3.11\", data.python) +\n renderEnvItem(\"Node.js\", \"≥ 18\", data.node) +\n renderEnvItem(\"uv\", \"Python 包管理器\", data.uv);\n onboardingNext.disabled = !data.allOk;\n if (!data.allOk) {\n list.querySelectorAll(\".env-install-btn\").forEach((btn) => {\n btn.addEventListener(\"click\", () => installTool(btn.dataset.tool, btn));\n });\n }\n } catch (error) {\n onboardingBody.innerHTML = `<p>检测失败: ${escapeHtml(error.message)}</p>`;\n }\n}\n\nfunction renderEnvItem(name, requirement, info) {\n const ok = info.ok;\n const status = ok\n ? `<span class=\"env-status-ok\">✓ ${info.version}</span>`\n : `<span class=\"env-status-missing\">✗ ${info.installed ? info.version + \" 版本过低\" : \"未检测到\"}</span>`;\n const action = ok ? \"\" : `<button class=\"env-install-btn\" data-tool=\"${name.toLowerCase().replace(\".\", \"\")}\">一键安装</button>`;\n // 修正 tool 名称映射\n const toolName = name === \"Python\" ? \"python\" : name === \"Node.js\" ? \"node\" : name === \"uv\" ? \"uv\" : \"\";\n const actionBtn = ok ? \"\" : `<button class=\"env-install-btn\" data-tool=\"${toolName}\">一键安装</button>`;\n return `\n <div class=\"env-check-item ${ok ? \"ok\" : \"missing\"}\">\n <div>\n <span class=\"env-name\">${escapeHtml(name)}</span>\n <span class=\"env-version\">${escapeHtml(requirement)}</span>\n </div>\n <div style=\"display:flex;align-items:center;gap:10px;\">\n ${status}\n ${actionBtn}\n </div>\n </div>\n `;\n}\n\nasync function installTool(tool, btn) {\n btn.disabled = true;\n btn.classList.add(\"installing\");\n btn.textContent = \"安装中...\";\n try {\n const result = await api(\"/api/env/install\", {\n method: \"POST\",\n body: JSON.stringify({ tool }),\n });\n if (result.status === \"done\") {\n showToast(`${tool} 安装完成`);\n renderEnvCheckStep();\n } else {\n btn.disabled = false;\n btn.classList.remove(\"installing\");\n btn.textContent = \"重试\";\n showToast(`${tool} 安装失败: ${result.message}`);\n if (result.downloadUrl) {\n onboardingBody.insertAdjacentHTML(\"beforeend\",\n `<p style=\"margin-top:12px;color:var(--rose);\">手动安装: <a href=\"${result.downloadUrl}\" target=\"_blank\">${result.downloadUrl}</a></p>`);\n }\n }\n } catch (error) {\n btn.disabled = false;\n btn.classList.remove(\"installing\");\n btn.textContent = \"重试\";\n showToast(`安装失败: ${error.message}`);\n }\n}\n\nasync function renderProviderStep() {\n onboardingNext.disabled = true;\n onboardingBody.innerHTML = `\n <h3>选择 API 提供商</h3>\n <p>选择你的 LLM 服务提供商,选择后会自动填入端点和推荐模型。</p>\n <div class=\"provider-grid\" id=\"providerGrid\"></div>\n `;\n try {\n const providers = await api(\"/api/providers\");\n const grid = document.querySelector(\"#providerGrid\");\n grid.innerHTML = providers.map((p) => `\n <div class=\"provider-card\" data-provider=\"${escapeHtml(p.id)}\">\n <strong>${escapeHtml(p.name)}</strong>\n <span>${escapeHtml(p.description)}</span>\n </div>\n `).join(\"\");\n grid.querySelectorAll(\".provider-card\").forEach((card) => {\n card.addEventListener(\"click\", () => {\n grid.querySelectorAll(\".provider-card\").forEach((c) => c.classList.remove(\"selected\"));\n card.classList.add(\"selected\");\n const provider = providers.find((p) => p.id === card.dataset.provider);\n onboardingData.provider = provider.id;\n onboardingData.apiBase = provider.apiBase;\n onboardingData.defaultModel = provider.defaultModel;\n onboardingData.strongModel = provider.strongModel;\n onboardingNext.disabled = false;\n });\n });\n } catch (error) {\n onboardingBody.innerHTML = `<p>加载提供商列表失败: ${escapeHtml(error.message)}</p>`;\n }\n}\n\nfunction renderApiKeyStep() {\n onboardingBody.innerHTML = `\n <h3>API 密钥 & 端点配置</h3>\n <p>填写你的 API 密钥。端点已根据所选提供商自动填入,可修改。</p>\n <div class=\"field\">\n <span>API 端点(Base URL)</span>\n <input id=\"obApiBase\" value=\"${escapeHtml(onboardingData.apiBase)}\" placeholder=\"https://api.deepseek.com/v1\" style=\"width:100%;height:42px;padding:0 12px;border:1px solid var(--line);border-radius:8px;font-size:14px;color:var(--ink);background:var(--white);outline:0;\" />\n </div>\n <div class=\"field\">\n <span>API 密钥</span>\n <div style=\"display:flex;gap:8px;\">\n <input id=\"obApiKey\" type=\"password\" value=\"${escapeHtml(onboardingData.apiKey)}\" placeholder=\"sk-...\" style=\"flex:1;height:42px;padding:0 12px;border:1px solid var(--line);border-radius:8px;font-size:14px;color:var(--ink);background:var(--white);outline:0;\" />\n <button class=\"ghost-button\" type=\"button\" id=\"obToggleKey\">👁 显示</button>\n </div>\n </div>\n `;\n const apiBaseInput = document.querySelector(\"#obApiBase\");\n const apiKeyInput = document.querySelector(\"#obApiKey\");\n const toggleBtn = document.querySelector(\"#obToggleKey\");\n\n apiBaseInput.addEventListener(\"input\", () => { onboardingData.apiBase = apiBaseInput.value; });\n apiKeyInput.addEventListener(\"input\", () => { onboardingData.apiKey = apiKeyInput.value; });\n toggleBtn.addEventListener(\"click\", () => {\n if (apiKeyInput.type === \"password\") {\n apiKeyInput.type = \"text\";\n toggleBtn.textContent = \"👁 隐藏\";\n } else {\n apiKeyInput.type = \"password\";\n toggleBtn.textContent = \"👁 显示\";\n }\n });\n onboardingNext.disabled = false;\n}\n\nfunction renderModelTestStep() {\n onboardingBody.innerHTML = `\n <h3>模型选择 & 连接测试</h3>\n <p>选择主力模型和强力模型,然后测试连接。</p>\n <div class=\"field\">\n <span>主力模型(编码、灵敏度分析)</span>\n <input id=\"obDefaultModel\" value=\"${escapeHtml(onboardingData.defaultModel)}\" style=\"width:100%;height:42px;padding:0 12px;border:1px solid var(--line);border-radius:8px;font-size:14px;color:var(--ink);background:var(--white);outline:0;\" />\n </div>\n <div class=\"field\">\n <span>强力模型(分析、建模、写作、评审)</span>\n <input id=\"obStrongModel\" value=\"${escapeHtml(onboardingData.strongModel)}\" style=\"width:100%;height:42px;padding:0 12px;border:1px solid var(--line);border-radius:8px;font-size:14px;color:var(--ink);background:var(--white);outline:0;\" />\n </div>\n <button class=\"primary-button\" type=\"button\" id=\"obTestBtn\">🔍 测试连接</button>\n <div id=\"obTestResult\"></div>\n `;\n const dmInput = document.querySelector(\"#obDefaultModel\");\n const smInput = document.querySelector(\"#obStrongModel\");\n dmInput.addEventListener(\"input\", () => { onboardingData.defaultModel = dmInput.value; });\n smInput.addEventListener(\"input\", () => { onboardingData.strongModel = smInput.value; });\n\n let testOk = false;\n document.querySelector(\"#obTestBtn\").addEventListener(\"click\", async () => {\n const result = document.querySelector(\"#obTestResult\");\n result.innerHTML = '<p style=\"color:var(--muted);\">测试中...</p>';\n try {\n const [r1, r2] = await Promise.all([\n testLlm(onboardingData.apiBase, onboardingData.apiKey, onboardingData.defaultModel),\n testLlm(onboardingData.apiBase, onboardingData.apiKey, onboardingData.strongModel),\n ]);\n const html = renderTestResult(\"主力模型\", r1) + renderTestResult(\"强力模型\", r2);\n result.innerHTML = html;\n testOk = r1.success && r2.success;\n onboardingNext.disabled = !testOk;\n } catch (error) {\n result.innerHTML = `<div class=\"test-result fail\">测试失败: ${escapeHtml(error.message)}</div>`;\n }\n });\n onboardingNext.disabled = true;\n}\n\nasync function testLlm(apiBase, apiKey, model) {\n const res = await api(\"/api/config/test-llm\", {\n method: \"POST\",\n body: JSON.stringify({ apiBase, apiKey, model }),\n });\n return res;\n}\n\nfunction renderTestResult(label, result) {\n const cls = result.success ? \"ok\" : \"fail\";\n const text = result.success\n ? `✓ ${label} 连接成功 · ${result.latency_ms}ms`\n : `✗ ${label} 失败: ${escapeHtml(result.error || \"未知错误\")}`;\n return `<div class=\"test-result ${cls}\">${text}</div>`;\n}\n\nasync function renderCompleteStep() {\n // 保存配置\n try {\n await api(\"/api/config\", {\n method: \"POST\",\n body: JSON.stringify({\n apiBase: onboardingData.apiBase,\n apiKey: onboardingData.apiKey,\n defaultModel: onboardingData.defaultModel,\n strongModel: onboardingData.strongModel,\n }),\n });\n } catch (error) {\n showToast(`保存配置失败: ${error.message}`);\n }\n\n const providerName = onboardingData.provider === \"custom\" ? \"自定义\" : onboardingData.provider;\n onboardingBody.innerHTML = `\n <div style=\"text-align:center;padding:20px 0;\">\n <div style=\"font-size:48px;margin-bottom:12px;\">🎉</div>\n <h3 style=\"color:var(--green);\">配置完成!</h3>\n <p>配置已自动保存到 .env 文件</p>\n <div style=\"margin:16px auto;padding:14px;border:1px solid var(--line);border-radius:8px;background:#fbfcfe;text-align:left;font-size:0.86rem;max-width:400px;\">\n <p><strong>端点:</strong> ${escapeHtml(onboardingData.apiBase)}</p>\n <p><strong>主力模型:</strong> ${escapeHtml(onboardingData.defaultModel)}</p>\n <p><strong>强力模型:</strong> ${escapeHtml(onboardingData.strongModel)}</p>\n </div>\n </div>\n `;\n}\n\nonboardingPrev?.addEventListener(\"click\", () => {\n if (onboardingStep > 1) {\n onboardingStep--;\n renderOnboardingStep();\n }\n});\n\nonboardingNext?.addEventListener(\"click\", () => {\n if (onboardingStep < 5) {\n onboardingStep++;\n renderOnboardingStep();\n } else {\n hideOnboarding();\n showToast(\"配置完成,欢迎使用 Beacon!\");\n }\n});\n```\n\n- [ ] **Step 3: 在 `frontend/app.js` 末尾添加设置页面逻辑**\n\n```javascript\n// ============ 设置页面 ============\n\nlet settingsTab = \"api\";\nlet settingsConfig = null;\n\nasync function loadSettings() {\n try {\n settingsConfig = await api(\"/api/config\");\n renderSettings();\n } catch (error) {\n settingsContent.innerHTML = `<p>加载配置失败: ${escapeHtml(error.message)}</p>`;\n }\n}\n\nfunction renderSettings() {\n if (!settingsConfig) return;\n if (settingsTab === \"api\") renderSettingsApi();\n else if (settingsTab === \"models\") renderSettingsModels();\n else if (settingsTab === \"rag\") renderSettingsRag();\n else if (settingsTab === \"advanced\") renderSettingsAdvanced();\n}\n\nfunction renderSettingsApi() {\n settingsContent.innerHTML = `\n <div class=\"field\">\n <span>API 端点</span>\n <input id=\"setApiBase\" value=\"${escapeHtml(settingsConfig.apiBase)}\" />\n <span class=\"field-hint\">OpenAI 兼容端点地址</span>\n </div>\n <div class=\"field\">\n <span>API 密钥</span>\n <div style=\"display:flex;gap:8px;\">\n <input id=\"setApiKey\" type=\"password\" value=\"${escapeHtml(settingsConfig.apiKey)}\" style=\"flex:1;\" />\n <button class=\"ghost-button\" type=\"button\" id=\"setToggleKey\">👁 显示</button>\n <button class=\"primary-button\" type=\"button\" id=\"setTestBtn\">🔍 测试</button>\n </div>\n <div id=\"setTestResult\"></div>\n </div>\n `;\n document.querySelector(\"#setToggleKey\")?.addEventListener(\"click\", (e) => {\n const input = document.querySelector(\"#setApiKey\");\n if (input.type === \"password\") { input.type = \"text\"; e.target.textContent = \"👁 隐藏\"; }\n else { input.type = \"password\"; e.target.textContent = \"👁 显示\"; }\n });\n document.querySelector(\"#setTestBtn\")?.addEventListener(\"click\", async () => {\n const apiBase = document.querySelector(\"#setApiBase\").value;\n const apiKey = document.querySelector(\"#setApiKey\").value;\n const model = settingsConfig.defaultModel || \"test\";\n const result = document.querySelector(\"#setTestResult\");\n result.innerHTML = '<p class=\"field-hint\">测试中...</p>';\n try {\n const r = await testLlm(apiBase, apiKey.includes(\"***\") ? \"\" : apiKey, model);\n result.innerHTML = r.success\n ? `<div class=\"test-result ok\">✓ 连接成功 · ${r.latency_ms}ms</div>`\n : `<div class=\"test-result fail\">✗ ${escapeHtml(r.error)}</div>`;\n } catch (error) {\n result.innerHTML = `<div class=\"test-result fail\">✗ ${escapeHtml(error.message)}</div>`;\n }\n });\n}\n\nfunction renderSettingsModels() {\n settingsContent.innerHTML = `\n <div class=\"field\">\n <span>主力模型(编码、灵敏度分析)</span>\n <input id=\"setDefaultModel\" value=\"${escapeHtml(settingsConfig.defaultModel)}\" />\n <span class=\"field-hint\">用于 routine 节点,格式:provider/model</span>\n </div>\n <div class=\"field\">\n <span>强力模型(分析、建模、写作、评审)</span>\n <input id=\"setStrongModel\" value=\"${escapeHtml(settingsConfig.strongModel)}\" />\n <span class=\"field-hint\">用于核心节点</span>\n </div>\n `;\n}\n\nfunction renderSettingsRag() {\n settingsContent.innerHTML = `\n <div class=\"toggle-row\">\n <div>\n <strong>启用 RAG 知识库</strong>\n <p class=\"field-hint\">启用后注入经典模型模式到提示词</p>\n </div>\n <div class=\"toggle-switch ${settingsConfig.ragEnabled ? \"on\" : \"\"}\" id=\"setRagToggle\"></div>\n </div>\n <div class=\"field\">\n <span>Embedding 模型</span>\n <input id=\"setRagEmbed\" value=\"${escapeHtml(settingsConfig.ragEmbeddingModel)}\" />\n </div>\n <div class=\"field\">\n <span>检索 Top-K</span>\n <input id=\"setRagTopK\" type=\"number\" value=\"${settingsConfig.ragTopK}\" />\n </div>\n `;\n document.querySelector(\"#setRagToggle\")?.addEventListener(\"click\", (e) => {\n e.target.classList.toggle(\"on\");\n });\n}\n\nfunction renderSettingsAdvanced() {\n settingsContent.innerHTML = `\n <div class=\"field\">\n <span>模型迭代轮次上限</span>\n <div style=\"display:flex;align-items:center;gap:12px;\">\n <input id=\"setIterations\" type=\"range\" min=\"1\" max=\"5\" value=\"${settingsConfig.maxModelIterations}\" style=\"flex:1;\" />\n <span id=\"setIterationsVal\" style=\"font-weight:700;color:var(--blue);\">${settingsConfig.maxModelIterations}</span>\n </div>\n </div>\n <div class=\"field\">\n <span>LLM 超时(秒)</span>\n <input id=\"setLlmTimeout\" type=\"number\" value=\"${settingsConfig.llmTimeout}\" />\n </div>\n <div class=\"field\">\n <span>前端端口</span>\n <input id=\"setPort\" type=\"number\" value=\"${settingsConfig.port}\" />\n <span class=\"field-hint\">修改后需重启服务生效</span>\n </div>\n `;\n document.querySelector(\"#setIterations\")?.addEventListener(\"input\", (e) => {\n document.querySelector(\"#setIterationsVal\").textContent = e.target.value;\n });\n}\n\nfunction collectSettings() {\n const config = {};\n const apiBase = document.querySelector(\"#setApiBase\");\n const apiKey = document.querySelector(\"#setApiKey\");\n const defaultModel = document.querySelector(\"#setDefaultModel\");\n const strongModel = document.querySelector(\"#setStrongModel\");\n const ragToggle = document.querySelector(\"#setRagToggle\");\n const ragEmbed = document.querySelector(\"#setRagEmbed\");\n const ragTopK = document.querySelector(\"#setRagTopK\");\n const iterations = document.querySelector(\"#setIterations\");\n const llmTimeout = document.querySelector(\"#setLlmTimeout\");\n const port = document.querySelector(\"#setPort\");\n\n if (apiBase) config.apiBase = apiBase.value;\n if (apiKey && !apiKey.value.includes(\"***\")) config.apiKey = apiKey.value;\n if (defaultModel) config.defaultModel = defaultModel.value;\n if (strongModel) config.strongModel = strongModel.value;\n if (ragToggle) config.ragEnabled = ragToggle.classList.contains(\"on\");\n if (ragEmbed) config.ragEmbeddingModel = ragEmbed.value;\n if (ragTopK) config.ragTopK = Number(ragTopK.value);\n if (iterations) config.maxModelIterations = Number(iterations.value);\n if (llmTimeout) config.llmTimeout = Number(llmTimeout.value);\n if (port) config.port = Number(port.value);\n return config;\n}\n\nsettingsNavButtons.forEach((btn) => {\n btn.addEventListener(\"click\", () => {\n settingsNavButtons.forEach((b) => b.classList.remove(\"active\"));\n btn.classList.add(\"active\");\n settingsTab = btn.dataset.settingsTab;\n renderSettings();\n });\n});\n\nsettingsSave?.addEventListener(\"click\", async () => {\n try {\n const config = collectSettings();\n await api(\"/api/config\", { method: \"POST\", body: JSON.stringify(config) });\n showToast(\"配置已保存\");\n await loadSettings();\n } catch (error) {\n showToast(`保存失败: ${error.message}`);\n }\n});\n\nsettingsReset?.addEventListener(\"click\", async () => {\n if (!confirm(\"确定要重置为 .env.example 的默认值吗?\")) return;\n try {\n // 读取 .env.example 的默认值\n const { readFile } = await import(\"node:fs/promises\");\n // 通过 API 保存默认值\n await api(\"/api/config\", {\n method: \"POST\",\n body: JSON.stringify({\n apiBase: \"http://localhost:20128/v1\",\n apiKey: \"123456\",\n defaultModel: \"deepseek-v4-flash-free\",\n strongModel: \"deepseek-v4-flash-free\",\n llmTimeout: 300,\n maxModelIterations: 3,\n ragEnabled: false,\n ragEmbeddingModel: \"text-embedding-3-small\",\n ragTopK: 4,\n port: 5173,\n }),\n });\n showToast(\"已重置为默认值\");\n await loadSettings();\n } catch (error) {\n showToast(`重置失败: ${error.message}`);\n }\n});\n```\n\n- [ ] **Step 4: 修改启动逻辑,检查 onboarding 状态**\n\n在 `frontend/app.js` 末尾找到现有的 health 检查(约第 656 行):\n\n```javascript\napi(\"/api/health\")\n .then((health) => showToast(`已连接项目:${health.fixtures} 个示例题`))\n .catch((error) => showToast(`项目 API 未连接:${error.message}`));\n```\n\n替换为:\n\n```javascript\napi(\"/api/health\")\n .then((health) => {\n if (health.onboarding && health.onboarding.needed) {\n showOnboarding();\n } else {\n showToast(`已连接项目:${health.fixtures} 个示例题`);\n }\n })\n .catch((error) => showToast(`项目 API 未连接:${error.message}`));\n```\n\n- [ ] **Step 5: 修改导航逻辑,支持设置页面切换**\n\n在 `frontend/app.js` 中找到 `navLinks.forEach` 行(约第 509 行),在其之前添加设置页面切换逻辑:\n\n```javascript\n// 设置页面导航\ndocument.querySelectorAll(\".nav-list a\").forEach((link) => {\n link.addEventListener(\"click\", (e) => {\n if (link.getAttribute(\"href\") === \"#settings\") {\n e.preventDefault();\n document.querySelectorAll(\".nav-list a\").forEach((l) => l.classList.remove(\"active\"));\n link.classList.add(\"active\");\n // 隐藏工作台,显示设置\n document.querySelector(\"#workspace\").style.display = \"none\";\n settingsPanel.hidden = false;\n loadSettings();\n } else {\n // 显示工作台,隐藏设置\n document.querySelector(\"#workspace\").style.display = \"\";\n settingsPanel.hidden = true;\n }\n });\n});\n```\n\n- [ ] **Step 6: Commit**\n\n```bash\ngit add frontend/app.js\ngit commit -m \"feat: add onboarding wizard + settings page JS logic\"\n```\n\n---\n\n### Task 11: 增强 start.bat\n\n**Files:**\n- Modify: `scripts/start.bat`\n\n- [ ] **Step 1: 替换 `scripts/start.bat` 全部内容**\n\n```batch\n@echo off\nchcp 65001 >nul\nsetlocal EnableDelayedExpansion\n\necho ============================================\necho Beacon Math Agent - 一键启动 (Windows)\necho ============================================\necho.\n\ncd /d \"%~dp0..\"\n\nREM --- 检查 Node.js(唯一硬依赖,没有它无法启动服务器)---\nwhere node >nul 2>nul\nif %errorlevel% neq 0 (\n echo [错误] 未找到 Node.js,请先安装:https://nodejs.org\n echo 安装后重新运行此脚本。\n pause\n exit /b 1\n)\nfor /f \"delims=\" %%v in ('node --version') do set \"node_version=%%v\"\necho [OK] Node.js %node_version% 已就绪\n\nREM --- 检查 .env ---\nif not exist \".env\" (\n if exist \".env.example\" (\n echo [初始化] 从 .env.example 创建 .env\n copy \".env.example\" \".env\" >nul\n echo 启动后可通过引导向导配置 API 密钥\n )\n)\n\nREM --- 安装 Node.js 依赖 ---\nif not exist \"node_modules\" (\n echo.\n echo [安装] Node.js 依赖...\n call npm install\n if !errorlevel! neq 0 (\n echo [错误] npm install 失败\n pause\n exit /b 1\n )\n)\n\nREM --- 检查 uv(不阻断启动,引导向导会处理安装)---\nwhere uv >nul 2>nul\nif %errorlevel% neq 0 (\n echo [提示] 未找到 uv,Python 后端暂不可用\n echo 启动后可在引导向导中一键安装 uv\n) else (\n echo [OK] uv 已就绪\n REM --- 安装 Python 依赖 ---\n echo [检查] Python 依赖...\n uv sync 2>nul\n if !errorlevel! neq 0 (\n echo [警告] uv sync 失败,Python 后端可能无法启动\n )\n)\n\nREM --- 启动 ---\necho.\necho ============================================\necho 正在启动服务...\necho 浏览器访问 http://localhost:5173\necho 首次使用会自动进入配置引导\necho 按 Ctrl+C 停止\necho ============================================\necho.\n\nREM 等待 2 秒后自动打开浏览器\nstart \"\" /b cmd /c \"timeout /t 2 >nul && start http://localhost:5173\"\n\ncall npm start\npause\n```\n\n- [ ] **Step 2: Commit**\n\n```bash\ngit add scripts/start.bat\ngit commit -m \"feat: enhance start.bat - non-blocking uv check + onboarding hint\"\n```\n\n---\n\n### Task 12: 端到端验证\n\n- [ ] **Step 1: 运行全部测试**\n\nRun: `node --test frontend/server.test.mjs`\nExpected: 所有测试 PASS\n\n- [ ] **Step 2: 启动服务器手动验证**\n\nRun: `npm start`\n\n验证项:\n1. 打开 `http://localhost:5173`,如果 .env 没有 API 密钥,应自动弹出引导向导\n2. 引导向导步骤1应显示 Python/Node/uv 检测结果\n3. 点击侧边栏\"设置\"应进入设置页面\n4. 设置页面四个分类切换正常\n5. 保存配置后 `.env` 文件应被更新\n\n- [ ] **Step 3: 最终 Commit**\n\n```bash\ngit add -A\ngit commit -m \"feat: complete onboarding wizard + settings page + start.bat enhancement\"\n```\n\n---\n\n## 自查\n\n**1. Spec 覆盖:**\n- ✓ 引导向导5步:Task 8 (HTML) + Task 9 (CSS) + Task 10 (JS)\n- ✓ 设置页面4个分类:Task 8 + Task 10\n- ✓ 启动脚本增强:Task 11\n- ✓ 环境检测 + winget 安装:Task 3\n- ✓ .env 读写:Task 4\n- ✓ LLM 连接测试:Task 4\n- ✓ 引导状态判断:Task 5\n- ✓ providers.json 预设:Task 2\n- ✓ server.mjs 路由分发:Task 6\n- ✓ health 端点增强:Task 6\n- ✓ 错误处理(winget不可用/超时/失败):Task 3\n- ✓ 测试:Task 7\n\n**2. 占位符扫描:** 无 TBD/TODO,所有步骤都有完整代码。✓\n\n**3. 类型一致性:**\n- `handleEnvRoutes(request, response, url)` 签名在 Task 3 和 Task 6 一致 ✓\n- `handleConfigRoutes(request, response, url)` 签名在 Task 4 和 Task 6 一致 ✓\n- `handleOnboardingRoutes(request, response, url)` 签名在 Task 5 和 Task 6 一致 ✓\n- `getOnboardingStatus()` 在 Task 5 定义,Task 6 导入使用 ✓\n- 前端 API 路径 `/api/env/check`、`/api/config`、`/api/config/test-llm`、`/api/onboarding/status`、`/api/providers` 在前后端一致 ✓\n- `testLlm(apiBase, apiKey, model)` 在 Task 10 定义并在多处使用 ✓\n"}}}}