11 KiB
11 KiB
E18: 版本回滚 & AI 修改标记
概述
为编辑器的 AI 助手增加两个生产级功能:
- 版本回滚 — AI 修改前自动存档,错误时可恢复到任意历史版本
- 修改标记 — 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() |
版本数据结构
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
// 保存版本
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 后刷新全部
└──────────────────────┘
恢复后逻辑:
gameData = version.gameDataautoSave()写回磁盘selectedNodeId = null(重置选中)- AIPanel 聊天区显示 "已恢复到 'AI 修改前'"
- 不清除该版本快照(用户可能想在不同版本间多次切换)
saveVersion 触发时机
| 时机 | 标签 |
|---|---|
AI 模式 send() 调用前(reloadFromDisk 之前) |
"AI 修改前" |
App.vue 首次加载 demo.json 后 |
"初始版本" |
不自动在手动编辑时保存版本(用户手动点"保存版本"按钮即可,可选后续添加)。
二、AI 修改标记
流程
reloadFromDisk() 前:
记下 preGameData = gameData(深拷贝)
reloadFromDisk() 后:
diff = computeDiff(preGameData, newGameData)
aiChanges = { added, modified, deleted }
SceneGraph:
渲染时根据 aiChanges 给对应节点加角标
数据结构
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 内)
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 | 版本功能静默失效,不影响编辑 |