# E18: 版本回滚 & AI 修改标记

## 概述

为编辑器的 AI 助手增加两个生产级功能：
1. **版本回滚** — AI 修改前自动存档，错误时可恢复到任意历史版本
2. **修改标记** — AI 修改后在 SceneGraph 节点上显示角标（新增/修改/删除），视觉可感知变更

---

## 一、版本回滚

### 流程

```
用户发起 AI 请求
  → buildMessage → POST /api/ai
  → AIPanel.send() 在调用 sendAIRequest 前：
      editorStore.saveVersion("AI 修改前")
  → AI 修改完成 → reloadFromDisk() → gameData 刷新

用户发现改错了
  → AIPanel 版本下拉 → 选择历史版本
  → editorStore.restoreVersion(index)
  → gameData = version.gameData → autoSave()
  → SceneGraph + NodeEditor 重绘
```

### 涉及文件

| 文件 | 改动 |
|------|------|
| `editor/stores/editorStore.ts` | 新增 `versions` ref、`saveVersion(label)`、`restoreVersion(idx)` |
| `editor/db/editorDB.ts` | **新建** — IndexedDB 封装：`put`/`get`/`clear`，按 `sourcePath` 分组 |
| `editor/components/AIPanel.vue` | 新增版本下拉 UI（在聊天区顶部），send() 前调用 `saveVersion()` |

### 版本数据结构

```ts
interface EditorVersion {
  id: string            // crypto.randomUUID()
  sourcePath: string    // "/scenes/demo.json"
  timestamp: number     // Date.now()
  label: string         // "AI 修改前" / "初始版本"
  gameData: GameData    // 完整游戏数据快照
}
```

### IndexedDB 设计

```
数据库: editor_versions
表: versions
索引: sourcePath (查询同一文件的所有版本)
索引: timestamp (排序)
```

保留策略：同一 `sourcePath` 最多 20 个版本，超出删除最旧的。

### editorDB.ts API

```ts
// 保存版本
putVersion(v: EditorVersion): Promise<void>

// 获取某文件的所有版本（按时间倒序）
getVersions(sourcePath: string): Promise<EditorVersion[]>

// 清除某文件的所有版本（切换文件时）
clearVersions(sourcePath: string): Promise<void>
```

### 版本 UI 设计

```
AIPanel 顶部
┌──────────────────────┐
│ 历史版本: [AI 修改前 ▼]  │  ← 下拉列表，默认隐藏
│ 2026-06-15 14:30 AI 修改前 │
│ 2026-06-15 14:28 初始版本  │  ← restoreVersion 后刷新全部
└──────────────────────┘
```

恢复后逻辑：
1. `gameData = version.gameData`
2. `autoSave()` 写回磁盘
3. `selectedNodeId = null`（重置选中）
4. AIPanel 聊天区显示 "已恢复到 'AI 修改前'"
5. 不清除该版本快照（用户可能想在不同版本间多次切换）

### saveVersion 触发时机

| 时机 | 标签 |
|------|------|
| AI 模式 `send()` 调用前（`reloadFromDisk` 之前） | `"AI 修改前"` |
| `App.vue` 首次加载 demo.json 后 | `"初始版本"` |

不自动在手动编辑时保存版本（用户手动点"保存版本"按钮即可，可选后续添加）。

---

## 二、AI 修改标记

### 流程

```
reloadFromDisk() 前：
  记下 preGameData = gameData（深拷贝）
  
reloadFromDisk() 后：
  diff = computeDiff(preGameData, newGameData)
  aiChanges = { added, modified, deleted }
  
SceneGraph:
  渲染时根据 aiChanges 给对应节点加角标
```

### 数据结构

```ts
interface AIDiff {
  added: string[]        // 新 gameData 中有，旧 gameData 中没有的 scene ID
  modified: string[]      // 相同 ID，JSON.stringify 不同的 scene ID
  deleted: string[]       // 旧 gameData 中有，新 gameData 中没有的 scene ID
  globalFields: string[]  // 变更的全局配置字段名，例如 ["title", "assetBase"]
}
```

### diff 计算逻辑（editorStore.reloadFromDisk 内）

