{"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 行(`知识库` 之后)添加:\n\n```html\n 设置\n```\n\n- [ ] **Step 2: 在 `` 之前添加设置页面容器**\n\n在 `frontend/index.html` 找到 ``(第 230 行),在其之前添加:\n\n```html\n \n```\n\n- [ ] **Step 3: 在 `\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

环境检测

\n

正在检测运行所需依赖...

\n
\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 = `

检测失败: ${escapeHtml(error.message)}

`;\n }\n}\n\nfunction renderEnvItem(name, requirement, info) {\n const ok = info.ok;\n const status = ok\n ? `✓ ${info.version}`\n : `✗ ${info.installed ? info.version + \" 版本过低\" : \"未检测到\"}`;\n const action = ok ? \"\" : ``;\n // 修正 tool 名称映射\n const toolName = name === \"Python\" ? \"python\" : name === \"Node.js\" ? \"node\" : name === \"uv\" ? \"uv\" : \"\";\n const actionBtn = ok ? \"\" : ``;\n return `\n
\n
\n ${escapeHtml(name)}\n ${escapeHtml(requirement)}\n
\n
\n ${status}\n ${actionBtn}\n
\n
\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 `

手动安装: ${result.downloadUrl}

`);\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

选择 API 提供商

\n

选择你的 LLM 服务提供商,选择后会自动填入端点和推荐模型。

