docs/developer/technical-analysis.md
本文档整合了项目开发过程中的技术分析和问题解决方案
代码中有三个地方会自动触发保存:
// 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 KB
6 个 session stores (basic-system, basic-user, pro-system, pro-user, image-text2image, image-image2image)
× 每个累积 3 个月的测试结果
× 没有任何清理
× 每次切换标签页都保存一次
= 2.4 GB 💥
开发者假设:
实际情况:
没有代码检查:
其他应用的常见做法:
// ✅ 示例:自动清理 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
这说明:
无限累积的三个关键原因:
解决方案:
你的 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(只追加) 架构:
写入 100 次 → 积累 100 个版本 → 触发 Compaction
↓
合并多个 .ldb 文件
↓
删除重复的 key,只保留最新版本
↓
数据库大小回落
可能原因:
浏览器崩溃或异常退出
数据写入速度 > 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
这说明:
// ✅ 其他应用通常这样做
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 到内存
💥 浏览器崩溃
限制单次写入大小
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 依赖:
你的情况:
问题的本质:
不是"新增 vs 覆盖"的问题,而是:
类比: 就像你每天往同一个文件柜(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 结果 | 失败 ❌ | 成功 ✅ |
不是"分离"本身解决问题,而是:
关键是:减少写入频率(42次 → 2次),而不是分离!
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问题场景:
// useBasicSystemSession.ts
state.value.testResults = results // 可能包含完整的输出文本
如果用户测试了很长的输出(如 GPT-4 的长回答):
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;
}
}
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 = '';
}
}
数据分片存储
添加数据压缩
添加数据库健康检查
✅ 已确认数据库状态:
✅ 根本原因确认:
自动保存机制过于频繁
保存完整 state,没有过滤
// useBasicSystemSession.ts:219
const snapshot = JSON.stringify(state.value) // ❌ 包含所有 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),导致:
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
} else if (imageRef.b64) {
// 如果有 base64(旧数据兼容)
return
} 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:限制图像数量
方案1:图像单独存储
方案1 + 方案4 组合
packages/core/src/services/image/types.ts
ImageResultRef 接口packages/ui/src/stores/session/useImageText2ImageSession.ts
ImageText2ImageSessionStatepackages/ui/src/stores/session/useImageImage2ImageSession.ts
packages/core/src/services/storage/ (新增)
ImageStorageServicepackages/ui/src/components/.../ImageWorkspace.vue
| 方案 | 复杂度 | 效果 | 推荐度 |
|---|---|---|---|
| 方案1:单独存储 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ✅ 强烈推荐 |
| 方案2:限制数量 | ⭐⭐ | ⭐⭐⭐⭐ | ✅ 短期推荐 |
| 方案3:只保存URL | ⭐ | ⭐⭐ | ❌ 不推荐 |
| 方案4:文件系统 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ✅ 桌面推荐 |
本次迁移旨在统一项目中的模式术语,将过时或语义不清的 optimizationMode、contextMode、selectedOptimizationMode 等表达,逐步对齐到 functionMode(一级功能模式)与 subMode(二级子模式)的设计,并确保各模式的子模式状态独立持久化。
说明:该文档保留在 docs/workspace/ 作为“模式术语与迁移现状”的单点入口;其中的“待办/清理项”只保留仍然有效的内容,避免对过往实现阶段产生误导。
basic | pro | image)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.vueselectedOptimizationMode 已改为 computed(非独立状态源)PromptOptimizerApp.vue 使用 useFunctionMode + useBasicSubMode/useProSubMode/useImageSubMode 管理状态,并做独立持久化。selectedOptimizationMode 不再是独立 ref,而是从 subMode 推导的 computed(兼容旧接口/props 形态)。selectedOptimizationMode → optimizationModeselectedOptimizationMode → optimizationModeusePromptTester.ts 中所有 selectedOptimizationMode.value → optimizationMode.valuePromptOptimizerApp.vue 中保留必要的兼容性注释(以反映真实装配位置)逐步移除命名上的“误导”
selectedOptimizationMode 虽已是 computed,但命名仍容易让人误以为它是“用户选择的优化模式状态源”。optimizationMode props(避免大范围破坏性改动)currentOptimizationMode / derivedOptimizationMode),并集中在 UI 层统一出口组件/模板中的旧名收敛
optimizationMode/contextMode/selectedOptimizationMode 相关命名逐步统一为“functionMode/subMode 派生值”的表达(不强求一次性替换,但要避免继续引入新旧混用)类型定义中的过时术语
packages/ui/src/types/components.tspackages/core/src/types/ 相关文件测试文件中的术语
packages/ui/src/i18n/locales/ 中的键名functionMode + 各自 subMode(已完成)@deprecated 标记(后续清理)文档版本: v1.0 创建时间: 2025-10-31 最近更新: 2025-12-19 维护者: 用户
你是一个基于E4-D方法论的智能提示词优化引擎,实现分解→诊断→开发→交付的全流程自动化优化。
基础参数
E4-D专项参数
扩展参数
阶段一:分解(Decompose)
阶段二:诊断(Diagnose)
阶段三:开发(Develop)
阶段四:交付(Deliver)
自动评估指标
迭代优化逻辑
最终产物包含
日期:2025-12-20
分支:develop
基线提交:390545b(工作区存在未提交变更)
本次审查覆盖当前工作区代码变更(未提交),核心目标是:
prompt-only:仅根据提示词本身评估质量,不依赖测试结果prompt-iterate:在“迭代需求(iterationNote)”背景下评估提示词改进程度provide/inject 共享评估上下文,减少多层组件传递评估 props。备注:本报告聚焦功能一致性、正确性与可维护性;不包含运行时验证(未执行 pnpm 指令)。
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() 为上述两种类型注入模板上下文:
optimizedPromptoptimizedPrompt + iterateRequirement(packages/core/src/services/evaluation/service.ts:270)。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.tspackages/core/src/services/template/default-templates/index.tsevaluation-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 必填)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)。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)。EvaluationScoreBadgepackages/ui/src/components/PromptPanel.vue:122)。optimizedPrompt 为空,toast prompt.error.noOptimizedPromptiterationNote 调用 evaluation.evaluatePromptOnly/Iterate(packages/ui/src/components/PromptPanel.vue:489)。EvaluationRequestEvaluationService.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 等)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 实例”解决。
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 并未定义/emit analyze 事件(packages/ui/src/components/PromptPanel.vue:413),点击「分析」走的是 handleEvaluate() 直接调用 evaluation.evaluatePromptOnly/Iterate,且未传 proContext(packages/ui/src/components/PromptPanel.vue:489)。影响
@analyze 监听逻辑大概率不会触发,成为“死代码”;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)。风险点
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)。影响
建议
现象
packages/core/src/services/evaluation/service.ts:160 等)。getErrorMessage(error) 透传(packages/ui/src/composables/prompt/useEvaluation.ts:410),在中文界面下可能显示英文错误。影响
packages/core/tests/unit/evaluation/service.test.ts:100)。建议
现象
PromptPanel 的 defineEmits 新增了 "apply-improvement",但注释中提到“评估相关事件(evaluate 和 show-evaluation-detail 已通过 inject 处理)”(packages/ui/src/components/PromptPanel.vue:431)。@analyze 监听(见 P0),但 PromptPanel 并未 emit。影响
建议
apply-improvement),其余通过 context 内部处理即可。EvaluationService 对新类型的校验、模板 ID 生成、evaluateStream 回调路径已有单测(packages/core/tests/unit/evaluation/service.test.ts:73)。proContext 在 prompt-only/prompt-iterate 评估中确实被带入,且模板渲染符合预期。useEvaluationHandler.handleEvaluate() 支持 prompt-only/prompt-iterate,确保 EvaluationPanel 的 re-evaluate 可用。PromptPanel 的 analyze emit,并确保 Pro 场景传递 proContext。PromptPanel 内增加内容变更触发的 clearResult('prompt-only'|'prompt-iterate'),避免旧分数残留。isOptimizedBetter 的语义(模板/服务/前端三方一致)。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.tspackages/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,并在评估调用时传入@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 字段在此场景下意义有限决策 保持 Core 层错误使用英文,在 UI 层进行本地化映射(未来改进方向):
getErrorMessage(error) 透传,中文界面下可能显示英文错误未来改进方向
@analyze 监听PromptPanel 对外只保留必要事件:iterate、switchVersion、save-favorite、apply-improvement 等provide/inject 内部处理,无需对外暴露本节聚焦"截至当前代码状态仍存在的问题"(以代码为准),用于指导后续 AI 做收敛与修复。
原始问题
evaluationHandler 并渲染本地 EvaluationPanel,导致状态不同步。修复方案(已实施) 采纳了"全局一套 evaluation + 顶层唯一 EvaluationPanel"方案:
useEvaluationHandler.ts:新增 externalEvaluation 可选参数(第 57 行、第 183-188 行),允许传入外部 evaluation 实例<EvaluationPanel>:
ContextSystemWorkspace.vue:212 - 仅保留注释说明ContextUserWorkspace.vue:247 - 仅保留注释说明ContextSystemWorkspace.vue:417 - const globalEvaluation = useEvaluationContext()ContextSystemWorkspace.vue:446 - externalEvaluation: globalEvaluationContextUserWorkspace.vue:523 - const globalEvaluation = useEvaluationContext()ContextUserWorkspace.vue:552 - externalEvaluation: globalEvaluation验证方式
<EvaluationPanel 应无匹配externalEvaluation 应能找到两个 Workspace 的使用prompt-iterate re-evaluate 缺少 iterateRequirement(已修复)原始问题
useEvaluationHandler() 未传 currentIterateRequirement,可能导致 prompt-iterate 的 re-evaluate 校验失败。修复方案(已实施)
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?):用于“应用改进建议”路径预填充输入并打开弹窗。验证方式
背景/场景
修复方案(已实施)
PromptOptimizerApp.vue 在以下入口统一执行:
evaluation.closePanel()(关闭详情面板)evaluation.clearAllResults()(清空所有评估结果)handleModeSelect(...)contextManagement.contextMode)handleBasicSubModeChange(...) / handleProSubModeChange(...) / handleImageSubModeChange(...)验证方式
isOptimizedBetter 在 prompt-only/prompt-iterate 中不落库:模板要求输出该字段但服务端只在 compare 保留;建议要么删模板字段节省 token,要么扩展服务与 UI 一致消费(packages/core/src/services/evaluation/service.ts:468)。packages/core/src/services/evaluation/service.ts:159、packages/ui/src/composables/prompt/useEvaluation.ts:410)。该问题是"全局面板事件处理器绑定到基础模式数据源"导致的模式耦合。尽管 Context Workspace 已通过
externalEvaluation复用了全局 evaluation,并移除了本地面板,但 App 顶层面板的交互仍需要进一步解耦。
代码事实
EvaluationPanel 的 @re-evaluate 绑定到 handleReEvaluate(packages/ui/src/components/app-layout/PromptOptimizerApp.vue:583),其实现来自 App 内部的 evaluationHandler.handleReEvaluate(),而该 handler 使用的数据源是 optimizer.prompt/optimizer.optimizedPrompt/testResults(即基础模式优化器与测试结果)。PromptPanel 直接使用 inject 到的全局 evaluation 发起,内容来源是 Context Workspace 传入的 originalPrompt/optimizedPrompt props(packages/ui/src/components/PromptPanel.vue:489)。修复方案(已实施)
本次采用“方案 B:Provider(数据源提供者)路由”,核心原则是:
EvaluationPanel 只做 UI,不再绑定到基础模式数据源;其事件路由到“当前活跃 Workspace”执行。useEvaluationHandler.ts 调整 handleReEvaluate 语义:
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。验证方式
EvaluationPanel 点击“重新评估”,应重新评估当前选中消息/当前变量提示词(而非基础模式 optimizer 的数据)。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)本质上不是“单提示词”,而是“带上下文的目标对象”:
proContext 会携带“目标 message + 全对话消息列表”,便于模型理解上下文语义。proContext 会携带变量解析信息(raw/resolved/variables),便于评估时知道占位符如何被填充。因此:
EvaluationType(比如 prompt-only)在不同子模式下“模板与上下文输入”可能不同;EvaluationResponse 规范化,保持 UI 展示一致(分数/建议/原因等)。“重新评估”的产品语义是:再执行一次评估,且评估对象永远是“当前 UI 正在展示的版本”。
因此实现上只需要两类信息:
evaluation.state.activeDetailType之前的 lastRequest 方案容易引入“旧状态回放”与跨模式污染;当前实现已移除 lastRequest,并把 re-evaluate 变成“以当前状态重建请求并执行”,更符合产品定义。
本次已落地的是 方案 B:全局唯一 EvaluationPanel + Provider 路由:
provide/inject)。备选方案(回退):每个模式各自渲染一个 EvaluationPanel。
当前结论:在现有 UI 架构下,优先保持方案 B;若未来 Provider 接口进一步膨胀或难以维护,再考虑回退为“各模式自带面板”,但需要严格避免重复 evaluation 实例。