15 KiB
15 KiB
E17: AI 编码助手 — opencode + DeepSeek 集成
概述
编辑器内嵌 AI 对话面板,使用 opencode Agent + DeepSeek 后端,支持两种模式:
- JSON 模式 — 修改场景配置,填充 textarea 供用户审查后接受
- 代码模式 — 直接修改
src/目录下的 Vue 组件和 CSS,Vite HMR 实时预览
完整架构
浏览器 (Editor)
├── AIPanel.vue 用户输入自然语言
├── NodeEditor.vue 接收 AI 返回的 JSON,[接受]/[撤销]
└── App.vue 管理 AI 面板状态
↓ POST /api/ai { sessionId, userMessage, apiKey, mode, nodeId? }
Vite 中间件(零状态)
└── spawn('node_modules/.bin/opencode', ['run', '--session', sessionId, '--model', 'deepseek', '--format', 'json', fullMessage])
├── fullMessage = modePrefix + userMessage
│ JSON模式: "JSON模式:只返回修改后的 JSON 文本,不要写任何文件。需求:..." + nodeJson
│ 代码模式: "代码模式:直接修改 src/ 下的源码文件并保存。需求:..."
├── opencode 自身管理会话上下文(--session 复用已有会话)
├── 自动读取项目文件构建 prompt → 调 DeepSeek API
└── 返回 stdout
JSON 模式交互时序
用户 编辑器 Vite 中间件 opencode DeepSeek
│ │ │ │ │
│ 选中节点+输入需求 │ │ │ │
│─────────────────────→│ │ │ │
│ │ POST /api/ai │ │ │
│ │──────────────────────→│ │ │
│ │ │ spawn opencode │ │
│ │ │───────────────────→│ │
│ │ │ │ 读 story.json + │
│ │ │ │ SPEC.md + 用户需求 │
│ │ │ │────────────────────→│
│ │ │ │ 返回 JSON │
│ │ │ │←────────────────────│
│ │ │ stdout: JSON │ │
│ │ │←───────────────────│ │
│ │ 200 { result } │ │ │
│ │←──────────────────────│ │ │
│ │ │ │ │
│ 显示 [接受] [撤销] │ │ │ │
│─────→ textarea 填充 │ │ │ │
│ │ │ │ │
│ 点击 [接受] │ │ │ │
│─────────────────────→│ │ │ │
│ │ JSON.parse → update │ │ │
│ │ → autoSave 写磁盘 │ │ │
代码模式交互时序
用户 编辑器 Vite 中间件 opencode DeepSeek
│ │ │ │ │
│ 输入 UI 修改需求 │ │ │ │
│─────────────────────→│ │ │ │
│ │ POST /api/ai │ │ │
│ │──────────────────────→│ │ │
│ │ │ spawn opencode │ │
│ │ │───────────────────→│ │
│ │ │ │ 读 src/components/ │
│ │ │ │ 改 Vue/CSS 文件 │
│ │ │ │────────────────────→│
│ │ │ │ 写文件到 src/ │
│ │ │ │←────────────────────│
│ │ │ stdout: done │ │
│ │ │←───────────────────│ │
│ │ 200 { result } │ │ │
│ │←──────────────────────│ │ │
│ │ │ │ │
│ Vite HMR 检测变化 │ │ │ │
│←─────────────────────│ │ │ │
│ 浏览器热更新预览 │ │ │ │
关键设计决策
| 决策 | 做法 | 原因 |
|---|---|---|
| AI 后端 | opencode Agent + DeepSeek | 唯一覆盖 JSON + 代码双模式;自动读取项目文件构建上下文 |
| opencode 安装 | npm 包 opencode-ai,作为 devDependencies。npm install 后 node_modules/.bin/opencode 即可用 |
clone 即用,无需手动全局安装 |
| API Key 存储 | 编辑器设置中输入,存 localStorage,每次请求传给 /api/ai |
不硬编码,创作者自行管理 |
| API Key 传输 | 通过 POST body 传到 Vite 中间件,不暴露在浏览器网络日志 | XSS 只能获取 localStorage,看不到服务端日志 |
| JSON 编辑 | AI 返回 JSON 填充 textarea,用户审查后 [接受] 才保存 | 保留创作者对故事数据的最终控制权 |
| 代码编辑 | opencode 直接写 src/ 目录,Vite HMR 秒级刷新 |
代码修改即时可视化,不需要手动刷新 |
| 上下文构建 | opencode 读取项目文件自成上下文 | 不需要手动拼系统 prompt |
| 模式切换 | AIPanel 当前模式标签:选中节点 → "JSON 模式";未选中节点 → "代码模式"。手动可切换 | 减少用户认知负担,大部分场景自动判断正确 |
| DeepSeek 模型 | --model deepseek,通过 opencode providers 配置 Key |
opencode 自身管理模型路由 |
会话管理
opencode 原生支持会话:opencode run --session <id> 复用已有上下文,opencode session list 列出历史。
架构原则:前端持 sessionId,中间件无状态
AIPanel (Vue)
├── sessionId = localStorage.getItem('editor_ai_session') || crypto.randomUUID()
├── 每次请求 POST /api/ai 带上 sessionId
├── "新建对话" → 新 UUID → 覆盖 localStorage
└── 历史列表 → GET /api/ai/sessions → opencode session list → 前端渲染
Vite 中间件 — 零状态
└── spawn('opencode', ['run', '--session', sessionId, '--format', 'json', userMessage])
└── 不存任何 sessionId,不维护子进程
会话操作
| 操作 | opencode CLI | 触发方式 |
|---|---|---|
| 首次对话 | opencode run --session <新UUID> --model deepseek --format json "..." |
AIPanel 检测 localStorage 无 sessionId |
| 继续对话 | opencode run --session <已有UUID> --model deepseek --format json "..." |
带上同一 sessionId |
| 新建对话 | 前端生成新 UUID → 旧会话仍存在于 opencode 内部 DB | AIPanel "新建对话" 按钮 |
| 历史列表 | opencode session list |
AIPanel 顶部下拉(可选) |
为什么不用长活子进程
| 长活子进程 + stdin/stdout | 独立进程 + --session | |
|---|---|---|
| 进程管理 | 需管道通信、心跳检测 | spawn 等待 exit,0 行管理代码 |
| 中间件状态 | 需维护 sessionId→process Map | 零状态 |
| dev server 重启 | 会话丢失 | localStorage 持久化,重启可恢复 |
| 稳定性 | 子进程可能 crash | 每次独立进程,天然隔离 |
| 延迟 | ~1-3s(管道通信) | ~5s(启动 Node + 加载会话),对编辑器 AI 场景可接受 |
安全设计
| 层级 | 措施 |
|---|---|
| API Key | AES 加密存 localStorage?→ 不用,Key 本身是服务端 API 凭证,浏览器存储已是业界实践(VS Code Copilot、Cursor 同理) |
| 传输 | POST body 中传递,HTTPS 加密。仅 /api/ai 路由可读取,上游不打印日志 |
| 文件写入 | opencode 通过 Vite 中间件 spawn,中间件做路径白名单:JSON 模式只允许 public/scenes/*.json;代码模式只允许 src/**/*.vue、src/**/*.ts、src/locales/*.json |
| 请求频率 | 中间件加 3s 内去重(同一 mode+userMessage 不重复 spawn) |
错误处理
| 错误场景 | 处理 |
|---|---|
| DeepSeek API 超时(15s) | 中间件 kill 子进程,返回 504 { error: "timeout" } |
| opencode 进程崩溃(exit code ≠ 0) | 中间件返回 500 { error: "opencode exited with code " + code } |
| DeepSeek API 余额不足 | 中间件返回 402 { error: "insufficient_quota" } |
| opencode 返回非 JSON(代码模式) | 直接视为成功(stdout 文本无关紧要) |
| opencode 返回非 JSON(JSON 模式) | 中间件尝试用正则提取 JSON 块,无法提取则返回 500 给前端 |
文件改动清单
| 文件 | 职能 |
|---|---|
package.json |
新增 opencode-ai devDependency("opencode-ai": "^1.17") |
vite.config.ts |
新增 POST /api/ai 中间件:接收 sessionId/userMessage/apiKey/mode,构造 modePrefix + spawn(opencode, ['run', '--session', sessionId, '--model', 'deepseek', '--format', 'json', fullMessage]);GET /api/ai/sessions → opencode session list |
editor/composables/useAI.ts |
新建 — sendAIRequest(sessionId, userMessage, mode) → fetch(/api/ai);listSessions() → fetch(/api/ai/sessions) |
editor/components/AIPanel.vue |
新建 — 消息历史 + <input> + loading + "正在生成..." + "新建对话"按钮 + 会话列表下拉 |
editor/stores/editorStore.ts |
新增 deepseekKey(localStorage)、showAIPanel、aiResult、sessionId |
editor/App.vue |
新增 "AI 助手" 按钮 + AIPanel 组件 + 设置面板中 API Key 输入栏 |
测试用例
T1: JSON 模式 — 正常流程
| 步骤 | 预期结果 |
|---|---|
| 选中一个场景节点 | NodeEditor 显示该场景 JSON |
| 打开 AIPanel,输入"给这个场景添加一个 QTE" | 显示 loading 状态 |
| opencode 返回修改后 JSON | NodeEditor textarea 被填充,出现 [接受] [撤销] |
| 点击 [接受] | JSON.parse 成功,store.updateScene 执行,autoSave 写磁盘 |
| 点击 [撤销] | textarea 回滚到 AI 修改前的值 |
T2: JSON 模式 — opencode 返回无效 JSON
| 步骤 | 预期结果 |
|---|---|
| AI 返回非法 JSON | 中间件返回 500 { error } |
| AIPanel 显示红色错误提示 | "AI 返回格式异常,请重试",textarea 不被填充 |
T3: 代码模式 — 正常流程
| 步骤 | 预期结果 |
|---|---|
| 未选中任何节点 | AIPanel 显示 "代码模式" 标签 |
| 输入"把按钮改成圆角 20px" | 显示 loading |
opencode 修改 src/components/ChoicePanel.vue 的 CSS |
文件写磁盘成功 |
| Vite HMR 触发 | 预览窗按钮圆角即时可见 |
T4: 会话管理
| 步骤 | 预期结果 |
|---|---|
| 首次对话 | localStorage 无 editor_ai_session → AIPanel 自动生成 UUID |
| 第二次对话 | 带上同一 sessionId → opencode --session <id> 恢复上下文 |
| 点击 "新建对话" | 新 UUID 写入 localStorage |
| 刷新页面 | sessionId 不丢失,历史列表可查 |
T5: 错误处理
| 步骤 | 预期结果 |
|---|---|
| DeepSeek Key 无效 | AIPanel 显示 "API 认证失败,请检查 Key" |
| opencode 进程被 kill | 中间件 child.on('exit', 137) → 500 |
| 请求 15s 无响应 | 中间件 kill 子进程 → 504 |
| 同一 userMessage 3s 内发送两次 | 去重,不重复 spawn |
T6: 路径白名单
| 步骤 | 预期结果 |
|---|---|
代码模式让 AI 修改 /etc/passwd |
中间件拒绝或 opencode 写盘失败 |
JSON 模式让 AI 修改 ../secret.json |
路径不在 public/scenes/*.json 范围内, 被拒绝 |
T7: API Key 管理
| 步骤 | 预期结果 |
|---|---|
| 未填 Key 就发送请求 | AIPanel 提示 "请先在设置中输入 DeepSeek API Key" |
| 填入 Key → 发送请求 | Key 从 localStorage 读取,POST body 传给中间件 |
| 刷新页面 | Key 仍存在 |
T8: NodeEditor 覆盖保护
| 步骤 | 预期结果 |
|---|---|
| 用户正在手动编辑 textarea → AI 返回结果 | AI 结果填充到 textarea(覆盖手动编辑),可 [撤销] |
| AI 返回后用户手动再编辑 → 失焦 | 手动编辑内容通过 blur 保存(不是 AI 内容) |