\n
\n `;\n try {\n const providers = await api(\"/api/providers\");\n const grid = document.querySelector(\"#providerGrid\");\n grid.innerHTML = providers.map((p) => `\n
\n ${escapeHtml(p.name)}\n ${escapeHtml(p.description)}\n
\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 = `

加载提供商列表失败: ${escapeHtml(error.message)}

`;\n }\n}\n\nfunction renderApiKeyStep() {\n onboardingBody.innerHTML = `\n

API 密钥 & 端点配置

\n

填写你的 API 密钥。端点已根据所选提供商自动填入,可修改。

\n
\n API 端点(Base URL)\n \n
\n
\n API 密钥\n
\n \n \n
\n
\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

模型选择 & 连接测试

\n

选择主力模型和强力模型,然后测试连接。

\n
\n 主力模型(编码、灵敏度分析)\n \n
\n
\n 强力模型(分析、建模、写作、评审)\n \n
\n \n
\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 = '

测试中...

';\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 = `
测试失败: ${escapeHtml(error.message)}
`;\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 `
${text}
`;\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
\n
🎉
\n

配置完成!

\n

配置已自动保存到 .env 文件

\n
\n

端点: ${escapeHtml(onboardingData.apiBase)}

\n

主力模型: ${escapeHtml(onboardingData.defaultModel)}

\n

强力模型: ${escapeHtml(onboardingData.strongModel)}

\n
\n
\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 = `

加载配置失败: ${escapeHtml(error.message)}

`;\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
\n API 端点\n \n OpenAI 兼容端点地址\n
\n
\n API 密钥\n
\n \n \n \n
\n
\n
\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 = '

测试中...

';\n try {\n const r = await testLlm(apiBase, apiKey.includes(\"***\") ? \"\" : apiKey, model);\n result.innerHTML = r.success\n ? `
✓ 连接成功 · ${r.latency_ms}ms
`\n : `
✗ ${escapeHtml(r.error)}
`;\n } catch (error) {\n result.innerHTML = `
✗ ${escapeHtml(error.message)}
`;\n }\n });\n}\n\nfunction renderSettingsModels() {\n settingsContent.innerHTML = `\n
\n 主力模型(编码、灵敏度分析)\n \n 用于 routine 节点,格式:provider/model\n
\n
\n 强力模型(分析、建模、写作、评审)\n \n 用于核心节点\n
\n `;\n}\n\nfunction renderSettingsRag() {\n settingsContent.innerHTML = `\n
\n
\n 启用 RAG 知识库\n

启用后注入经典模型模式到提示词

\n
\n
\n
\n
\n Embedding 模型\n \n
\n
\n 检索 Top-K\n \n
\n `;\n document.querySelector(\"#setRagToggle\")?.addEventListener(\"click\", (e) => {\n e.target.classList.toggle(\"on\");\n });\n}\n\nfunction renderSettingsAdvanced() {\n settingsContent.innerHTML = `\n
\n 模型迭代轮次上限\n
\n \n ${settingsConfig.maxModelIterations}\n
\n
\n
\n LLM 超时(秒)\n \n
\n
\n 前端端口\n \n 修改后需重启服务生效\n
\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 行(`知识库` 之后)添加:\n\n```html\n 设置\n```\n\n- [ ] **Step 2: 在 `` 之前添加设置页面容器**\n\n在 `frontend/index.html` 找到 ``(第 230 行),在其之前添加:\n\n```html\n \n```\n\n- [ ] **Step 3: 在 `\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

环境检测

\n

正在检测运行所需依赖...

\n
\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 = `

检测失败: ${escapeHtml(error.message)}

`;\n }\n}\n\nfunction renderEnvItem(name, requirement, info) {\n const ok = info.ok;\n const status = ok\n ? `✓ ${info.version}`\n : `✗ ${info.installed ? info.version + \" 版本过低\" : \"未检测到\"}`;\n const action = ok ? \"\" : ``;\n // 修正 tool 名称映射\n const toolName = name === \"Python\" ? \"python\" : name === \"Node.js\" ? \"node\" : name === \"uv\" ? \"uv\" : \"\";\n const actionBtn = ok ? \"\" : ``;\n return `\n
\n
\n ${escapeHtml(name)}\n ${escapeHtml(requirement)}\n
\n
\n ${status}\n ${actionBtn}\n
\n
\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 `

手动安装: ${result.downloadUrl}

`);\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

选择 API 提供商

\n

选择你的 LLM 服务提供商,选择后会自动填入端点和推荐模型。

\n
\n `;\n try {\n const providers = await api(\"/api/providers\");\n const grid = document.querySelector(\"#providerGrid\");\n grid.innerHTML = providers.map((p) => `\n
\n ${escapeHtml(p.name)}\n ${escapeHtml(p.description)}\n
\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 = `

加载提供商列表失败: ${escapeHtml(error.message)}

`;\n }\n}\n\nfunction renderApiKeyStep() {\n onboardingBody.innerHTML = `\n

API 密钥 & 端点配置

\n

填写你的 API 密钥。端点已根据所选提供商自动填入,可修改。

\n
\n API 端点(Base URL)\n \n
\n
\n API 密钥\n
\n \n \n
\n
\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

模型选择 & 连接测试

\n

选择主力模型和强力模型,然后测试连接。

\n
\n 主力模型(编码、灵敏度分析)\n \n
\n
\n 强力模型(分析、建模、写作、评审)\n \n
\n \n
\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 = '

测试中...

';\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 = `
测试失败: ${escapeHtml(error.message)}
`;\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 `
${text}
`;\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
\n
🎉
\n

配置完成!

\n

配置已自动保存到 .env 文件

\n
\n

端点: ${escapeHtml(onboardingData.apiBase)}

\n

主力模型: ${escapeHtml(onboardingData.defaultModel)}

\n

强力模型: ${escapeHtml(onboardingData.strongModel)}

\n
\n
\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 = `

加载配置失败: ${escapeHtml(error.message)}

`;\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
\n API 端点\n \n OpenAI 兼容端点地址\n
\n
\n API 密钥\n
\n \n \n \n
\n
\n
\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 = '

测试中...

';\n try {\n const r = await testLlm(apiBase, apiKey.includes(\"***\") ? \"\" : apiKey, model);\n result.innerHTML = r.success\n ? `
✓ 连接成功 · ${r.latency_ms}ms
`\n : `
✗ ${escapeHtml(r.error)}
`;\n } catch (error) {\n result.innerHTML = `
✗ ${escapeHtml(error.message)}
`;\n }\n });\n}\n\nfunction renderSettingsModels() {\n settingsContent.innerHTML = `\n
\n 主力模型(编码、灵敏度分析)\n \n 用于 routine 节点,格式:provider/model\n
\n
\n 强力模型(分析、建模、写作、评审)\n \n 用于核心节点\n
\n `;\n}\n\nfunction renderSettingsRag() {\n settingsContent.innerHTML = `\n
\n
\n 启用 RAG 知识库\n

启用后注入经典模型模式到提示词

\n
\n
\n
\n
\n Embedding 模型\n \n
\n
\n 检索 Top-K\n \n
\n `;\n document.querySelector(\"#setRagToggle\")?.addEventListener(\"click\", (e) => {\n e.target.classList.toggle(\"on\");\n });\n}\n\nfunction renderSettingsAdvanced() {\n settingsContent.innerHTML = `\n
\n 模型迭代轮次上限\n
\n \n ${settingsConfig.maxModelIterations}\n
\n
\n
\n LLM 超时(秒)\n \n
\n
\n 前端端口\n \n 修改后需重启服务生效\n
\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 行(`知识库` 之后)添加:\n\n```html\n 设置\n```\n\n- [ ] **Step 2: 在 `` 之前添加设置页面容器**\n\n在 `frontend/index.html` 找到 ``(第 230 行),在其之前添加:\n\n```html\n \n```\n\n- [ ] **Step 3: 在 `\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

环境检测

\n

正在检测运行所需依赖...

\n
\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 = `

检测失败: ${escapeHtml(error.message)}

`;\n }\n}\n\nfunction renderEnvItem(name, requirement, info) {\n const ok = info.ok;\n const status = ok\n ? `✓ ${info.version}`\n : `✗ ${info.installed ? info.version + \" 版本过低\" : \"未检测到\"}`;\n const action = ok ? \"\" : ``;\n // 修正 tool 名称映射\n const toolName = name === \"Python\" ? \"python\" : name === \"Node.js\" ? \"node\" : name === \"uv\" ? \"uv\" : \"\";\n const actionBtn = ok ? \"\" : ``;\n return `\n
\n
\n ${escapeHtml(name)}\n ${escapeHtml(requirement)}\n
\n
\n ${status}\n ${actionBtn}\n
\n
\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 `

手动安装: ${result.downloadUrl}

`);\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

选择 API 提供商

\n

选择你的 LLM 服务提供商,选择后会自动填入端点和推荐模型。

\n
\n `;\n try {\n const providers = await api(\"/api/providers\");\n const grid = document.querySelector(\"#providerGrid\");\n grid.innerHTML = providers.map((p) => `\n
\n ${escapeHtml(p.name)}\n ${escapeHtml(p.description)}\n
\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 = `

加载提供商列表失败: ${escapeHtml(error.message)}

`;\n }\n}\n\nfunction renderApiKeyStep() {\n onboardingBody.innerHTML = `\n

API 密钥 & 端点配置

\n

填写你的 API 密钥。端点已根据所选提供商自动填入,可修改。

\n
\n API 端点(Base URL)\n \n
\n
\n API 密钥\n
\n \n \n
\n
\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

模型选择 & 连接测试

\n

选择主力模型和强力模型,然后测试连接。

\n
\n 主力模型(编码、灵敏度分析)\n \n
\n
\n 强力模型(分析、建模、写作、评审)\n \n
\n \n
\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 = '

测试中...

';\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 = `
测试失败: ${escapeHtml(error.message)}
`;\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 `
${text}
`;\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
\n
🎉
\n

配置完成!

\n

配置已自动保存到 .env 文件

\n
\n

端点: ${escapeHtml(onboardingData.apiBase)}

\n

主力模型: ${escapeHtml(onboardingData.defaultModel)}

\n

强力模型: ${escapeHtml(onboardingData.strongModel)}

\n
\n
\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 = `

加载配置失败: ${escapeHtml(error.message)}

`;\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
\n API 端点\n \n OpenAI 兼容端点地址\n
\n
\n API 密钥\n
\n \n \n \n
\n
\n
\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 = '

测试中...

';\n try {\n const r = await testLlm(apiBase, apiKey.includes(\"***\") ? \"\" : apiKey, model);\n result.innerHTML = r.success\n ? `
✓ 连接成功 · ${r.latency_ms}ms
`\n : `
✗ ${escapeHtml(r.error)}
`;\n } catch (error) {\n result.innerHTML = `
✗ ${escapeHtml(error.message)}
`;\n }\n });\n}\n\nfunction renderSettingsModels() {\n settingsContent.innerHTML = `\n
\n 主力模型(编码、灵敏度分析)\n \n 用于 routine 节点,格式:provider/model\n
\n
\n 强力模型(分析、建模、写作、评审)\n \n 用于核心节点\n
\n `;\n}\n\nfunction renderSettingsRag() {\n settingsContent.innerHTML = `\n
\n
\n 启用 RAG 知识库\n

启用后注入经典模型模式到提示词

\n
\n
\n
\n
\n Embedding 模型\n \n
\n
\n 检索 Top-K\n \n
\n `;\n document.querySelector(\"#setRagToggle\")?.addEventListener(\"click\", (e) => {\n e.target.classList.toggle(\"on\");\n });\n}\n\nfunction renderSettingsAdvanced() {\n settingsContent.innerHTML = `\n
\n 模型迭代轮次上限\n
\n \n ${settingsConfig.maxModelIterations}\n
\n
\n
\n LLM 超时(秒)\n \n
\n
\n 前端端口\n \n 修改后需重启服务生效\n
\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"}}}}