```ts
const { scenes: oldScenes, ...oldGlobal } = oldGameData
const { scenes: newScenes, ...newGlobal } = newGameData
const diff: AIDiff = { added: [], modified: [], deleted: [], globalFields: [] }

for (const id of Object.keys(newScenes)) {
  if (!oldScenes[id]) diff.added.push(id)
  else if (JSON.stringify(oldScenes[id]) !== JSON.stringify(newScenes[id])) diff.modified.push(id)
}
for (const id of Object.keys(oldScenes)) {
  if (!newScenes[id]) diff.deleted.push(id)
}

for (const key of Object.keys({ ...oldGlobal, ...newGlobal })) {
  if (JSON.stringify(oldGlobal[key]) !== JSON.stringify(newGlobal[key])) {
    diff.globalFields.push(key)
  }
}
```

同时检测全局配置变更（startScene、title 等），记录到 `globalFields`。

### 角标视觉效果

Vue Flow 自定义节点模板（SceneGraph.vue 已有 `CustomNode` 组件）：

```
  ┌──────────────┐
  │   ● NEW      │  ← 绿色角标 (纯 CSS 色块)
  │ scene_A      │
  └──────────────┘

  ┌──────────────┐
  │   ● MOD      │  ← 橙色角标
  │ scene_B      │
  └──────────────┘
```

CSS 实现：在节点元素右上角添加绝对定位的 `<span class="diff-badge">`。

### 全局配置变更标记

当 `aiChanges.globalFields` 非空时，在 `NodeEditor` 的"全局配置"标题区展示：

```
┌─────────────────────────────┐
│  全局配置  ● 已修改 3 字段   │  ← 橙色圆点 + 变更字段数
│  ╭──────────────────╮       │
│  │ title: "xxx"      │       │  ← 鼠标 hover 时弹出 tooltip
│  │ startScene: "yyy" │       │    列出具体变更的字段名
│  │ assetBase: "zzz"  │       │
│  ╰──────────────────╯       │
│  {                          │
│   "title": "xxx",     ← 正常 JSON textarea 不变
│   ...                       │
└─────────────────────────────┘
```

**不变更项占位**：用逗号分隔字段名，如 `已修改 3 字段`；hover 列出所有字段名。

**清除**：用户 blur JSON textarea 后标记消失（手工编辑即为最终确认）。

### 标记清除时机

| 条件 | 行为 |
|------|------|
| 用户手动编辑任意节点后 blur 保存 | 清除 `aiChanges`，所有标记消失 |
| 用户手动编辑全局配置后 blur 保存 | 清除全局配置标记 |
| AIPanel 点击"清除高亮"按钮 | 手动清除所有标记 |
| 新一轮 AI 修改 | 覆盖旧 diff（新 diff 替换旧 diff） |

### 涉及文件

| 文件 | 改动 |
|------|------|
| `editor/stores/editorStore.ts` | 新增 `aiChanges` ref、diff 计算逻辑、标记清除 |
| `editor/components/SceneGraph.vue` | watch `aiChanges`、自定义节点模板加角标 |
| `editor/components/NodeEditor.vue` | 全局配置标题加变更提示 |

---

## 三、完整交互时序

```
用户输入需求 "添加一个新场景 scene_battle"
     │
     ▼
AIPanel.send()
  ① saveVersion("AI 修改前")              ← 快照
  ② preGameData = clone gameData            ← 记下旧值
  ③ sendAIRequest(buildMessage(msg), ...)
     │
     ▼
opencode 读文件 → 新增 scene_battle → 写盘 → 回复 "已创建 scene_battle"
     │
     ▼
AIPanel 收到 result
  ④ 聊天显示 result
  ⑤ store.reloadFromDisk()
       ├─ fetch → 新 gameData
        ├─ diff(oldGameData, newGameData) → aiChanges = { added: ["scene_battle"], modified: [], deleted: [], globalFields: [] }
       ├─ gameData = newGameData
       └─ selectedNodeId = null
     │
     ▼
SceneGraph 检测到 aiChanges 非空
  → 新增节点 scene_battle 渲染时显示绿色 "NEW" 角标
  → 已有节点无角标
     │
     ▼
用户手动编辑任一节点后 blur
  → store.clearAIMarkers()
  → 所有角标消失
```

---

