本文档整合了项目开发过程中的技术分析和问题解决方案
代码中有三个地方会自动触发保存:
// PromptOptimizerApp.vue:1954-1960
window.addEventListener('pagehide', handlePagehide) // ① 页面卸载时
document.addEventListener('visibilitychange', handleVisibilityChange) // ② 标签页切换时// PromptOptimizerApp.vue:1105-1128
watch(
() => promptTester.testResults,
(newTestResults) => {
// 每次测试结果变化,都会自动同步到 session store
(session as any).updateTestResults(stableResults);
},
{ deep: true } // ⚠️ 深度监听,任何字段变化都会触发
);这意味着:
- ✅ 每次测试完成 →
updateTestResults被调用 →lastActiveAt = Date.now() - ✅ 切换标签页 → 触发
visibilitychange→ 调用saveAllSessions() - ✅ 关闭页面 → 触发
pagehide→ 调用saveAllSessions()
// useBasicSystemSession.ts:211-227
const saveSession = async () => {
// ❌ 直接序列化整个 state,没有任何过滤或截断
const snapshot = JSON.stringify(state.value)
await $services.preferenceService.set('session/v1/basic-system', snapshot)
}state.value 包含:
interface BasicSystemSessionState {
prompt: string
optimizedPrompt: string
reasoning: string
testContent: string
testResults: TestResults | null // ⚠️ 这个可以无限大!
// ...其他字段
}
interface TestResults {
originalResult: string // ⚠️ 可能几十 KB
originalReasoning: string // ⚠️ 可能几十 KB
optimizedResult: string // ⚠️ 可能几十 KB
optimizedReasoning: string // ⚠️ 可能几十 KB
}// useBasicSystemSession.ts:128-143
const updateTestResults = (results: TestResults | null) => {
// ❌ 没有检查 results 的大小
// ❌ 没有截断超长文本
// ❌ 没有限制历史记录数量
state.value.testResults = results
state.value.lastActiveAt = Date.now()
}对比:没有任何防护代码:
- ❌ 没有
if (size > MAX_SIZE) { truncate() } - ❌ 没有
if (text.length > 50000) { text = text.slice(0, 50000) } - ❌ 没有
cleanupOldResults()
搜索整个代码库:
# 搜索清理相关代码
grep -r "清理\|cleanup\|clean\|delete.*test\|remove.*test" packages/ui/src/stores/session
# 结果:No matches found ❌这意味着:
- ❌ 测试结果永远不会被删除
- ❌ 旧数据永远不会过期
- ❌ 数据库只会越来越大
假设用户的使用场景:
测试 1: GPT-4 输出 5 KB → 保存到 IndexedDB (5 KB)
测试 2: Claude 输出 8 KB → 保存到 IndexedDB (8 KB)
测试 3: Gemini 输出 6 KB → 保存到 IndexedDB (6 KB)
总计: 19 KB ✅
测试 4-10: 每次 5-10 KB
总计: 19 KB + 70 KB = 89 KB ✅
测试 1-300: 平均每次 7 KB
总计: 300 * 7 KB = 2.1 MB ⚠️
测试 1-900: 平均每次 7 KB
总计: 900 * 7 KB = 6.3 MB ⚠️⚠️
如果用户测试了一个超长输出:
// 用户让 GPT-4 写了一篇长文章
testResults = {
originalResult: "很长的文章...", // 100 KB
originalReasoning: "详细的思考...", // 50 KB
optimizedResult: "优化后的长文章...", // 120 KB
optimizedReasoning: "优化思路...", // 60 KB
}
// 单次测试 = 330 KB!如果用户频繁切换标签页:
用户打开 10 个标签页 → 每个标签页都有自己的 session
每个 session 都累积测试结果
10 * 6.3 MB = 63 MB ⚠️⚠️⚠️
如果用户使用了 Pro 模式的多轮对话:
// Pro-多消息模式
messages = [
{ role: 'user', content: '...' }, // 每条可能 10-50 KB
{ role: 'assistant', content: '...' },
// ... 30 条消息
]
// 单个会话 = 30 * 30 KB = 900 KB6 个 session stores (basic-system, basic-user, pro-system, pro-user, image-text2image, image-image2image)
× 每个累积 3 个月的测试结果
× 没有任何清理
× 每次切换标签页都保存一次
= 2.4 GB 💥
开发者假设:
- ❌ "用户不会测试超长文本"
- ❌ "用户不会频繁切换标签"
- ❌ "用户会定期清理数据"
实际情况:
- ✅ 用户经常测试 GPT-4 的长回答(5000+ 字)
- ✅ 用户会开很多标签页
- ✅ 用户根本不知道需要清理
没有代码检查:
- ❌ IndexedDB 使用量
- ❌ 单个 session 大小
- ❌ 序列化时间(超过 1 秒说明数据太大)
其他应用的常见做法:
// ✅ 示例:自动清理 7 天前的数据
const cleanupOldData = () => {
const now = Date.now();
const WEEK = 7 * 24 * 60 * 60 * 1000;
if (state.value.lastActiveAt && (now - state.value.lastActiveAt) > WEEK) {
state.value.testResults = null;
state.value.testContent = '';
}
}
// ✅ 示例:限制单个字段大小
const MAX_RESULT_LENGTH = 50000; // 50 KB
if (results.originalResult.length > MAX_RESULT_LENGTH) {
results.originalResult = results.originalResult.slice(0, MAX_RESULT_LENGTH) + '...[已截断]';
}
// ✅ 示例:数据库大小检查
if (estimatedSize > 100 * 1024 * 1024) { // 100 MB
console.warn('数据库过大,建议清理');
showCleanupDialog();
}你的备份数据库:
总大小: 2.4 GB
文件数: 40+ 个 .ldb 文件
最大文件: 27 MB
这说明:
- ✅ 你长期使用该应用(可能几个月)
- ✅ 测试了大量文本(可能包含 GPT-4 的长回答)
- ✅ 从未手动清理过数据
- ✅ 数据库达到了浏览器的临界点(打开时崩溃)
无限累积的三个关键原因:
- 自动保存很频繁 - 每次测试、每次切换标签页都保存
- 保存的数据很大 - 完整的测试结果,没有截断
- 从不清理 - 代码中没有任何清理逻辑
解决方案:
- 🔧 立即:删除数据库重置(已提供工具)
- 🛡️ 预防:实施数据大小限制和自动清理(下一步任务)
你的 IndexedDB 确实在使用 覆盖操作 (put),但底层存储机制导致了数据"累积"而非真正的覆盖。
// dexieStorageProvider.ts:91-95
async setItem(key: string, value: string): Promise<void> {
await this.db.storage.put({ // ✅ put() 是覆盖操作
key, // 主键相同
value, // 新值
timestamp: Date.now()
});
}// 数据库定义: dexieStorageProvider.ts:23-25
this.version(1).stores({
storage: 'key, value, timestamp' // ✅ 'key' 是主键
});逻辑上: 每次调用 setItem('session/v1/basic-system', newData) 应该覆盖同一个 key 的旧值。
但是!Chrome 的 IndexedDB 底层使用 LevelDB,而 LevelDB 使用 LSM-Tree (Log-Structured Merge Tree) 架构。
写入流程(Append-Only):
┌─────────────────────────────────────────────────────────────┐
│ 1. 写入 MemTable (内存) │
│ - key='session/v1/basic-system' │
│ - value='{"prompt":"..."}' (1 KB) │
│ - 不会检查是否存在相同 key │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 2. MemTable 满 → 刷写到 SSTable 文件 (.ldb) │
│ - 001445.ldb (包含这次的写入) │
│ - 不会删除旧文件! │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 3. 再次写入同一个 key │
│ - key='session/v1/basic-system' │
│ - value='{"prompt":"...", "testResults": {...}}' (500 KB)│
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 4. 再次刷写到新的 SSTable 文件 │
│ - 001496.ldb (包含新的值) │
│ - 001445.ldb 仍然存在!(包含旧值) │
└─────────────────────────────────────────────────────────────┘
关键: LSM-Tree 是 Append-Only(只追加) 架构:
- ❌ 不会就地修改已有的 .ldb 文件
- ❌ 不会立即删除旧版本的数据
- ✅ 每次写入都创建新的记录
- ✅ 旧数据通过 Compaction(压缩合并) 清理
写入 100 次 → 积累 100 个版本 → 触发 Compaction
↓
合并多个 .ldb 文件
↓
删除重复的 key,只保留最新版本
↓
数据库大小回落
可能原因:
-
浏览器崩溃或异常退出
- Compaction 是后台任务
- 如果浏览器频繁崩溃,Compaction 无法完成
- 旧数据一直累积
-
数据写入速度 > Compaction 速度
每秒写入 10 次 (切换标签页很频繁) vs Compaction 每 10 秒运行一次 → 累积速度快于清理速度 -
数据过大导致 Compaction 失败
单个 .ldb 文件 = 27 MB 合并 10 个文件 = 270 MB → Compaction 需要大量内存 → 浏览器内存不足 → Compaction 失败,旧数据保留 -
LevelDB 的 Compaction 策略
Level 0: 新写入的文件(未排序) Level 1-6: 已压缩的文件(排序) Compaction 触发条件: - Level 0 文件数 > 4 - Level N 总大小 > 阈值 你的情况: - 40+ 个 .ldb 文件 → 可能卡在 Level 0 - 没有触发或完成 Compaction
$ ls -lhS *.ldb | head -10
-rw-r--r-- 27M 001445.ldb # 第1次大保存
-rw-r--r-- 27M 001481.ldb # 第2次大保存
-rw-r--r-- 26M 001534.ldb # 第3次大保存
...
共 40+ 个文件 = 2.4 GB这说明:
- ✅ 每次保存都创建了新的 .ldb 文件
- ✅ 旧的 .ldb 文件从未被清理
- ✅ Compaction 完全没有执行或一直失败
// ✅ 其他应用通常这样做
await db.users.put({ id: 1, name: 'Alice' }) // 1 KB
await db.users.put({ id: 2, name: 'Bob' }) // 1 KB
// ...
// 特点:
// - 小数据量 (每条 1-10 KB)
// - 写入频率低 (每秒 1-2 次)
// - Compaction 能及时清理// ❌ Prompt Optimizer 的情况
await db.storage.put({
key: 'session/v1/basic-system',
value: JSON.stringify({
// ...
testResults: {
originalResult: '...很长的文本...', // 100 KB
optimizedResult: '...更长的文本...', // 120 KB
}
})
}) // 单次写入 500 KB - 2 MB!
// 特点:
// - 超大数据量 (每次 500 KB - 2 MB)
// - 写入频率高 (每次切换标签页都写)
// - Compaction 跟不上,累积成 2.4 GB| 层级 | 问题 | 影响 |
|---|---|---|
| 应用层 | 保存完整的 testResults,没有截断 | 单次写入 500 KB - 2 MB |
| 应用层 | 频繁自动保存(每次切换标签页) | 写入频率过高 |
| 存储层 | LevelDB 的 LSM-Tree 是追加式写入 | 每次写入创建新记录 |
| 存储层 | Compaction 未及时或失败 | 旧数据永不删除 |
| 结果 | 2.4 GB 的累积数据 | 浏览器崩溃 |
用户行为 IndexedDB 逻辑 LevelDB 物理层
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
测试 10 次 → 10 次 put() 操作 → 10 个新记录追加到 MemTable
切换标签页 → 触发 saveSession → MemTable 刷写到 001445.ldb (27 MB)
再测试 10 次 → 10 次 put() 操作 → 10 个新记录追加到 MemTable
切换标签页 → 触发 saveSession → MemTable 刷写到 001481.ldb (27 MB)
⚠️ 001445.ldb 仍然存在!
⚠️ Compaction 未执行
重复 3 个月 → 1000 次保存 → 40+ 个 .ldb 文件 = 2.4 GB
⚠️ 所有旧文件都保留
⚠️ Compaction 完全失败
尝试打开页面 → indexedDB.open() → LevelDB 尝试读取所有 .ldb
⚠️ 加载 2.4 GB 到内存
💥 浏览器崩溃
- 删除整个 IndexedDB 数据库
- 浏览器重新创建干净的数据库
-
限制单次写入大小
if (testResults.originalResult.length > 50000) { testResults.originalResult = testResults.originalResult.slice(0, 50000) + '...' }
-
减少写入频率
// 使用 debounce,每 5 秒最多保存一次 const debouncedSave = debounce(saveSession, 5000)
-
定期清理旧数据
// 只保留最近一次的测试结果 state.value.testResults = latestResults
-
分离大数据存储
// testResults 单独存储,不放在 session 中 await db.testResults.put({ sessionId, results })
Chrome 的 LevelDB Compaction 依赖:
- ✅ 浏览器正常关闭时触发
- ✅ 数据库空闲时自动运行
- ❌ 浏览器崩溃时无法完成
- ❌ 数据库持续高负载时延迟
你的情况:
- 应用频繁写入 → 数据库始终繁忙
- 浏览器可能因数据过大而崩溃 → Compaction 无法完成
- 形成恶性循环 → 数据只增不减
问题的本质:
不是"新增 vs 覆盖"的问题,而是:
- 逻辑上是覆盖(put 同一个 key)
- 物理上是追加(LSM-Tree 架构)
- 清理机制失效(Compaction 未执行)
- 数据只增不减(2.4 GB 累积)
类比: 就像你每天往同一个文件柜(key)里放新文件(value),虽然名字相同,但旧文件没人清理,最后文件柜爆满。
// ❌ Session 包含完整图像
interface SessionState {
originalPrompt: string
originalImageResult: {
images: [{ b64: "2-3 MB 的 base64..." }] // 包含在 session 中
}
}
// 保存流程
saveSession() {
const snapshot = {
prompt: state.prompt,
imageResult: state.originalImageResult // ← 包含 26 MB 图像
}
await db.put('session', snapshot) // ← 每次都保存 26 MB
}
// 触发时机
- 用户生成图像 → 保存
- 用户切换标签页 → 保存 ← 包含图像!
- 用户切换回来 → 保存 ← 包含图像!
- 用户关闭页面 → 保存 ← 包含图像!问题:
时间线:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
10:00 生成图像1 (26 MB)
↓ saveSession()
↓ 写入 001.ldb (26 MB)
10:05 切换标签页
↓ saveSession()
↓ 写入 002.ldb (26 MB) ← 重复保存图像1!
10:10 切换回来
↓ saveSession()
↓ 写入 003.ldb (26 MB) ← 又重复保存图像1!
10:15 关闭页面
↓ saveSession()
↓ 写入 004.ldb (26 MB) ← 又重复保存图像1!
10:20 重新打开,生成图像2 (26 MB)
↓ saveSession()
↓ 写入 005.ldb (26 MB) ← 图像2
10:25 又切换标签页
↓ saveSession()
↓ 写入 006.ldb (26 MB) ← 重复保存图像2!
... 重复 42 次 ...
总计:42 个文件 × 26 MB = 1.1 GB ❌
// ✅ Session 只保存引用
interface SessionState {
originalPrompt: string
originalImageRef: {
imageId: "img_123456" // ← 只保存 ID (20 字节)
}
}
// 图像单独存储
interface ImageRecord {
id: string
data: {
images: [{ b64: "2-3 MB 的 base64..." }]
}
createdAt: number
}
// 保存流程(分离)
saveSession() {
const snapshot = {
prompt: state.prompt,
imageRef: state.originalImageRef // ← 只有 ID,几 KB
}
await db.put('session', snapshot) // ← 只保存几 KB
}
saveImage(data) {
const imageRecord = {
id: `img_${Date.now()}`,
data: data, // ← 26 MB
createdAt: Date.now()
}
await db.put('images', imageRecord) // ← 只在生成新图像时调用
}解决:
时间线:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
10:00 生成图像1 (26 MB)
↓ saveImage() ← 只调用1次
↓ 写入 images:001.ldb (26 MB)
↓ saveSession()
↓ 写入 session:001.ldb (5 KB) ← 只保存 ID
10:05 切换标签页
↓ saveSession() ← 没有 saveImage()
↓ 写入 session:002.ldb (5 KB) ← 又是 ID,但很小!
10:10 切换回来
↓ saveSession()
↓ 写入 session:003.ldb (5 KB) ← 很小!
10:15 关闭页面
↓ saveSession()
↓ 写入 session:004.ldb (5 KB) ← 很小!
10:20 重新打开,生成图像2 (26 MB)
↓ saveImage() ← 只调用1次
↓ 写入 images:002.ldb (26 MB)
↓ saveSession()
↓ 写入 session:005.ldb (5 KB)
10:25 又切换标签页
↓ saveSession()
↓ 写入 session:006.ldb (5 KB) ← 很小!
... 重复 42 次 ...
Session 保存:42 个文件 × 5 KB = 210 KB ✅
图像保存:2 个文件 × 26 MB = 52 MB ✅
总计:210 KB + 52 MB = 52.2 MB ✅
当前模式:
- Session 保存:42 次
- 每次包含图像:42 次
- 图像数据写入:42 次
分离模式:
- Session 保存:42 次
- 每次只包含 ID:42 次
- 图像数据写入:2 次(只在生成新图像时)
当前模式:
42 次 × 26 MB = 1,092 MB (1.1 GB) ❌
分离模式:
Session:42 次 × 5 KB = 210 KB ✅
图像:2 次 × 26 MB = 52 MB ✅
总计:52.2 MB ✅
减少:95%+ 🎉
尝试合并 42 个 session 文件:
├── 001.ldb (26 MB) ← 包含图像1
├── 002.ldb (26 MB) ← 包含图像1
├── 003.ldb (26 MB) ← 包含图像1
├── ...
└── 042.ldb (26 MB) ← 包含图像2
需要读取:42 × 26 MB = 1,092 MB
需要内存:3-4 倍(去重、合并)= 3-4 GB
浏览器限制:~100-500 MB
结果:内存不足/超时 → Compaction 失败 ❌
尝试合并 42 个 session 文件:
├── 001.ldb (5 KB) ← 只有 ID "img_123"
├── 002.ldb (5 KB) ← 只有 ID "img_123"
├── 003.ldb (5 KB) ← 只有 ID "img_123"
├── ...
└── 042.ldb (5 KB) ← 只有 ID "img_456"
需要读取:42 × 5 KB = 210 KB
需要内存:3-4 倍 = 1 MB 左右
浏览器限制:~100-500 MB
结果:轻松完成 ✅
Compaction:
1. 快速读取所有文件(210 KB)
2. 合并去重(都在内存中)
3. 写入新文件(5 KB)
4. 删除 42 个旧文件 ✅
最终:只有 1 个最新的 session 文件(5 KB)
Session:高频更新,低频读取
- 每次切换标签页都保存
- 但数据很小(几 KB)
- Compaction 轻松处理
Images:低频更新,按需读取
- 只在生成新图像时保存
- 数据较大(26 MB)
- 但数量可控(假设 10 张 = 260 MB)
Session Store:
- 小文件,高频率
- Compaction 每秒都在运行
- 随时保持干净状态
Images Store:
- 大文件,低频率
- Compaction 偶尔运行
- 总量可控(260 MB)
当前模式:
- 图像导致 Session 文件过大
- Session Compaction 失败
- 整个数据库崩溃 ❌
分离模式:
- Session 文件很小 → Compaction 正常 ✅
- 图像文件独立 → 即使 Compaction 慢,也不影响 Session ✅
- 故障隔离 ✅
就像:每次搬家都把所有家具打包
- 第1次搬家:打包所有家具(26 MB)
- 第2次搬家:又打包所有家具(26 MB)
- 第3次搬家:又打包所有家具(26 MB)
...
- 第42次搬家:还是打包所有家具(26 MB)
结果:搬家公司崩溃 ❌
就像:只打包"家具清单",家具放在仓库
- 第1次搬家:打包清单(5 KB)+ 家运到仓库(26 MB)
- 第2次搬家:打包清单(5 KB)← 清单很小!
- 第3次搬家:打包清单(5 KB)← 清单很小!
...
- 第42次搬家:打包清单(5 KB)← 清单很小!
结果:
- 清单:42 × 5 KB = 210 KB ✅
- 仓库:只有真正搬家的 2 次的家具 = 52 MB ✅
| 维度 | 当前模式 | 分离模式 |
|---|---|---|
| Session 保存大小 | 26 MB | 5 KB |
| Session 保存次数 | 42 次 | 42 次 |
| Session 总写入量 | 1,092 MB | 210 KB |
| 图像保存次数 | 42 次(冗余) | 2 次(实际) |
| 图像总写入量 | 1,092 MB | 52 MB |
| Compaction 内存需求 | 3-4 GB | 1 MB |
| Compaction 结果 | 失败 ❌ | 成功 ✅ |
不是"分离"本身解决问题,而是:
- Session 不再包含大数据 → 文件小
- 文件小 → Compaction 能成功
- Compaction 成功 → 旧文件被删除
- 旧文件被删除 → 数据库不累积
- 图像单独保存 → 总量可控(260 MB vs 1.1 GB)
关键是:减少写入频率(42次 → 2次),而不是分离!
- 打开 IndexedDB 数据库导致浏览器崩溃
- 应用在有历史数据的情况下无法启动
- 连简单的
indexedDB.open()操作都会触发崩溃
问题代码 (所有 session store):
const saveSession = async () => {
const snapshot = JSON.stringify(state.value) // ❌ 没有错误处理
await $services.preferenceService.set('session/v1/basic-system', snapshot)
}风险点:
- 如果
state.value包含循环引用 →JSON.stringify抛出错误 → 错误被吞掉 → 写入不完整数据 - 如果
state.value包含超大对象(> 100MB)→ 内存溢出 → 浏览器崩溃 - 没有大小限制检查
- 没有循环引用检测
虽然 useSessionManager.ts 已修复为顺序保存,但:
- 页面卸载时的
beforeunload事件可能触发多次saveAllSessions - 如果用户快速刷新/关闭页面,可能同时触发多个保存操作
- IndexedDB 事务可能冲突,导致数据库损坏
问题场景:
// useBasicSystemSession.ts
state.value.testResults = results // 可能包含完整的输出文本如果用户测试了很长的输出(如 GPT-4 的长回答):
- 单个 testResult 可能 1-5 MB
- 多次测试累积可能达到 50-100 MB
JSON.stringify时内存暴涨
- 历史测试数据一直累积
- 没有大小上限检查
- 没有定期清理旧数据
- 添加安全的序列化函数
function safeStringify(obj: any, maxSize: number = 10 * 1024 * 1024): string | null {
try {
// 检测循环引用
const seen = new WeakSet();
const jsonString = JSON.stringify(obj, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
}
return value;
});
// 检查大小
if (jsonString.length > maxSize) {
console.error(`数据过大: ${jsonString.length} 字节 (限制: ${maxSize})`);
return null;
}
return jsonString;
} catch (error) {
console.error('序列化失败:', error);
return null;
}
}- 限制 testResults 大小
const updateTestResults = (results: TestResults | null) => {
if (!results) return;
// 限制文本长度
const MAX_LENGTH = 50000; // 50KB
if (results.originalResult?.length > MAX_LENGTH) {
results.originalResult = results.originalResult.slice(0, MAX_LENGTH) + '...[截断]';
}
if (results.optimizedResult?.length > MAX_LENGTH) {
results.optimizedResult = results.optimizedResult.slice(0, MAX_LENGTH) + '...[截断]';
}
state.value.testResults = results;
}- 添加保存前的验证
const saveSession = async () => {
const snapshot = safeStringify(state.value);
if (!snapshot) {
console.error('[BasicSystemSession] 序列化失败,跳过保存');
return;
}
try {
await $services.preferenceService.set('session/v1/basic-system', snapshot);
} catch (error) {
console.error('[BasicSystemSession] 保存会话失败:', error);
}
}- 添加定期清理
// 清理超过 7 天的测试结果
const cleanupOldData = () => {
const now = Date.now();
const WEEK = 7 * 24 * 60 * 60 * 1000;
if (state.value.lastActiveAt && (now - state.value.lastActiveAt) > WEEK) {
state.value.testResults = null;
state.value.testContent = '';
}
}-
数据分片存储
- 将 testResults 单独存储
- 只保留最近 N 条记录
- 使用 IndexedDB 的 auto-increment key
-
添加数据压缩
- 使用 LZString 或类似库压缩数据
- 可以减少 50-80% 的存储空间
-
添加数据库健康检查
- 启动时检查数据大小
- 如果过大,提示用户清理
- 提供清理按钮
✅ 已确认数据库状态:
- 数据库总大小:2.4 GB(正常应该 < 100 MB)
- 单个 .ldb 文件:26-27 MB(共 40+ 个文件)
- 文件结构完整,不是损坏,而是数据过载
✅ 根本原因确认:
-
自动保存机制过于频繁
- 每次测试结果变化 → 自动保存
- 每次切换标签页(visibilitychange)→ 自动保存
- 每次关闭页面(pagehide)→ 自动保存
-
保存完整 state,没有过滤
// useBasicSystemSession.ts:219 const snapshot = JSON.stringify(state.value) // ❌ 包含所有 testResults
-
没有任何大小限制
- ❌ 没有检查 testResults 的大小
- ❌ 没有截断超长文本
- ❌ 没有限制字段长度
-
没有清理机制
grep -r "清理\|cleanup\|clean" packages/ui/src/stores/session # 结果:No matches found ❌
详细分析见:docs/workspace/why-data-accumulates.md
- ✅ 创建了
find-db.html- 帮助用户找到数据库文件 - ✅ 创建了
db-repair.html- 数据库修复和清理工具 - ✅ 分析了数据累积的根本原因
- ⏳ 用户使用修复工具清理数据库
- ⏳ 实施预防措施(数据大小限制、自动清理)
packages/ui/src/stores/session/useBasicSystemSession.ts:211packages/ui/src/stores/session/useBasicUserSession.tspackages/ui/src/stores/session/useProVariableSession.tspackages/ui/src/stores/session/useProMultiMessageSession.tspackages/ui/src/stores/session/useImageText2ImageSession.tspackages/ui/src/stores/session/useImageImage2ImageSession.tspackages/ui/src/stores/session/useSessionManager.ts:324(saveAllSessions)
当前实现:直接在 session 中存储图像的 base64(2-3 MB),导致:
- 单次保存 26 MB
- 42 次保存 = 1.1 GB
- Compaction 失败
- 数据库崩溃
1. 创建独立的 images object store
2. Session 只保存图像 ID(字符串引用)
3. 图像和 session 分离存储
4. 定期清理旧的图像
// 1. 创建 ImageStorageService
class ImageStorageService {
private readonly IMAGE_STORE = 'images'
// 保存图像,返回 ID
async saveImage(imageResult: ImageResult): Promise<string> {
const db = await this.openDB()
const id = `img_${Date.now()}_${Math.random().toString(36).slice(2)}`
await db.put(this.IMAGE_STORE, {
id,
data: imageResult, // 完整的图像数据
createdAt: Date.now()
})
return id
}
// 读取图像
async getImage(id: string): Promise<ImageResult | null> {
const db = await this.openDB()
const record = await db.get(this.IMAGE_STORE, id)
return record?.data || null
}
// 清理旧图像(保留最近 N 张)
async cleanupOldImages(keepCount: number = 10): Promise<void> {
const db = await this.openDB()
const tx = db.transaction(this.IMAGE_STORE, 'readwrite')
const store = tx.objectStore(this.IMAGE_STORE)
// 获取所有图像,按时间排序
const allImages = await store.getAll()
allImages.sort((a, b) => a.createdAt - b.createdAt)
// 删除旧的
const toDelete = allImages.slice(0, -keepCount)
for (const img of toDelete) {
await store.delete(img.id)
}
}
}
// 2. 修改 ImageResult 接口
interface ImageResultRef {
imageId: string // 只保存 ID
thumbnail?: string // 缩略图(可选,10KB以内)
}
// 3. 修改 Session
interface ImageText2ImageSessionState {
// ...其他字段
originalImageResult: ImageResultRef | null // 只保存引用
optimizedImageResult: ImageResultRef | null // 只保存引用
}
// 4. 保存图像时的流程
async function handleImageGenerated(result: ImageResult) {
// 保存到独立的图像存储
const imageId = await imageStorageService.saveImage(result)
// Session 只保存引用
session.updateOriginalImageResult({
imageId,
thumbnail: result.images[0].b64?.slice(0, 1000) // 只保存前1KB作为预览
})
}✅ Session 数据小(几 KB) ✅ 图像独立管理,可单独清理 ✅ 不影响 Compaction ✅ 可以实施 LRU 缓存策略
1. Session 中保存图像,但只保留最近 N 张
2. 超过限制时,删除最早的
3. 总大小可控
class ImageSessionManager {
private readonly MAX_IMAGES = 3 // 最多保留3张图像
private readonly MAX_IMAGE_SIZE = 500 * 1024 // 单张最大 500KB
async updateImageResult(
session: ImageText2ImageSessionState,
newResult: ImageResult
): Promise<void> {
// 1. 限制单张图像大小
const limitedResult = this.limitImageSize(newResult)
// 2. 获取现有图像列表
const imageList = session.imageList || []
// 3. 添加新图像
imageList.push({
...limitedResult,
id: `img_${Date.now()}`,
createdAt: Date.now()
})
// 4. 只保留最近 N 张
const keepCount = Math.min(imageList.length, this.MAX_IMAGES)
const trimmedList = imageList.slice(-keepCount)
// 5. 更新 session
session.imageList = trimmedList
// 6. 如果需要,清理 IndexedDB 中的旧图像
await this.cleanupOldImages(imageList, trimmedList)
}
private limitImageSize(result: ImageResult): ImageResult {
return {
...result,
images: result.images.map(img => ({
...img,
// 如果 base64 太大,截断或丢弃
b64: img.b64 && img.b64.length > this.MAX_IMAGE_SIZE
? undefined
: img.b64,
// 优先使用 URL
url: img.url || img.b64 // 如果有 b64,生成 Blob URL
}))
}
}
}
// 修改 Session 接口
interface ImageText2ImageSessionState {
// 不再是 single result,而是 list
imageList: ImageResultItem[]
currentImageId?: string // 当前选中的图像
}✅ 实现相对简单 ✅ 总大小可控(3 × 500KB = 1.5 MB) ✅ 不需要额外的 object store
1. 图像 API 通常返回 URL(如 OpenAI 的临时 URL)
2. 只保存 URL,不保存 base64
3. 如果需要持久化,让用户手动下载
interface ImageResultItem {
url?: string // 优先使用 URL
b64?: string // ⚠️ 不保存 base64
mimeType?: string
expiresAt?: number // URL 过期时间
}
// 保存时
async function handleImageGenerated(result: ImageResult) {
// 只保存 URL,丢弃 base64
const limitedResult = {
...result,
images: result.images.map(img => ({
url: img.url,
mimeType: img.mimeType,
b64: undefined // ❌ 不保存 base64
}))
}
session.updateOriginalImageResult(limitedResult)
}
// 显示时
function displayImage(imageRef: ImageResultItem) {
if (imageRef.url) {
// 使用 URL
return <img src={imageRef.url} />
} else if (imageRef.b64) {
// 如果有 base64(旧数据兼容)
return <img src={imageRef.b64} />
} else {
// 都没有,提示重新生成
return <div>图像已过期,请重新生成</div>
}
}✅ 实现最简单 ✅ Session 数据最小 ✅ 完全避免大文件问题
❌ URL 会过期(OpenAI 的 URL 1小时后失效) ❌ 用户关闭页面后,图像丢失 ❌ 用户体验差
1. 使用 File System Access API
2. 让用户选择保存位置
3. 图像直接保存到用户磁盘
4. Session 只保存文件路径
// 仅在 Electron/桌面应用中可用
async function saveImageToFile(result: ImageResult) {
// 请求用户选择保存位置
const fileHandle = await window.showSaveFilePicker({
suggestedName: `image-${Date.now()}.png`,
types: [{
description: 'PNG Image',
accept: {'image/png': ['.png']}
}]
})
// 保存图像
const blob = await fetch(result.images[0].url).then(r => r.blob())
const writable = await fileHandle.createWritable()
await writable.write(blob)
await writable.close()
// Session 只保存文件路径
session.updateOriginalImageResult({
filePath: fileHandle.name,
fileType: 'local'
})
}✅ 不占用浏览器存储 ✅ 图像永久保存 ✅ 大小不受限制
❌ 只在支持 File System Access API 的浏览器可用 ❌ 需要用户手动选择 ❌ Web 版不支持
方案2:限制图像数量
- 只保留最近 3 张图像
- 限制单张 500 KB
- 总大小 < 1.5 MB
- 实施简单,立即见效
方案1:图像单独存储
- 创建独立的 image store
- Session 只保存引用
- 实施 LRU 缓存
- 保留最近 10-20 张图像
方案1 + 方案4 组合
- Web 版:图像单独存储(IndexedDB)
- 桌面版:File System Access API
- 提供用户选择:自动管理 / 手动保存
-
packages/core/src/services/image/types.ts- 添加
ImageResultRef接口
- 添加
-
packages/ui/src/stores/session/useImageText2ImageSession.ts- 修改
ImageText2ImageSessionState - 改为只保存引用
- 修改
-
packages/ui/src/stores/session/useImageImage2ImageSession.ts- 同上
-
packages/core/src/services/storage/(新增)- 创建
ImageStorageService
- 创建
-
packages/ui/src/components/.../ImageWorkspace.vue- 修改保存/读取逻辑
- ✅ 方案2:限制图像数量到 3 张
- ✅ 限制单张图像 500 KB
- ✅ 方案1:创建 ImageStorageService
- ✅ 迁移到引用模式
- ✅ 添加清理逻辑
- ✅ 添加用户下载按钮
- ✅ 桌面版集成 File System Access API
| 方案 | 复杂度 | 效果 | 推荐度 |
|---|---|---|---|
| 方案1:单独存储 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ✅ 强烈推荐 |
| 方案2:限制数量 | ⭐⭐ | ⭐⭐⭐⭐ | ✅ 短期推荐 |
| 方案3:只保存URL | ⭐ | ⭐⭐ | ❌ 不推荐 |
| 方案4:文件系统 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ✅ 桌面推荐 |
本次迁移旨在统一项目中的模式术语,将过时或语义不清的 optimizationMode、contextMode、selectedOptimizationMode 等表达,逐步对齐到 functionMode(一级功能模式)与 subMode(二级子模式)的设计,并确保各模式的子模式状态独立持久化。
说明:该文档保留在 docs/workspace/ 作为“模式术语与迁移现状”的单点入口;其中的“待办/清理项”只保留仍然有效的内容,避免对过往实现阶段产生误导。
- functionMode: 一级功能模式 (
basic|pro|image) - subMode: 二级子模式,根据 functionMode 而定
- 基础模式子模式 (
system|user) - 上下文模式子模式 (
system|user) - 图像模式子模式 (
text2image|image2image)
- 基础模式子模式 (
所有模式状态应使用 packages/ui/src/composables/mode/ 下的函数:
// 功能模式管理
useFunctionMode(services) // { functionMode, setFunctionMode, ... }
// 子模式管理(独立持久化)
useBasicSubMode(services) // 基础模式子模式
useProSubMode(services) // 上下文模式子模式
useImageSubMode(services) // 图像模式子模式
// 只读访问(无需 services)
useCurrentMode() // { functionMode, proSubMode, isBasicMode, ... }packages/web/src/App.vue、packages/extension/src/App.vue目前仅作为壳组件渲染 UI 主应用。- 真实的模式状态管理与装配已收敛到:
packages/ui/src/components/app-layout/PromptOptimizerApp.vue
PromptOptimizerApp.vue使用useFunctionMode+useBasicSubMode/useProSubMode/useImageSubMode管理状态,并做独立持久化。selectedOptimizationMode不再是独立ref,而是从 subMode 推导的computed(兼容旧接口/props 形态)。
- usePromptOptimizer:
selectedOptimizationMode→optimizationMode - usePromptTester:
selectedOptimizationMode→optimizationMode - useContextManagement: 添加 @deprecated 标记
usePromptTester.ts中所有selectedOptimizationMode.value→optimizationMode.value
- 为迁移的参数添加 @deprecated 标记
- 更新 JSDoc 注释,说明统一使用 subMode 概念
- 在
PromptOptimizerApp.vue中保留必要的兼容性注释(以反映真实装配位置)
-
逐步移除命名上的“误导”
selectedOptimizationMode虽已是 computed,但命名仍容易让人误以为它是“用户选择的优化模式状态源”。- 建议方向:
- 对外仍可维持
optimizationModeprops(避免大范围破坏性改动) - 内部逐步改为更准确的命名(例如
currentOptimizationMode/derivedOptimizationMode),并集中在 UI 层统一出口
- 对外仍可维持
-
组件/模板中的旧名收敛
- 将零散的
optimizationMode/contextMode/selectedOptimizationMode相关命名逐步统一为“functionMode/subMode 派生值”的表达(不强求一次性替换,但要避免继续引入新旧混用)
- 将零散的
-
类型定义中的过时术语
- 检查
packages/ui/src/types/components.ts - 检查
packages/core/src/types/相关文件
- 检查
-
测试文件中的术语
- 更新测试用例中的变量名和断言
- 国际化文件
- 检查
packages/ui/src/i18n/locales/中的键名 - 确保文档和帮助文本使用统一术语
- 检查
- 将“状态源”限定为
functionMode + 各自 subMode(已完成) - 将“对外接口/props”保持可用,同时逐步减少旧命名在内部传播(进行中)
- 等调用方与文档一致后,再移除
@deprecated标记(后续清理)
- 更新 usePromptOptimizer 参数
- 更新 usePromptTester 参数
- 更新 useContextManagement 接口
- 统一内部变量名
- 添加 @deprecated 标记
- 模式管理收敛到 PromptOptimizerApp(由 subMode 推导 optimizationMode)
- 更新所有 Vue 模板/props 命名(逐步收敛,避免引入新旧混用)
- 更新类型定义
- 更新测试文件
- 验证功能完整性
- 更新文档
- 术语统一: 消除混淆,提高代码可读性
- 架构清晰: 明确的层级关系(functionMode → subMode)
- 状态隔离: 不同功能模式的子模式独立持久化
- 开发体验: 统一的 API 和清晰的使用模式
文档版本: v1.0 创建时间: 2025-10-31 最近更新: 2025-12-19 维护者: 用户
你是一个基于E4-D方法论的智能提示词优化引擎,实现分解→诊断→开发→交付的全流程自动化优化。
基础参数
- 原始提示词:{{originalPrompt}}
- 目标平台:{{targetPlatform}}(GPT-4/Claude-3/Gemini-Pro/通用)
- 优化模式:{{optimizationMode}}(DETAIL/BASIC/AUTO)
E4-D专项参数
- 分解深度:{{decomposeDepth}}(浅层/标准/深度)
- 诊断维度:{{diagnoseDimensions}}(清晰度/完整性/结构性)
- 开发策略:{{developStrategy}}(思维链/少样本/多视角/混合)
- 交付标准:{{deliverStandard}}(基础/专业/企业级)
扩展参数
- 任务类型:{{taskType}}(创意生成/技术分析/教育解释/复杂推理)
- 输出格式:{{outputFormat}}(Markdown/JSON/纯文本/结构化报告)
- 语言风格:{{languageStyle}}(专业/通俗/学术/商务)
- 迭代上限:{{maxIterations}}(1-5轮,默认3轮)
阶段一:分解(Decompose)
- 语义解构:解析{{originalPrompt}}的核心意图与隐含需求
- 要素提取:识别关键实体、操作指令、约束条件
- 边界划定:根据{{decomposeDepth}}确定分析粒度
阶段二:诊断(Diagnose)
- 多维评估:基于{{diagnoseDimensions}}进行缺陷量化分析
- 问题定位:识别模糊点、歧义项、结构缺陷
- 优先级排序:按影响程度分类处理优化重点
阶段三:开发(Develop)
- 策略匹配:根据{{developStrategy}}选择最优架构模式
- 模板注入:动态绑定标准化优化模板
- 平台适配:针对{{targetPlatform}}注入兼容层
阶段四:交付(Deliver)
- 质量验证:按照{{deliverStandard}}进行输出检验
- 格式标准化:确保符合{{outputFormat}}要求
- 迭代判断:未达阈值时自动触发下一轮E4-D循环
自动评估指标
- 意图达成率(≥95%)
- 结构完整性(≥90%)
- 平台兼容性(≥95%)
- 风格一致性(≥90%)
迭代优化逻辑
- 每轮E4-D流程后重新评估质量得分
- 根据薄弱环节调整下一轮优化策略
- 达到{{qualityThreshold}}或{{maxIterations}}后终止
最终产物包含
- 优化后的提示词(标记E4-D迭代版本)
- E4-D流程报告(各阶段执行详情)
- 质量认证证书(四维度达标状态)
- 参数使用摘要(所有配置参数效果分析)
日期:2025-12-20
分支:develop
基线提交:390545b(工作区存在未提交变更)
本次审查覆盖当前工作区代码变更(未提交),核心目标是:
- 在“评估(Evaluation)”能力中新增两类评估:
prompt-only:仅根据提示词本身评估质量,不依赖测试结果prompt-iterate:在“迭代需求(iterationNote)”背景下评估提示词改进程度
- 在 UI 中新增「分析」入口与评分徽章展示,并通过
provide/inject共享评估上下文,减少多层组件传递评估 props。
备注:本报告聚焦功能一致性、正确性与可维护性;不包含运行时验证(未执行 pnpm 指令)。
- 第 4 节为“问题清单(含风险)”,记录审查时发现的缺陷与建议。
- 由于后续已有代码修复/解释补充,本报告新增第 8 节“修复状态(更新记录)”。
- 若第 4 节的“建议/风险”与第 8 节内容存在冲突,请以第 8 节的“当前实现状态”为准,并据此做回归验证。
- 扩展评估类型联合:
EvaluationType增加prompt-only、prompt-iterate(packages/core/src/services/evaluation/types.ts:14)。 - 新增请求类型:
PromptOnlyEvaluationRequest:要求optimizedPrompt,不要求testResult(packages/core/src/services/evaluation/types.ts:145)PromptIterateEvaluationRequest:要求optimizedPrompt+iterateRequirement(packages/core/src/services/evaluation/types.ts:156)
EvaluationService.validateRequest()增加上述两种类型的字段校验(packages/core/src/services/evaluation/service.ts:159)。EvaluationService.buildTemplateContext()为上述两种类型注入模板上下文:- prompt-only:
optimizedPrompt - prompt-iterate:
optimizedPrompt+iterateRequirement(packages/core/src/services/evaluation/service.ts:270)。
- prompt-only:
- 多处错误文案由中文改为英文(例如校验/解析错误)(
packages/core/src/services/evaluation/service.ts:160、packages/core/src/services/evaluation/service.ts:385)。
新增内置评估模板(basic/pro × system/user × zh/en × prompt-only/prompt-iterate),并注册到默认模板集合:
- 导出聚合:
packages/core/src/services/template/default-templates/evaluation/index.ts - 静态模板集合:
packages/core/src/services/template/default-templates/index.ts - 模板示例:
evaluation-basic-system-prompt-only(packages/core/src/services/template/default-templates/evaluation/basic/system/evaluation-prompt-only.ts)evaluation-pro-system-prompt-iterate(packages/core/src/services/template/default-templates/evaluation/pro/system/evaluation-prompt-iterate.ts)
注意:TemplateManager.getBuiltinTemplates() 会根据“当前语言”选择模板集合(packages/core/src/services/template/manager.ts:208),因此模板 ID 必须在不同语言集合中一致;目前 en 文件的 id 与 zh 文件一致(例如 evaluation-basic-system-original),符合该机制。
- 新增
packages/core/tests/unit/evaluation/service.test.ts,覆盖:prompt-only/prompt-iterate校验规则(包括不要求testResult、iterateRequirement必填)- 模板 ID 生成与模板拉取是否按预期发生
evaluateStream回调路径(packages/core/tests/unit/evaluation/service.test.ts:73)。
useEvaluation:- 扩展状态
state['prompt-only']、state['prompt-iterate'] - 新增计算属性(分数/等级/是否评估中/是否有结果)
- 新增方法
evaluatePromptOnly()、evaluatePromptIterate() executeEvaluation()的 request 类型由联合改为EvaluationRequest(packages/ui/src/composables/prompt/useEvaluation.ts:375)。
- 扩展状态
- 新增评估上下文:
provideEvaluation()/useEvaluationContext()/useEvaluationContextOptional()(packages/ui/src/composables/prompt/useEvaluationContext.ts:28)。
PromptOptimizerApp提供上下文:provideEvaluation(evaluation)(packages/ui/src/components/app-layout/PromptOptimizerApp.vue:993)。
- i18n 增加文案:
prompt.analyzeprompt.error.noOptimizedPrompt(packages/ui/src/i18n/locales/zh-CN.ts:1131、packages/ui/src/i18n/locales/en-US.ts:1163)。
PromptPanel:- 通过
useEvaluationContextOptional()读取上下文(packages/ui/src/components/PromptPanel.vue:358)。 - 计算评估类型:若当前版本存在
iterationNote,使用prompt-iterate,否则prompt-only(packages/ui/src/components/PromptPanel.vue:371)。 - 入口 UI:
- 若有结果或正在评估:展示
EvaluationScoreBadge - 否则:展示「分析」按钮(
packages/ui/src/components/PromptPanel.vue:122)。
- 若有结果或正在评估:展示
- 点击「分析」:
- 若
optimizedPrompt为空,toastprompt.error.noOptimizedPrompt - 否则按是否有
iterationNote调用evaluation.evaluatePromptOnly/Iterate(packages/ui/src/components/PromptPanel.vue:489)。
- 若
- 通过
- UI 组装
EvaluationRequest EvaluationService.validateRequest()校验必要字段- 根据
mode+type组装模板 ID:evaluation-{functionMode}-{subMode}-{type}(packages/core/src/services/evaluation/service.ts:263) TemplateManager.getTemplate(id):按语言选择内置模板集合,并用相同的id查找(packages/core/src/services/template/manager.ts:208)buildTemplateContext()注入字段(optimizedPrompt/iterateRequirement等)- 调用 LLM(stream 或非 stream)
parseEvaluationResult()→normalizeEvaluationResponse()规范化输出(packages/core/src/services/evaluation/service.ts:331)。
PromptOptimizerApp:统一持有evaluation实例,并通过provideEvaluation()注入PromptPanel:直接通过inject调用评估方法并展示结果徽章EvaluationPanel:仍由顶层统一展示(依赖evaluation.state.activeDetailType、evaluation.activeResult等)。
不同模式(basic/pro、system/user)在“优化对象形态、评估维度、上下文信息”上确实可能不同,但在当前架构下,这些差异主要由“请求参数 + 模板选择 + 上下文注入”解决,不必通过“每个 Workspace 各自一套 evaluation 实例”解决。
- 模板选择天然区分模式:Core 通过
evaluation-{functionMode}-{subMode}-{type}生成模板 ID,不同模式会命中不同模板(packages/core/src/services/evaluation/service.ts:263)。 - 上下文差异通过
proContext注入:Pro-System 需要多消息上下文,Pro-User 需要变量解析上下文。当前通过provideProContext()在 Workspace 提供,并在PromptPanel评估时读取注入(packages/ui/src/components/context-mode/ContextSystemWorkspace.vue:420、packages/ui/src/components/PromptPanel.vue:363、packages/ui/src/components/PromptPanel.vue:489)。 - 输出结构被统一规范化:模板可返回不同
dimensions[],但最终都会被规范化为统一的EvaluationResponse结构,UI 可复用同一渲染组件(packages/core/src/services/evaluation/service.ts:394、packages/core/src/services/evaluation/types.ts:206)。
结论:建议“全局一套 evaluation(App-level)+ provide/inject”,用 mode/proContext/type 适配不同模式差异;这样能避免 Context 模式出现“双套评估状态/双面板”的割裂问题(见第 9 节)。
状态:✅ 已修复(见第 8 节“P0-1”)
现象
- 在
EvaluationPanel中触发 “重新评估(re-evaluate)” 时,若当前详情类型为prompt-only或prompt-iterate,不会重新发起请求。
原因定位
handleReEvaluate()读取evaluation.state.activeDetailType并调用handleEvaluate(currentType)(packages/ui/src/composables/prompt/useEvaluationHandler.ts:220)。- 但
handleEvaluate(type)只处理original/optimized/compare三种类型(packages/ui/src/composables/prompt/useEvaluationHandler.ts:183),对新类型没有分支,等同于“无操作返回”。
影响
- 用户从详情面板复评新类型无响应,体验不一致;
- 若未来
EvaluationScoreBadge也依赖EvaluationPanel复评链路,问题将进一步扩大。
建议
- 在
useEvaluationHandler.handleEvaluate()增加对prompt-only/prompt-iterate的分支,并考虑从状态或上下文中取得iterateRequirement(或由 UI 提供)。
状态:✅ 已修复(见第 8 节“P0-2”)
现象
ContextSystemWorkspace与ContextUserWorkspace监听@analyze="handleAnalyze",并在handleAnalyze中调用evaluation.evaluatePromptOnly/Iterate且传入proContext(packages/ui/src/components/context-mode/ContextSystemWorkspace.vue:518、packages/ui/src/components/context-mode/ContextUserWorkspace.vue:769)。- 但
PromptPanel并未定义/emitanalyze事件(packages/ui/src/components/PromptPanel.vue:413),点击「分析」走的是handleEvaluate()直接调用evaluation.evaluatePromptOnly/Iterate,且未传proContext(packages/ui/src/components/PromptPanel.vue:489)。
影响
@analyze监听逻辑大概率不会触发,成为“死代码”;- Pro 模式模板对
proContext依赖较强(尤其pro-system场景,用于多消息上下文理解),未传会降低评估质量。
建议(历史记录)
- 原建议为“事件驱动”或“上下文直连”二选一避免双轨;当前实现已选择“上下文直连”,并通过
provide/inject共享proContext(见第 8 节“P0-2”)。
状态:✅ 已修复(见第 8 节“P0-3”)
现象
PromptPanel徽章展示基于evaluation.state['prompt-only'|'prompt-iterate']是否已有结果(packages/ui/src/components/PromptPanel.vue:399)。- 当切换版本/切换消息/替换
optimizedPrompt时,如果没有明确清理对应评估状态,徽章可能展示上一条内容的分数与详情。
当前已有防护
- 顶层仅对
optimizer.optimizedPrompt做了 watch 并清理prompt-only/prompt-iterate(packages/ui/src/components/app-layout/PromptOptimizerApp.vue:1340)。
风险点
- Context 模式下
PromptPanel的optimizedPrompt来自displayAdapter.displayedOptimizedPrompt(packages/ui/src/components/context-mode/ContextSystemWorkspace.vue:102),不一定会触发上述 watch; - 即使触发,
PromptPanel内部也没有基于currentVersionId或selectedMessage的精确清理逻辑。
建议
- 在
PromptPanel内部针对optimizedPrompt、currentVersionId、versions(或等价“内容标识”)做 watch,主动清空对应评估状态,确保“内容-评估结果”一致性。
现象
prompt-only/prompt-iterate模板输出 JSON 中包含"isOptimizedBetter"(例如packages/core/src/services/template/default-templates/evaluation/basic/system/evaluation-prompt-only.ts)。- 但
normalizeEvaluationResponse()仅在type === 'compare'时才会把isOptimizedBetter写入响应(packages/core/src/services/evaluation/service.ts:468)。
影响
- 模板 token 成本增加但信息被丢弃;
- 易产生误导:模板要求输出 true/false,但 UI/服务端并不消费该字段。
建议
- 明确语义:若希望 prompt-only/prompt-iterate 也保留该字段,扩展响应结构与 UI 展示;若不需要,应移除模板中的字段要求(更省 token、更一致)。
现象
- Core 抛出的校验/解析错误信息改为英文(
packages/core/src/services/evaluation/service.ts:160等)。 - UI toast 使用
getErrorMessage(error)透传(packages/ui/src/composables/prompt/useEvaluation.ts:410),在中文界面下可能显示英文错误。
影响
- 用户体验与 i18n 文案体系不一致;
- 单测已锁定英文字符串,后续想恢复中文会引入测试修改成本(
packages/core/tests/unit/evaluation/service.test.ts:100)。
建议
- 若希望 i18n 统一:考虑在 UI 层将错误映射到本地化 key(按 error class / error code),而不是依赖错误 message 文案。
现象
PromptPanel的defineEmits新增了"apply-improvement",但注释中提到“评估相关事件(evaluate 和 show-evaluation-detail 已通过 inject 处理)”(packages/ui/src/components/PromptPanel.vue:431)。- 同时 workspace 中仍出现
@analyze监听(见 P0),但PromptPanel并未 emit。
影响
- 组件接口不清晰,调用方难以判断哪些事件仍有效;
- 容易引入更多“监听了但永远不触发”的事件绑定。
建议
- 统一组件契约:对外只保留必要事件(例如
apply-improvement),其余通过 context 内部处理即可。
- Core
EvaluationService对新类型的校验、模板 ID 生成、evaluateStream回调路径已有单测(packages/core/tests/unit/evaluation/service.test.ts:73)。
- UI 层至少做一次“切换版本/切换消息后徽章不残留”的用例验证(手测即可,或后续补 e2e/组件测试)。
- Pro 模式下确认
proContext在 prompt-only/prompt-iterate 评估中确实被带入,且模板渲染符合预期。
useEvaluationHandler.handleEvaluate()支持prompt-only/prompt-iterate,确保EvaluationPanel的 re-evaluate 可用。- 统一“分析”入口架构:删除死代码或补齐
PromptPanel的analyzeemit,并确保 Pro 场景传递proContext。 - 在
PromptPanel内增加内容变更触发的clearResult('prompt-only'|'prompt-iterate'),避免旧分数残留。 - 明确并统一
isOptimizedBetter的语义(模板/服务/前端三方一致)。 - 如需 i18n 统一,考虑“错误码/错误类型 → 文案 key”的映射策略,减少对英文 message 的依赖。
packages/core/src/services/evaluation/service.tspackages/core/src/services/evaluation/types.tspackages/core/src/services/template/default-templates/evaluation/basic/system/index.tspackages/core/src/services/template/default-templates/evaluation/basic/user/index.tspackages/core/src/services/template/default-templates/evaluation/index.tspackages/core/src/services/template/default-templates/evaluation/pro/system/index.tspackages/core/src/services/template/default-templates/evaluation/pro/user/index.tspackages/core/src/services/template/default-templates/index.tspackages/ui/src/components/PromptPanel.vuepackages/ui/src/components/app-layout/PromptOptimizerApp.vuepackages/ui/src/components/basic-mode/BasicSystemWorkspace.vuepackages/ui/src/components/basic-mode/BasicUserWorkspace.vuepackages/ui/src/components/context-mode/ContextSystemWorkspace.vuepackages/ui/src/components/context-mode/ContextUserWorkspace.vuepackages/ui/src/composables/prompt/index.tspackages/ui/src/composables/prompt/useEvaluation.tspackages/ui/src/composables/prompt/useEvaluationHandler.tspackages/ui/src/i18n/locales/en-US.tspackages/ui/src/i18n/locales/zh-CN.tspackages/ui/src/i18n/locales/zh-TW.ts
packages/core/src/services/template/default-templates/evaluation/**/evaluation-prompt-only*.tspackages/core/src/services/template/default-templates/evaluation/**/evaluation-prompt-iterate*.tspackages/core/tests/unit/evaluation/service.test.tspackages/ui/src/composables/prompt/useEvaluationContext.tspackages/ui/src/composables/prompt/useProContext.ts
修复内容
- 在
useEvaluationHandler.ts的handleEvaluate()中添加了对prompt-only和prompt-iterate类型的处理分支 - 在
UseEvaluationHandlerOptions中新增currentIterateRequirement可选参数,用于prompt-iterate类型的重新评估 - 在
PromptOptimizerApp.vue中计算currentIterateRequirement(从当前版本的iterationNote获取)并传递给 evaluationHandler
涉及文件
packages/ui/src/composables/prompt/useEvaluationHandler.tspackages/ui/src/components/app-layout/PromptOptimizerApp.vue
修复方案
选择了"上下文直连"路径:通过 provide/inject 共享 proContext,而非事件驱动。
修复内容
- 新增
useProContext.ts,提供provideProContext()和useProContextOptional()方法 - 在
ContextSystemWorkspace.vue和ContextUserWorkspace.vue中调用provideProContext(proContext) - 在
PromptPanel.vue中调用useProContextOptional()获取 proContext,并在评估调用时传入 - 移除了 workspace 中的
@analyze监听和handleAnalyze函数(死代码清理) - 将
@analyze替换为@apply-improvement(用于应用改进建议)
涉及文件
packages/ui/src/composables/prompt/useProContext.ts(新增)packages/ui/src/composables/prompt/index.tspackages/ui/src/components/PromptPanel.vuepackages/ui/src/components/context-mode/ContextSystemWorkspace.vuepackages/ui/src/components/context-mode/ContextUserWorkspace.vue
修复内容
- 在
PromptPanel.vue中新增 watch,监听optimizedPrompt和currentVersionId的变化 - 当内容或版本变化时,自动清除
prompt-only和prompt-iterate评估结果 - 避免切换版本/消息后旧分数残留的问题
涉及文件
packages/ui/src/components/PromptPanel.vue
决策 保持当前行为,作为已知的设计取舍:
prompt-only和prompt-iterate模板中仍输出isOptimizedBetter字段- 服务端
normalizeEvaluationResponse()仅在compare类型时保留该字段 - 前端不消费新类型的
isOptimizedBetter
理由
- 新类型的语义是"评估单个提示词质量",
isOptimizedBetter字段在此场景下意义有限 - 模板中保留该字段可作为 LLM 输出的校验锚点,不影响功能正确性
- 若后续需要展示,可在服务端和前端同步扩展
决策 保持 Core 层错误使用英文,在 UI 层进行本地化映射(未来改进方向):
- 当前 Core 层的校验/解析错误使用英文,便于日志分析和问题定位
- UI 层通过
getErrorMessage(error)透传,中文界面下可能显示英文错误 - 这是一个可接受的临时状态,不影响核心功能
未来改进方向
- 在 UI 层实现"错误码 → i18n key"的映射机制
- 根据错误类型或错误码选择对应的本地化文案
- 保持 Core 层错误信息稳定,避免因文案变更导致测试频繁修改
- 移除了 workspace 中的
@analyze监听 PromptPanel对外只保留必要事件:iterate、switchVersion、save-favorite、apply-improvement等- 评估相关逻辑通过
provide/inject内部处理,无需对外暴露
本节聚焦"截至当前代码状态仍存在的问题"(以代码为准),用于指导后续 AI 做收敛与修复。
原始问题
- App 顶层已提供全局评估上下文,但 ContextSystem/ContextUser 两个 Workspace 各自创建独立
evaluationHandler并渲染本地EvaluationPanel,导致状态不同步。
修复方案(已实施) 采纳了"全局一套 evaluation + 顶层唯一 EvaluationPanel"方案:
- 修改
useEvaluationHandler.ts:新增externalEvaluation可选参数(第 57 行、第 183-188 行),允许传入外部 evaluation 实例 - 移除 Workspace 内的
<EvaluationPanel>:ContextSystemWorkspace.vue:212- 仅保留注释说明ContextUserWorkspace.vue:247- 仅保留注释说明
- Workspace 使用全局 evaluation:
ContextSystemWorkspace.vue:417-const globalEvaluation = useEvaluationContext()ContextSystemWorkspace.vue:446-externalEvaluation: globalEvaluationContextUserWorkspace.vue:523-const globalEvaluation = useEvaluationContext()ContextUserWorkspace.vue:552-externalEvaluation: globalEvaluation
验证方式
- 在 context-mode 目录搜索
<EvaluationPanel应无匹配 - 搜索
externalEvaluation应能找到两个 Workspace 的使用
原始问题
- Workspace 内部的
useEvaluationHandler()未传currentIterateRequirement,可能导致prompt-iterate的 re-evaluate 校验失败。
修复方案(已实施)
- 在两个 Workspace 中新增
currentIterateRequirement计算属性:ContextSystemWorkspace.vue:425-432- 从displayAdapter.displayedVersions / displayedCurrentVersionId获取(确保与 UI“当前显示版本”一致)ContextUserWorkspace.vue:531-538- 从contextUserOptimization.currentVersions获取
- 将其传入
useEvaluationHandler:ContextSystemWorkspace.vue:445-currentIterateRequirement,ContextUserWorkspace.vue:551-currentIterateRequirement,
背景/场景
- 用户在评估详情点击“应用改进建议”,预期行为是:直接打开迭代弹窗,并把建议文本放进输入框;模板在弹窗内再选择(不同模式可选模板不同)。
修复方案(已实施)
PromptPanel.vue的迭代弹窗内已包含TemplateSelect(可在弹窗内选择模板)。PromptPanel.vue的handleIterate()不再要求selectedIterateTemplate已预选;直接打开弹窗。PromptPanel.vue暴露openIterateDialog(input?):用于“应用改进建议”路径预填充输入并打开弹窗。
验证方式
- 不预选迭代模板,点击“继续优化”按钮:应能打开迭代弹窗并在弹窗内选择模板。
- 从评估详情点击“应用改进建议”:应打开迭代弹窗并预填建议文本;未选择模板时点击确认应提示“请先选择迭代提示词”(允许)。
背景/场景
- “评估”永远针对当前显示内容;当切换功能模式(basic/pro/image)或切换子模式(system/user 等)时,旧的评估详情和分数不应残留。
修复方案(已实施)
PromptOptimizerApp.vue在以下入口统一执行:evaluation.closePanel()(关闭详情面板)evaluation.clearAllResults()(清空所有评估结果)
- 覆盖:
- 功能模式切换
handleModeSelect(...) - Context 子模式切换 watch(
contextManagement.contextMode) - 子模式切换处理器:
handleBasicSubModeChange(...)/handleProSubModeChange(...)/handleImageSubModeChange(...)
- 功能模式切换
验证方式
- 任意模式下完成评估后切换模式/子模式:评估面板应关闭,评分徽章/详情应清空。
isOptimizedBetter在 prompt-only/prompt-iterate 中不落库:模板要求输出该字段但服务端只在 compare 保留;建议要么删模板字段节省 token,要么扩展服务与 UI 一致消费(packages/core/src/services/evaluation/service.ts:468)。- 错误文案语言不统一:Core 报错英文,UI 透传英文;后续可引入"错误类型/错误码 → i18n key"的映射(
packages/core/src/services/evaluation/service.ts:159、packages/ui/src/composables/prompt/useEvaluation.ts:410)。
该问题是"全局面板事件处理器绑定到基础模式数据源"导致的模式耦合。尽管 Context Workspace 已通过
externalEvaluation复用了全局 evaluation,并移除了本地面板,但 App 顶层面板的交互仍需要进一步解耦。
代码事实
- App 顶层唯一
EvaluationPanel的@re-evaluate绑定到handleReEvaluate(packages/ui/src/components/app-layout/PromptOptimizerApp.vue:583),其实现来自 App 内部的evaluationHandler.handleReEvaluate(),而该 handler 使用的数据源是optimizer.prompt/optimizer.optimizedPrompt/testResults(即基础模式优化器与测试结果)。 - 在 Context 模式中,评估请求通常由
PromptPanel直接使用 inject 到的全局evaluation发起,内容来源是 Context Workspace 传入的originalPrompt/optimizedPromptprops(packages/ui/src/components/PromptPanel.vue:489)。 - 因此,当用户在 Context 模式下打开评估详情并点击"重新评估",可能会用基础模式的数据重新评估,覆盖 Context 的评估结果。
修复方案(已实施)
本次采用“方案 B:Provider(数据源提供者)路由”,核心原则是:
- 重新评估使用最新状态(当前工作区/当前内容),不保存/重放 lastRequest。
- 全局
EvaluationPanel只做 UI,不再绑定到基础模式数据源;其事件路由到“当前活跃 Workspace”执行。
-
useEvaluationHandler.ts调整 handleReEvaluate 语义:- 改为始终使用当前业务状态重新组装请求并执行一次评估(不依赖 lastRequest)。
-
Context Workspaces 暴露 Provider 能力(defineExpose):
reEvaluateActive():内部调用evaluationHandler.handleReEvaluate(),使用当前 Workspace 的数据源(original/optimized/proContext/iterateRequirement 等)重新评估。openIterateDialog():内部转发到PromptPanel的openIterateDialog,用于应用改进建议时打开迭代弹窗。
-
PromptOptimizerApp.vue全局面板事件路由:@re-evaluate:根据functionMode/contextMode选择systemWorkspaceRef/userWorkspaceRef(Context)或使用基础模式 handler,调用对应 provider 的reEvaluateActive()。@apply-improvement:在 Context 模式下调用对应 Workspace 的openIterateDialog(improvement);基础模式继续走basicModeWorkspaceRef。
验证方式
- Context 模式下执行评估后,在全局
EvaluationPanel点击“重新评估”,应重新评估当前选中消息/当前变量提示词(而非基础模式 optimizer 的数据)。 - Context 模式下在全局
EvaluationPanel点击“应用改进”,应打开当前 Workspace 的迭代弹窗并预填改进建议。
原始问题
EvaluationPanel.vue标题 switch 只覆盖original/optimized/compare,prompt-only/prompt-iterate会落到evaluation.title.default(packages/ui/src/components/evaluation/EvaluationPanel.vue:185)。
修复方案(已实施)
-
EvaluationPanel.vue添加新类型的 case(第 188-191 行):case 'prompt-only': return t('evaluation.title.promptOnly') case 'prompt-iterate': return t('evaluation.title.promptIterate')
-
添加 i18n 标题:
zh-CN.ts-promptOnly: "提示词质量分析",promptIterate: "迭代优化分析"en-US.ts-promptOnly: "Prompt Quality Analysis",promptIterate: "Iteration Optimization Analysis"zh-TW.ts-promptOnly: "提示詞品質分析",promptIterate: "迭代優化分析"
典型流程(单提示词优化):
- 输入
originalPrompt(原始提示词) - 点击“优化”得到
optimizedPrompt(当前显示版本) - (可选)在测试区运行测试得到
testResult(用于 original/optimized/compare 三类评估) - 点击“分析”执行
prompt-only或prompt-iterate(不依赖测试结果) - 在评估详情中点击“重新评估”会对“当前显示的内容 + 当前模式参数”再评估一次
这里的关键约束:originalPrompt 在产品定义中始终存在(用于对齐原始需求,避免意图偏离),因此 Core 层校验 originalPrompt 不能为空是合理的,不需要为所谓“仅提示词独立评估”放宽。
Context 模式(pro)本质上不是“单提示词”,而是“带上下文的目标对象”:
- Pro-System:目标是对话中的某条 message(system/user/assistant/tool),
proContext会携带“目标 message + 全对话消息列表”,便于模型理解上下文语义。 - Pro-User:目标是“带变量的提示词”,
proContext会携带变量解析信息(raw/resolved/variables),便于评估时知道占位符如何被填充。
因此:
- 同一个
EvaluationType(比如prompt-only)在不同子模式下“模板与上下文输入”可能不同; - 但服务端输出仍应通过
EvaluationResponse规范化,保持 UI 展示一致(分数/建议/原因等)。
“重新评估”的产品语义是:再执行一次评估,且评估对象永远是“当前 UI 正在展示的版本”。
因此实现上只需要两类信息:
- “要评估哪种类型”:来自当前打开的详情类型
evaluation.state.activeDetailType - “要评估的输入数据”:来自当前业务状态(当前 prompt / 当前版本 / 当前 proContext / 当前 iterateRequirement 等)
之前的 lastRequest 方案容易引入“旧状态回放”与跨模式污染;当前实现已移除 lastRequest,并把 re-evaluate 变成“以当前状态重建请求并执行”,更符合产品定义。
本次已落地的是 方案 B:全局唯一 EvaluationPanel + Provider 路由:
- 优点:UI 一致、状态唯一(避免双套 evaluation)、跨组件更易共享(
provide/inject)。 - 风险:顶层需要知道“当前活跃 workspace”,并在能力缺失时按“异常 bug”处理(避免 silently fallback 用错数据源)。
备选方案(回退):每个模式各自渲染一个 EvaluationPanel。
- 优点:数据源天然就近,路由简单。
- 缺点:容易出现“双面板/双状态”,并带来更多模式分支与同步问题。
当前结论:在现有 UI 架构下,优先保持方案 B;若未来 Provider 接口进一步膨胀或难以维护,再考虑回退为“各模式自带面板”,但需要严格避免重复 evaluation 实例。