diff --git a/docs/E18_VERSION_AND_DIFF_PROPOSAL.md b/docs/E18_VERSION_AND_DIFF_PROPOSAL.md new file mode 100644 index 0000000..142ed7c --- /dev/null +++ b/docs/E18_VERSION_AND_DIFF_PROPOSAL.md @@ -0,0 +1,336 @@ +# 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 + +// 获取某文件的所有版本(按时间倒序) +getVersions(sourcePath: string): Promise + +// 清除某文件的所有版本(切换文件时) +clearVersions(sourcePath: string): Promise +``` + +### 版本 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 实现:在节点元素右上角添加绝对定位的 ``。 + +### 全局配置变更标记 + +当 `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` | 当前文件的所有历史版本 | +| `aiChanges: Ref` | 上次 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 | `