## 四、改动清单

### editor/db/editorDB.ts（新建）

| API | 说明 |
|-----|------|
| `initDB()` | 打开/创建 `editor_versions` 数据库 |
| `putVersion(v)` | 存入版本快照 |
| `getVersions(sourcePath)` | 获取某文件所有版本（按时间倒序，最多 20） |
| `clearVersions(sourcePath)` | 清除某文件的版本（切换文件/删除旧版本时） |

### editor/stores/editorStore.ts

| 新增 | 说明 |
|------|------|
| `versions: Ref<EditorVersion[]>` | 当前文件的所有历史版本 |
| `aiChanges: Ref<AIDiff>` | 上次 AI 修改的 diff 结果 |
| `saveVersion(label)` | 深拷贝 gameData → 存 IndexedDB → 更新 versions |
| `restoreVersion(idx)` | gameData = versions[idx].gameData → autoSave |
| `reloadFromDisk()` 增强 | diff 计算 + 设置 aiChanges |
| `clearAIMarkers()` | aiChanges = null |
| `loadVersions()` | 加载当前 sourcePath 的版本列表 |

### editor/components/SceneGraph.vue

| 新增 | 说明 |
|------|------|
| `watch(aiChanges)` | 重新渲染时按 aiChanges 给节点加 badge |
| CSS `.diff-badge` / `.diff-badge-added` / `.diff-badge-modified` / `.diff-badge-deleted` | 角标样式 |

### editor/components/AIPanel.vue

| 新增 | 说明 |
|------|------|
| 版本下拉 UI | `<select v-model="selectedVersion">` + 恢复按钮 |
| `send()` 前调 `saveVersion("AI 修改前")` | 在 `loading = true` 前 |
| "清除高亮" 按钮 | 调用 `store.clearAIMarkers()` |

### editor/components/NodeEditor.vue

| 新增 | 说明 |
|------|------|
| 全局配置标题加标记 | `全局配置 ● 已修改 N 字段` + hover tooltip 显示字段名，当 `aiChanges.globalFields` 非空 |
| `onBlur()` 时调 `clearAIMarkers()` | 编辑后清除标记 |

---

## 五、非侵入性保证

| 项 | 保障 |
|----|------|
| IndexedDB 故障容忍 | try/catch 包裹所有操作，失败不影响主流程 |
| 版本存储增长 | 20 个上限 + 切换文件时清空旧文件的版本 |
| SceneGraph 性能 | diff 比较仅用 `Object.keys` + `JSON.stringify`，百级节点 < 2ms |
| 不影响手动编辑 | `clearAIMarkers()` 在 blur 时调用，角标不留存 |
| 不影响 Code 模式 | 仅在 JSON 模式 `reloadFromDisk()` 时做 diff |
| 不使用 emoji | CSS 纯色圆点 + 文字实现角标 |

---

## 六、测试用例

### V1：版本自动保存
| 步骤 | 预期 |
|------|------|
| 编辑器中加载 demo.json | 自动创建版本 "初始版本" |
| 打开 AIPanel，JSON 模式发送"添加场景" | 自动创建版本 "AI 修改前" |
| 打开版本下拉 | 显示两个版本 |

### V2：版本恢复
| 步骤 | 预期 |
|------|------|
| AI 修改后选择 "AI 修改前" 版本 | 点击恢复 |
| 编辑器内容回到修改前 | SceneGraph + NodeEditor 刷新为旧内容 |

### V3：修改标记
| 步骤 | 预期 |
|------|------|
| AI 新增 scene_battle | SceneGraph 中该节点显示绿色 "NEW" 角标 |
| AI 修改 scene_start | 该节点显示橙色 "MOD" 角标 |
| AI 修改 title + startScene | NodeEditor 全局配置标题显示 "● 已修改 2 字段"，hover 列出 title/startScene |
| 用户手动编辑任一节点后 blur | 所有角标消失 |
| 用户手动编辑全局配置后 blur | 全局配置标记消失 |

### V4：非侵入
| 步骤 | 预期 |
|------|------|
| 不通过 AI 直接手动编辑 JSON | 不触发版本保存，不显示角标 |
| 浏览器不支持 IndexedDB | 版本功能静默失效，不影响编辑 |
