# 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 内容） |
