feat(settings): replicate the WorkBuddy personalization panel and wire identity, tone and prompts (TODO 13)

Recover the WorkBuddy 5.8.3 个性化 page from REA evidence (PersonalizationPanel-D16rRGjy.css + tokens): 45 module rules plus scoped light/dark tokens, the five-card 1160px layout (identity card 638x100 with inline editors, reply preference card 638x204, three 638x264 long-text cards with 598x160 previews) and the 640x520 editor modal.

Persist identity, tone, welcome tips, custom instructions, persona and long-term memory in config.json, expose personalization:get/update over IPC, and append the resulting personalization context to each turn's prompt for both the OMP and built-in kernels. Wire 欢迎语 to the streaming loading tips and keep 显示文件修改过程 visibly disabled because YCode has no incremental file-change renderer yet.

Verify with scripts/verify-personalization-settings.cjs: 29 assertions covering geometry, tone options, switches, inline identity editing with blur-discard, editor modal save/cancel, config persistence, reopen, dark surfaces and no renderer errors. Add a pure prompt-builder regression and keep every existing settings regression green.
This commit is contained in:
xiaohan.yuan committed 2026-10-17 10:46:40 +08:00
1 parent 19460f0b13
commit 0dc088f1f7
54 files changed
+2038 -248

No files matched your search

+34 -4
View File
@@ -408,11 +408,41 @@
### 13. 个性化页面与真实功能
- [ ] 拆解原版页面、字段、说明、保存/启停等交互。
- [ ] 将支持的个性化配置接入实际运行链路,确认在哪些新建/既有会话中生效。
- [ ] 验收:修改与持久化有效;界面显示与实际行为一致;未接能力明确记录,不用占位控件宣称完成。
- [x] 拆解原版页面、字段、说明、保存/启停等交互。
- [x] 将支持的个性化配置接入实际运行链路(身份/风格/指令/人设/长期记忆随每轮请求发送,欢迎语控制加载提示)。
- [x] 验收:修改与持久化有效;界面显示与实际行为一致;「显示文件修改过程」明确标注未接入,不用占位控件宣称完成。
状态:待 REA 拆解。
状态:已实现并通过真实窗口回归(29 项断言)。
原版证据:
- REA `ev_af920edf33af5e811d58f05dad15210b29cd891f28e0fa74f6b18d63212241b1`(WorkBuddy 5.8.3,设置窗口 `6D5A7FEE…`,900×680):浅色顶部 DOM/layout/computed style + 模块样式表 `PersonalizationPanel-D16rRGjy.css`(sha256 `318664f96253adace047d7b1d2d213fbf6139461c10d80ae12135998b7b98f3d`,6,218 字节)。
- REA `ev_88af0774005c02e9c75284ade2be45de4943ee5aaf73fe5b13ac8a38b85be786`:深色同页布局与计算样式。
- REA `ev_d6e0a2483a3bde901f4cf8299ff03eb0b475cb934921058cf38dd671585ab457`:回复风格下拉展开/关闭、欢迎语与显示文件修改过程开关的切换与恢复。
- REA `ev_3086b2b2…` / `ev_e1762bd8…` / `ev_9ee64574…` / `ev_bc120498…` / `ev_7b372850…`:身份字段编辑态、失焦丢弃草稿、非空确认保存、空值无法保存。
- REA `ev_523e07353a9fd7a003136f8e07c72d5ba87a05f893dd4b4c4adb2d7ed5a7430f`(指令编辑弹窗 DOM/几何)、`ev_624240fb…`(人设弹窗)、`ev_64e60ac3…`(长期记忆弹窗)。
- REA `ev_4f4f76054a930a186199ee28a5f0169f7de48a05a295b8e9eb630c796f07be4a`:最终浅色顶部稳定态。
- 页面结构:五张卡片、内容高 1160px。身份卡 `[232,40,638,100]`(两行 `[244,48/98,614,34]`,胶囊 172×34,占位「暂无内容,点击编辑添加」);回复偏好卡 `[232,156,638,204]`(回复风格下拉 120×32、欢迎语开关 34×20 默认开、显示文件修改过程默认关);指令/人设/长期记忆三张长卡 `[232,376/656/936,638,264]`,预览 598×160,编辑按钮 68×32;编辑弹窗 `[130,80,640,520]`,footer 保存/取消 68×32。
- 回复风格 8 个选项(标签+说明):默认/不设定特定风格。、专业严谨/清晰、准确、值得信赖。、亲和友善/温暖、平易近人、鼓励支持。、直言不讳/简明扼要、不废话、直击要点。、天马行空/富有想象力、善用比喻类比。、高效务实/最少文字、最大信息量。、毒舌吐槽/犀利吐槽、但绝不伤人。、启发引导/用提问引导思考、授人以渔。
- 深色差异:卡片 `#fff → rgb(31,31,31)`,标题 `rgb(0,0,0) → rgb(242,243,245)`,描述黑 50% → 白 50%,开关 on `rgb(0,194,154) → rgb(34,203,168)`、off `rgb(230,230,230) → rgb(60,60,60)`,长预览 `#fff → rgb(38,38,38)`。
- 观察范围:桌面端 900×680 浅色 + 深色抽查;未覆盖:富文本编辑器内的真实输入(REA text action 不支持 `.sc-editor-content`)、单条身份值清除(原版空值确认被拒)、主题切换时 `target_changed` 后的连续动画。
实现:
- `scripts/rea/recover-personalization-panel.mjs` → `source-personalization-panel-recovered.css`:45 条模块规则 + 两组主题 token(47 条规则、9,802 字节、sha256 `530b89d5…`),连续重跑一致。
- `src/shared/personalization.ts`:8 个风格选项、原版默认人设文本、默认值归一化与 `buildPersonalizationPrompt`(纯函数,可离线回归)。
- `src/main/personalization.ts` + `src/main/config.ts`:`config.json.personalization` 持久化(身份、风格、开关、指令、人设、长期记忆)。
- `src/main/ipc.ts` / `src/shared/ipc.ts` / `src/preload/index.ts`:`personalization:get/update`;每轮 `promptText` 末尾附加个性化上下文,OMP 与自建循环共用同一段文本。
- `PersonalizationSettingsPanel.tsx` + `source-personalization-panel.css`:按原版卡片/行/胶囊/下拉/开关/长预览/弹窗结构复刻;身份行支持 inline 编辑、失焦丢弃草稿、空值确认禁用;长文本用弹窗 + 保存/取消;欢迎语真实控制流式加载提示(`personalization-prefs.ts` + `StreamingFooter`);「显示文件修改过程」保留原版 off 形态,但禁用并标注「暂未接入」。
- `SettingsDialog.tsx`:`personalization` 加入 supportedTabs 并渲染面板。
验证:`scripts/verify-personalization-settings.cjs`(真实隔离 Electron + 原生设置窗口,900×680)29 项断言全部通过:导航启用/选中;面板/身份卡/两行/胶囊/回复偏好卡/三行/下拉/开关/三张长卡/预览/编辑按钮几何;5 组文案与占位;回复风格弹层 240 宽、8 个标签+说明、选择「专业严谨」持久化;欢迎语关→localStorage `0`→开;未接入开关保持禁用;身份 inline 编辑 172×34、确认保存、失焦丢弃草稿;指令弹窗 640×520 + 保存/取消 68×32 + 占位文案,保存后预览与配置一致;人设弹窗带默认文本且取消不改值;`config.json.personalization` 字段正确;重开设置窗口恢复全部值;深色卡片 `rgb(31,31,31)` 与正文 `rgb(242,243,245)`;恢复浅色;无渲染器报错。报告 `.tmp/personalization-settings-V0Vev8/report.json`。
回归:`npm run typecheck`、`npm run build`、`npm run test:chat`、`npm run test:conversation`、`npm run test:conversation-ipc`、`npm run test:personalization`、`node scripts/verify-settings-nav.cjs`、`node scripts/verify-shortcuts-settings.cjs`、`node scripts/verify-appearance-settings.cjs`、`node scripts/verify-general-settings.cjs`、`node scripts/verify-desktop-appearance.cjs`、`node scripts/verify-account-menu.cjs` 全部通过。
遗留:
1. 「显示文件修改过程」尚未接入 YCode 的逐步文件变更渲染,保持原版 off 形态并禁用+「暂未接入」提示。
2. 编辑弹窗用 textarea 承载纯文本;原版是富文本编辑器(`.sc-editor-content`),富文本格式、粘贴与快捷键未复刻。
3. 原版默认人设文本已按 REA 抄录并显示在面板中,但默认值不会注入请求上下文(用户修改后才注入),避免与 YCode 自身身份设定重复冲突。
4. 个性化上下文附加在每轮 `promptText` 末尾(与用户消息同一条 prompt 发送),不是独立的 system 消息;OMP 与自建循环行为一致。
5. **采集副作用**:REA 采集时把 WorkBuddy「对你的称呼」保存成了 `CodexTmp`;原版界面不接受空值确认(空字符串与单空格均被拒绝),无法通过可见操作恢复为空。该项需要在 WorkBuddy 内另行处理。
### 14. 记忆与进化
+1
View File
@@ -32,6 +32,7 @@
"test:assistant-footer": "esbuild scripts/assistant-message-footer-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --outfile=.tmp/assistant-message-footer-regression.cjs --log-level=error && node .tmp/assistant-message-footer-regression.cjs",
"test:omp-protocol": "esbuild scripts/omp-protocol-probe.ts --bundle --platform=node --format=esm --outfile=.tmp/omp-protocol-probe.mjs --log-level=warning && node .tmp/omp-protocol-probe.mjs",
"test:conversation-ipc": "esbuild scripts/conversation-ipc-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/conversation-ipc-regression.mjs --log-level=warning && node .tmp/conversation-ipc-regression.mjs",
"test:personalization": "esbuild scripts/personalization-prompt-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/personalization-prompt-regression.mjs --log-level=warning && node .tmp/personalization-prompt-regression.mjs",
"recover:artifact-reference": "node scripts/rea/recover-artifact-reference.mjs",
"test:artifacts": "esbuild scripts/artifact-diff-regression.ts scripts/artifact-renderer-regression.tsx scripts/specialized-block-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --out-extension:.js=.cjs --outdir=.tmp/artifact-tests --log-level=warning && node .tmp/artifact-tests/artifact-diff-regression.cjs && node .tmp/artifact-tests/artifact-renderer-regression.cjs && node .tmp/artifact-tests/specialized-block-regression.cjs",
"recover:conversation-labels": "node scripts/rea/recover-conversation-labels.mjs",
@@ -0,0 +1,45 @@
/**
* TODO 13:个性化提示词拼装的离线回归。
* 覆盖默认值不产生多余上下文、身份/风格/指令/人设/长期记忆分别进入提示词、超长截断与非法风格回退。
*/
import assert from 'node:assert/strict'
import { buildPersonalizationPrompt, DEFAULT_PERSONA, normalizePersonalization } from '../src/shared/personalization'
const base = normalizePersonalization({})
assert.equal(base.tone, 'default')
assert.equal(base.loadingTips, true)
assert.equal(base.showFileChanges, false)
assert.equal(base.persona, DEFAULT_PERSONA)
assert.equal(buildPersonalizationPrompt(base), '', '默认设置不应把原版人设注入每轮提示词')
const identity = buildPersonalizationPrompt(normalizePersonalization({ userCallName: ' 小明 ', aiName: ' YCode 助手 ' }))
assert.match(identity, /^## 个性化偏好(用户设置,始终遵守)/)
assert.ok(identity.includes('- 用户希望被称呼为:小明'))
assert.ok(identity.includes('- 你的名字是:YCode 助手;自我介绍时使用这个名字。'))
assert.ok(!identity.includes(DEFAULT_PERSONA), '默认人设不应进入提示词')
const tone = buildPersonalizationPrompt(normalizePersonalization({ tone: 'professional' }))
assert.ok(tone.includes('- 回复风格:专业严谨——清晰、准确、值得信赖。'))
const custom = buildPersonalizationPrompt(normalizePersonalization({ customPrompt: '\n先给结论,再给依据。\n' }))
assert.ok(custom.includes('### 用户指令\n先给结论,再给依据。'))
const persona = buildPersonalizationPrompt(normalizePersonalization({ persona: '你是一位严谨的代码审阅者。' }))
assert.ok(persona.includes('### 人设设定\n你是一位严谨的代码审阅者。'))
const memory = buildPersonalizationPrompt(normalizePersonalization({ longTermMemory: '用户偏好 TypeScript。' }))
assert.ok(memory.includes('### 长期记忆\n用户偏好 TypeScript。'))
const long = buildPersonalizationPrompt(normalizePersonalization({ customPrompt: 'x'.repeat(8100) }))
assert.ok(long.includes('(内容过长,已截断)'))
assert.ok(long.length < 8100, '超长字段应被截断')
const fallback = normalizePersonalization({ tone: 'not-a-tone' as never })
assert.equal(fallback.tone, 'default')
assert.equal(buildPersonalizationPrompt(fallback), '')
const tips = normalizePersonalization({ loadingTips: false, showFileChanges: true })
assert.equal(tips.loadingTips, false)
assert.equal(tips.showFileChanges, true)
console.log('personalization-prompt-regression: all checks passed')
+12
View File
@@ -0,0 +1,12 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".appearance-card--light",
".appearance-card--dark"
],
"actions": [
{ "kind": "wait", "step_id": "dark-theme-stable-after-target-rediscovery", "duration_ms": 800, "frames": 4 }
]
}
@@ -0,0 +1,12 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".appearance-card--light",
".appearance-card--dark"
],
"actions": [
{ "kind": "wait", "step_id": "light-theme-stable-after-target-rediscovery", "duration_ms": 800, "frames": 4 }
]
}
@@ -0,0 +1,16 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".local-memory-edit-modal",
"#settings-local-memory-body .general-settings-panel__section-title",
"#settings-local-memory-body .general-settings-panel__section-action button",
"#settings-local-memory-body .local-memory-long__preview"
],
"actions": [
{ "kind": "click", "step_id": "cancel-persona-editor-before-memory-test", "selector": ".local-memory-edit-modal .wb-modal__footer button", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "persona-editor-closed-for-memory-test", "duration_ms": 350, "frames": 0 },
{ "kind": "click", "step_id": "open-long-memory-editor", "selector": "#settings-local-memory-body .general-settings-panel__section-action button", "index": 0, "frames": 1 },
{ "kind": "wait", "step_id": "long-memory-editor-settled", "duration_ms": 350, "frames": 1 }
]
}
@@ -0,0 +1,15 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".local-memory-edit-modal",
".local-memory-edit-modal .wb-modal__footer button",
"#settings-local-memory-body .general-settings-panel__section-title"
],
"actions": [
{ "kind": "click", "step_id": "cancel-empty-long-memory-editor", "selector": ".local-memory-edit-modal .wb-modal__footer button", "index": 0, "frames": 1 },
{ "kind": "wait", "step_id": "long-memory-editor-cancel-settled", "duration_ms": 350, "frames": 1 },
{ "kind": "scroll", "step_id": "return-panel-top-from-long-memory", "selector": "#settings-local-memory-body .general-settings-panel__section-title", "delta_y": -1200, "frames": 1 },
{ "kind": "wait", "step_id": "personalization-top-confirmed-after-memory", "duration_ms": 200, "frames": 1 }
]
}
@@ -0,0 +1,14 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".settings-navigation__item",
".appearance-card--light",
".appearance-card--dark"
],
"actions": [
{ "kind": "click", "step_id": "open-appearance-to-restore-light-by-index-3", "selector": ".settings-navigation__item", "index": 3, "frames": 0 },
{ "kind": "wait", "step_id": "appearance-dark-before-restore-confirmed", "duration_ms": 450, "frames": 1 }
]
}
@@ -0,0 +1,14 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".settings-navigation__item",
".appearance-card--light",
".appearance-card--dark"
],
"actions": [
{ "kind": "click", "step_id": "open-appearance-by-observed-nav-index-3", "selector": ".settings-navigation__item", "index": 3, "frames": 0 },
{ "kind": "wait", "step_id": "appearance-page-confirmed-before-dark-theme", "duration_ms": 450, "frames": 1 }
]
}
@@ -0,0 +1,14 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".settings-navigation__item",
".general-settings-panel.personalization-panel",
".local-memory-identity-card"
],
"actions": [
{ "kind": "click", "step_id": "open-personalization-in-dark-theme-by-observed-nav-index-5", "selector": ".settings-navigation__item", "index": 5, "frames": 0 },
{ "kind": "wait", "step_id": "personalization-dark-navigation-confirmed", "duration_ms": 500, "frames": 1 }
]
}
@@ -0,0 +1,14 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".settings-navigation__item",
".general-settings-panel.personalization-panel",
".local-memory-identity-card"
],
"actions": [
{ "kind": "click", "step_id": "return-to-personalization-in-light-theme-by-nav-index-5", "selector": ".settings-navigation__item", "index": 5, "frames": 0 },
{ "kind": "wait", "step_id": "personalization-light-final-state-confirmed", "duration_ms": 500, "frames": 1 }
]
}
@@ -0,0 +1,15 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
"#settings-local-memory-soul .general-settings-panel__section-title",
"#settings-local-memory-soul .general-settings-panel__section-action button",
"#settings-local-memory-soul .local-memory-long__preview",
".local-memory-edit-modal"
],
"actions": [
{ "kind": "scroll", "step_id": "scroll-persona-heading-to-bottom", "selector": "#settings-local-memory-soul .general-settings-panel__section-title", "delta_y": 300, "frames": 1 },
{ "kind": "click", "step_id": "retry-persona-edit-button", "selector": "#settings-local-memory-soul .general-settings-panel__section-action button", "index": 0, "frames": 1 },
{ "kind": "wait", "step_id": "persona-edit-button-retry-settled", "duration_ms": 350, "frames": 1 }
]
}
@@ -0,0 +1,16 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
"#settings-local-memory-soul .general-settings-panel__section-title",
"#settings-local-memory-soul .general-settings-panel__section-action button",
"#settings-local-memory-soul .local-memory-long__preview",
".local-memory-edit-modal"
],
"actions": [
{ "kind": "scroll", "step_id": "scroll-to-persona-button", "selector": ".settings-modal__panel", "delta_y": 300, "frames": 0 },
{ "kind": "scroll", "step_id": "scroll-persona-button-visible", "selector": "#settings-local-memory-soul .general-settings-panel__section-title", "delta_y": 300, "frames": 0 },
{ "kind": "click", "step_id": "activate-persona-edit-button", "selector": "#settings-local-memory-soul .general-settings-panel__section-action button", "index": 0, "frames": 1 },
{ "kind": "wait", "step_id": "persona-edit-button-settled", "duration_ms": 350, "frames": 1 }
]
}
@@ -0,0 +1,20 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
"#settings-local-memory-soul .general-settings-panel__section-title",
"#settings-local-memory-soul .general-settings-panel__section-action button",
".local-memory-edit-modal .wb-modal__footer button"
],
"actions": [
{ "kind": "scroll", "step_id": "scroll-to-persona-editor", "selector": ".settings-modal__panel", "delta_y": 300, "frames": 0 },
{ "kind": "scroll", "step_id": "scroll-persona-section-into-view", "selector": "#settings-local-memory-soul .general-settings-panel__section-title", "delta_y": 300, "frames": 0 },
{ "kind": "click", "step_id": "open-persona-editor", "selector": "#settings-local-memory-soul .general-settings-panel__section-action button", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "persona-editor-open", "duration_ms": 350, "frames": 1 },
{ "kind": "click", "step_id": "cancel-persona-editor-without-change", "selector": ".local-memory-edit-modal .wb-modal__footer button", "index": 0, "frames": 1 },
{ "kind": "wait", "step_id": "persona-editor-cancelled", "duration_ms": 350, "frames": 1 },
{ "kind": "scroll", "step_id": "return-personalization-top-after-editor", "selector": "#settings-local-memory-soul .general-settings-panel__section-title", "delta_y": -1200, "frames": 1 },
{ "kind": "wait", "step_id": "personalization-top-confirmed", "duration_ms": 200, "frames": 1 }
]
}
@@ -0,0 +1,16 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".local-memory-edit-modal",
".local-memory-edit-modal .wb-modal__footer",
".local-memory-edit-modal .wb-modal__footer button",
"#settings-local-memory-soul .general-settings-panel__section-title"
],
"actions": [
{ "kind": "click", "step_id": "cancel-persona-editor", "selector": ".local-memory-edit-modal .wb-modal__footer button", "index": 0, "frames": 1 },
{ "kind": "wait", "step_id": "persona-editor-cancel-settled", "duration_ms": 300, "frames": 1 },
{ "kind": "scroll", "step_id": "restore-personalization-panel-top", "selector": "#settings-local-memory-soul .general-settings-panel__section-title", "delta_y": -1200, "frames": 1 },
{ "kind": "wait", "step_id": "personalization-panel-top-after-persona-editor", "duration_ms": 200, "frames": 1 }
]
}
@@ -0,0 +1,13 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
"#settings-local-memory-soul .local-memory-long__preview",
"#settings-local-memory-soul .general-settings-panel__section-action button"
],
"actions": [
{ "kind": "click", "step_id": "click-persona-preview-role-button", "selector": "#settings-local-memory-soul .local-memory-long__preview", "index": 0, "frames": 1 },
{ "kind": "wait", "step_id": "persona-preview-click-settled", "duration_ms": 350, "frames": 1 }
]
}
@@ -0,0 +1,16 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
"#settings-local-memory-ai-name",
"#settings-local-memory-ai-name input",
"#settings-local-memory-ai-name .local-memory-identity-row__actions"
],
"actions": [
{ "kind": "click", "step_id": "open-ai-name-inline-editor", "selector": "#settings-local-memory-ai-name .local-memory-pill", "index": 0, "frames": 1 },
{ "kind": "text", "step_id": "enter-temporary-ai-name-draft", "selector": "#settings-local-memory-ai-name input", "text": "CodexTmp", "frames": 1 },
{ "kind": "click", "step_id": "cancel-ai-name-draft", "selector": "#settings-local-memory-ai-name .local-memory-identity-row__actions button", "index": 0, "frames": 1 },
{ "kind": "wait", "step_id": "confirm-ai-name-remains-original-empty", "duration_ms": 200, "frames": 1 }
]
}
@@ -0,0 +1,12 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".local-memory-edit-modal",
".local-memory-edit-modal .wb-modal__footer",
".local-memory-edit-modal .wb-modal__footer button"
],
"actions": [
{ "kind": "click", "step_id": "cancel-workbuddy-instructions-editor", "selector": ".local-memory-edit-modal .wb-modal__footer button", "index": 0, "frames": 1 }
]
}
@@ -0,0 +1,13 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
"#settings-custom-prompt",
"#settings-custom-prompt .general-settings-panel__section-action button"
],
"actions": [
{ "kind": "click", "step_id": "open-workbuddy-instructions-editor", "selector": "#settings-custom-prompt .general-settings-panel__section-action button", "index": 0, "frames": 1 },
{ "kind": "key", "step_id": "close-workbuddy-instructions-editor-with-escape", "key": "Escape", "frames": 1 }
]
}
@@ -0,0 +1,15 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".local-memory-edit-modal",
".local-memory-edit-modal .local-memory-editor-host",
".local-memory-edit-modal .wb-modal__footer button"
],
"actions": [
{ "kind": "click", "step_id": "open-empty-workbuddy-instructions-editor", "selector": "#settings-custom-prompt .general-settings-panel__section-action button", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "empty-instructions-editor-ready", "duration_ms": 300, "frames": 1 },
{ "kind": "click", "step_id": "save-unchanged-empty-instructions", "selector": ".local-memory-edit-modal .wb-modal__footer button", "index": 1, "frames": 1 },
{ "kind": "wait", "step_id": "instructions-save-result", "duration_ms": 300, "frames": 1 }
]
}
@@ -0,0 +1,16 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".local-memory-pill",
"#settings-local-memory-user-call-name .local-memory-pill",
"#settings-local-memory-ai-name .local-memory-pill"
],
"actions": [
{ "kind": "click", "step_id": "open-user-call-name-editor", "selector": "#settings-local-memory-user-call-name .local-memory-pill", "index": 0, "frames": 1 },
{ "kind": "key", "step_id": "close-user-call-name-editor-with-escape", "key": "Escape", "frames": 1 },
{ "kind": "click", "step_id": "open-ai-name-editor", "selector": "#settings-local-memory-ai-name .local-memory-pill", "index": 0, "frames": 1 },
{ "kind": "key", "step_id": "close-ai-name-editor-with-escape", "key": "Escape", "frames": 1 }
]
}
@@ -0,0 +1,22 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".general-settings-panel.personalization-panel",
"#settings-local-memory-soul .general-settings-panel__section-title",
"#settings-local-memory-soul .local-memory-long__preview"
],
"actions": [
{ "kind": "wait", "step_id": "personalization-full-page-top", "duration_ms": 200, "frames": 1 },
{ "kind": "scroll", "step_id": "scroll-page-to-persona-section", "selector": ".settings-modal__panel", "delta_y": 300, "frames": 1 },
{ "kind": "scroll", "step_id": "scroll-page-to-bottom-on-visible-title", "selector": "#settings-local-memory-soul .general-settings-panel__section-title", "delta_y": 300, "frames": 1 },
{ "kind": "scroll", "step_id": "persona-preview-text-1", "selector": "#settings-local-memory-soul .local-memory-long__preview", "delta_y": 200, "frames": 1 },
{ "kind": "scroll", "step_id": "persona-preview-text-2", "selector": "#settings-local-memory-soul .local-memory-long__preview", "delta_y": 200, "frames": 1 },
{ "kind": "scroll", "step_id": "persona-preview-text-3", "selector": "#settings-local-memory-soul .local-memory-long__preview", "delta_y": 200, "frames": 1 },
{ "kind": "scroll", "step_id": "persona-preview-text-4-bottom", "selector": "#settings-local-memory-soul .local-memory-long__preview", "delta_y": 200, "frames": 1 },
{ "kind": "scroll", "step_id": "persona-preview-return-top", "selector": "#settings-local-memory-soul .local-memory-long__preview", "delta_y": -1600, "frames": 1 },
{ "kind": "scroll", "step_id": "return-page-to-top-from-visible-title", "selector": "#settings-local-memory-soul .general-settings-panel__section-title", "delta_y": -1200, "frames": 1 },
{ "kind": "wait", "step_id": "personalization-full-page-returned-top", "duration_ms": 200, "frames": 1 }
]
}
@@ -0,0 +1,15 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
"#settings-local-memory-user-call-name",
"#settings-local-memory-user-call-name .local-memory-identity-row__actions",
"#settings-tone-style"
],
"actions": [
{ "kind": "click", "step_id": "open-user-call-name-inline-editor", "selector": "#settings-local-memory-user-call-name .local-memory-pill", "index": 0, "frames": 1 },
{ "kind": "text", "step_id": "enter-temporary-call-name", "selector": "#settings-local-memory-user-call-name input", "text": "CodexTmp", "frames": 1 },
{ "kind": "click", "step_id": "blur-call-name-editor", "selector": "#settings-tone-style .general-settings-panel__section-title", "index": 0, "frames": 1 }
]
}
@@ -0,0 +1,14 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
"#settings-local-memory-user-call-name",
"#settings-local-memory-user-call-name input",
"#settings-local-memory-user-call-name .local-memory-identity-row__actions"
],
"actions": [
{ "kind": "click", "step_id": "edit-saved-user-call-name", "selector": "#settings-local-memory-user-call-name .local-memory-pill", "index": 0, "frames": 1 },
{ "kind": "click", "step_id": "test-first-identity-action", "selector": "#settings-local-memory-user-call-name .local-memory-identity-row__actions button", "index": 0, "frames": 1 }
]
}
@@ -0,0 +1,13 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
"#settings-local-memory-user-call-name",
"#settings-local-memory-user-call-name .local-memory-pill",
"#settings-local-memory-user-call-name .local-memory-identity-row__actions"
],
"actions": [
{ "kind": "click", "step_id": "close-empty-identity-editor", "selector": "#settings-local-memory-user-call-name .local-memory-identity-row__actions button", "index": 0, "frames": 1 }
]
}
@@ -0,0 +1,11 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
"#settings-local-memory-user-call-name .local-memory-pill"
],
"actions": [
{ "kind": "hover", "step_id": "inspect-saved-identity-hover-actions", "selector": "#settings-local-memory-user-call-name .local-memory-pill", "index": 0, "frames": 1 }
]
}
@@ -0,0 +1,15 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
"#settings-local-memory-user-call-name",
"#settings-local-memory-user-call-name input",
"#settings-local-memory-user-call-name .local-memory-identity-row__actions"
],
"actions": [
{ "kind": "click", "step_id": "reopen-saved-user-call-name", "selector": "#settings-local-memory-user-call-name .local-memory-pill", "index": 0, "frames": 1 },
{ "kind": "text", "step_id": "clear-user-call-name-to-original-empty", "selector": "#settings-local-memory-user-call-name input", "text": "", "frames": 1 },
{ "kind": "click", "step_id": "save-original-empty-user-call-name", "selector": "#settings-local-memory-user-call-name .local-memory-identity-row__actions button", "index": 1, "frames": 1 }
]
}
@@ -0,0 +1,15 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
"#settings-local-memory-user-call-name",
"#settings-local-memory-user-call-name input",
"#settings-local-memory-user-call-name .local-memory-identity-row__actions"
],
"actions": [
{ "kind": "click", "step_id": "reopen-user-call-name-editor", "selector": "#settings-local-memory-user-call-name .local-memory-pill", "index": 0, "frames": 1 },
{ "kind": "text", "step_id": "set-temporary-user-call-name", "selector": "#settings-local-memory-user-call-name input", "text": "CodexTmp", "frames": 1 },
{ "kind": "click", "step_id": "save-temporary-user-call-name", "selector": "#settings-local-memory-user-call-name .local-memory-identity-row__actions button", "index": 1, "frames": 1 }
]
}
@@ -0,0 +1,19 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".personalization-panel__tone-trigger",
"#settings-loading-tips .wb-switch__control",
"#settings-file-changes .wb-switch__control"
],
"actions": [
{ "kind": "wait", "step_id": "control-baseline", "duration_ms": 200, "frames": 1 },
{ "kind": "click", "step_id": "open-reply-style-dropdown", "selector": ".personalization-panel__tone-trigger", "index": 0, "frames": 1 },
{ "kind": "key", "step_id": "close-reply-style-dropdown", "key": "Escape", "frames": 1 },
{ "kind": "click", "step_id": "toggle-welcome-off", "selector": "#settings-loading-tips .wb-switch__control", "index": 0, "frames": 1 },
{ "kind": "click", "step_id": "restore-welcome-on", "selector": "#settings-loading-tips .wb-switch__control", "index": 0, "frames": 1 },
{ "kind": "click", "step_id": "toggle-file-changes-on", "selector": "#settings-file-changes .wb-switch__control", "index": 0, "frames": 1 },
{ "kind": "click", "step_id": "restore-file-changes-off", "selector": "#settings-file-changes .wb-switch__control", "index": 0, "frames": 1 }
]
}
@@ -0,0 +1,11 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
"#settings-custom-prompt .general-settings-panel__section-action button"
],
"actions": [
{ "kind": "click", "step_id": "open-custom-prompt-editor-for-layout", "selector": "#settings-custom-prompt .general-settings-panel__section-action button", "index": 0, "frames": 1 }
]
}
@@ -0,0 +1,14 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
"html",
".settings-modal__panel",
".settings-navigation__item",
".personalization-settings-panel"
],
"actions": [
{ "kind": "click", "step_id": "select-personalization-by-observed-nav-order", "selector": ".settings-navigation__item", "index": 5, "frames": 0 },
{ "kind": "wait", "step_id": "personalization-navigation-confirmation", "duration_ms": 500, "frames": 1 }
]
}
@@ -0,0 +1,14 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".general-settings-panel__section-title",
"#settings-local-memory-soul .general-settings-panel__section-title",
"#settings-local-memory-body .general-settings-panel__section-title"
],
"actions": [
{ "kind": "scroll", "step_id": "return-panel-to-top-from-visible-heading", "selector": "#settings-local-memory-soul .general-settings-panel__section-title", "delta_y": -1200, "frames": 1 },
{ "kind": "wait", "step_id": "confirm-personalization-top", "duration_ms": 200, "frames": 1 }
]
}
@@ -0,0 +1,17 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".general-settings-panel.personalization-panel",
".general-settings-panel__group",
".local-memory-long-card"
],
"actions": [
{ "kind": "wait", "step_id": "personalization-top", "duration_ms": 250, "frames": 1 },
{ "kind": "scroll", "step_id": "personalization-scroll-1", "selector": ".settings-modal__panel", "delta_y": 300, "frames": 1 },
{ "kind": "scroll", "step_id": "personalization-scroll-2", "selector": ".settings-modal__panel", "delta_y": 300, "frames": 1 },
{ "kind": "scroll", "step_id": "personalization-scroll-3-bottom", "selector": ".settings-modal__panel", "delta_y": 300, "frames": 1 },
{ "kind": "scroll", "step_id": "personalization-return-top", "selector": ".settings-modal__panel", "delta_y": -1200, "frames": 1 }
]
}
@@ -0,0 +1,25 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
"#settings-custom-prompt .local-memory-long__preview",
"#settings-local-memory-soul .general-settings-panel__section-title",
"#settings-local-memory-body .local-memory-long__preview",
".local-memory-edit-modal"
],
"actions": [
{ "kind": "click", "step_id": "click-instructions-preview-role-button", "selector": "#settings-custom-prompt .local-memory-long__preview", "index": 0, "frames": 1 },
{ "kind": "wait", "step_id": "instructions-preview-editor-open", "duration_ms": 300, "frames": 1 },
{ "kind": "click", "step_id": "cancel-instructions-preview-editor", "selector": ".local-memory-edit-modal .wb-modal__footer button", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "instructions-preview-editor-closed", "duration_ms": 350, "frames": 1 },
{ "kind": "scroll", "step_id": "scroll-to-bottom-memory-card-1", "selector": ".settings-modal__panel", "delta_y": 300, "frames": 1 },
{ "kind": "scroll", "step_id": "scroll-to-bottom-memory-card-2", "selector": "#settings-local-memory-soul .general-settings-panel__section-title", "delta_y": 300, "frames": 1 },
{ "kind": "click", "step_id": "click-long-memory-preview-role-button", "selector": "#settings-local-memory-body .local-memory-long__preview", "index": 0, "frames": 1 },
{ "kind": "wait", "step_id": "long-memory-preview-editor-open", "duration_ms": 300, "frames": 1 },
{ "kind": "click", "step_id": "cancel-long-memory-preview-editor", "selector": ".local-memory-edit-modal .wb-modal__footer button", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "long-memory-preview-editor-closed", "duration_ms": 350, "frames": 1 },
{ "kind": "scroll", "step_id": "return-panel-top-after-preview-checks", "selector": "#settings-local-memory-body .general-settings-panel__section-title", "delta_y": -1200, "frames": 1 },
{ "kind": "wait", "step_id": "top-after-preview-checks-confirmed", "duration_ms": 200, "frames": 1 }
]
}
@@ -0,0 +1,15 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
"#settings-local-memory-user-call-name",
"#settings-local-memory-user-call-name input",
"#settings-local-memory-user-call-name .local-memory-pill"
],
"actions": [
{ "kind": "click", "step_id": "open-saved-call-name", "selector": "#settings-local-memory-user-call-name .local-memory-pill", "index": 0, "frames": 1 },
{ "kind": "text", "step_id": "replace-with-single-space", "selector": "#settings-local-memory-user-call-name input", "text": " ", "frames": 1 },
{ "kind": "click", "step_id": "confirm-space-value", "selector": "#settings-local-memory-user-call-name .local-memory-identity-row__actions button", "index": 1, "frames": 2 }
]
}
@@ -0,0 +1,8 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [],
"actions": [
{ "kind": "click", "step_id": "restore-light-theme-for-final-state", "selector": ".appearance-card--light", "index": 0, "frames": 0 }
]
}
@@ -0,0 +1,8 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [],
"actions": [
{ "kind": "click", "step_id": "select-dark-theme-for-personalization-sample", "selector": ".appearance-card--dark", "index": 0, "frames": 0 }
]
}
@@ -0,0 +1,17 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".personalization-panel__tone-trigger",
".personalization-panel__tone-popover",
".wb-select__option",
".personalization-panel__option-name",
".personalization-panel__option-hint"
],
"actions": [
{ "kind": "click", "step_id": "open-reply-style-dropdown-for-all-options", "selector": ".personalization-panel__tone-trigger", "index": 0, "frames": 1 },
{ "kind": "scroll", "step_id": "reply-style-dropdown-lower-options", "selector": ".personalization-panel__tone-popover", "delta_y": 300, "frames": 1 },
{ "kind": "scroll", "step_id": "reply-style-dropdown-return-to-top", "selector": ".personalization-panel__tone-popover", "delta_y": -300, "frames": 1 },
{ "kind": "key", "step_id": "close-reply-style-dropdown-after-sampling", "key": "Escape", "frames": 1 }
]
}
@@ -0,0 +1,85 @@
// Recover only already-retained REA CSS; never access the installed application here.
// Source: REA evidence ev_af920edf33af5e811d58f05dad15210b29cd891f28e0fa74f6b18d63212241b1
// (WorkBuddy 5.8.3 设置 → 个性化), PersonalizationPanel-D16rRGjy.css.
import fs from 'node:fs/promises'
import { createHash } from 'node:crypto'
import postcss from 'postcss'
const sha = value => createHash('sha256').update(value).digest('hex')
const evidenceId = 'ev_af920edf33af5e811d58f05dad15210b29cd891f28e0fa74f6b18d63212241b1'
const evidence = JSON.parse(await fs.readFile(`.tmp/rea-css-evidence/${evidenceId}.json`, 'utf8'))
if (evidence.evidence_id !== evidenceId) throw new Error('REA identity mismatch')
const module = evidence.normalized_result.styles.items.find(item => /PersonalizationPanel-D16rRGjy\.css$/.test(item.file_path))
if (!module) throw new Error('Personalization panel module CSS is absent from the evidence')
const moduleCss = module.source.artifact.text
const moduleSha = sha(moduleCss)
if (moduleSha !== '318664f96253adace047d7b1d2d213fbf6139461c10d80ae12135998b7b98f3d') throw new Error('Personalization panel module CSS changed')
const tokens = await fs.readFile('.tmp/rea-css-recovered/tokens-Df-i-zsO.css', 'utf8')
const tokensSha = sha(tokens)
if (tokensSha !== '13f2bf82c96e5167a7d7098e36950c6c22c475b3337dbb8bb29ad2fe79cde696') throw new Error('Tokens CSS changed')
const lightTokens = new Map()
const darkTokens = new Map()
for (const rule of postcss.parse(tokens).nodes) {
if (rule.type !== 'rule') continue
const selectors = rule.selectors ?? []
const dark = selectors.some(selector => selector.includes('[data-theme=dark]'))
const light = selectors.some(selector => selector.trim() === ':root')
if (!dark && !light) continue
for (const node of rule.nodes ?? []) {
if (node.type !== 'decl' || !node.prop.startsWith('--')) continue
if (dark) darkTokens.set(node.prop, node.value)
if (light) lightTokens.set(node.prop, node.value)
}
}
const relatedSelector = /\.(?:personalization-panel|local-memory)(?:[\w-]*)?(?![\w-])/i
const darkScope = /^\s*(?:\[data-theme=["']?dark["']?\]|body\[data-vscode-theme-name=["']IDE Night["']\]|body\.vscode-dark|\.vscode-dark|body\.dark|\.dark)(?=\s|$)/i
function project(node) {
if (node.type === 'rule') {
const selectors = []
for (const selector of node.selectors) {
if (!relatedSelector.test(selector)) continue
const mapped = selector.replace(darkScope, '[data-theme=dark]')
if (!selectors.includes(mapped)) selectors.push(mapped)
}
return selectors.length ? node.clone({ selector: selectors.join(',') }) : null
}
if (node.type === 'atrule' && node.nodes) {
const nodes = node.nodes.map(project).filter(Boolean)
return nodes.length ? node.clone({ nodes }) : null
}
return null
}
const root = postcss.root({ nodes: postcss.parse(moduleCss).nodes.map(project).filter(Boolean) })
const referenced = new Set()
const collect = text => {
for (const match of text.matchAll(/var\(\s*(--[\w-]+)/g)) referenced.add(match[1])
}
collect(root.toString())
for (let depth = 0; depth < 8; depth++) {
const before = referenced.size
for (const name of [...referenced]) {
for (const value of [lightTokens.get(name), darkTokens.get(name)]) {
if (typeof value === 'string') collect(value)
}
}
if (referenced.size === before) break
}
const missingLight = [...referenced].filter(name => !lightTokens.has(name))
if (missingLight.length) throw new Error(`Tokens missing from light root scope: ${missingLight.join(', ')}`)
const pick = map => new Map([...referenced].map(name => [name, map.get(name) ?? lightTokens.get(name)]))
const scope = (selector, map) => `${selector}{${[...map].map(([property, value]) => `${property}:${value}`).join(';')}}`
const scopedTokens = `${scope('.general-settings-panel.personalization-panel', pick(lightTokens))}\n${scope(':root[data-theme=dark] .general-settings-panel.personalization-panel', pick(darkTokens))}\n`
const css = `/* REA ${evidenceId}; PersonalizationPanel-D16rRGjy.css sha256=${moduleSha}; tokens sha256=${tokensSha}. */\n${root.toString()}\n${scopedTokens}`
await fs.writeFile('src/renderer/src/components/source-personalization-panel-recovered.css', css)
const rules = []
root.walkRules(rule => rules.push(rule.selector))
console.log(`Recovered ${rules.length} personalization rules (${Buffer.byteLength(css)} bytes); scoped ${pick(lightTokens).size} light / ${pick(darkTokens).size} dark tokens`)
console.log(rules.slice(0, 16).join('\n'))
if (rules.length < 30) throw new Error('Unexpectedly few personalization rules')
+333
View File
@@ -0,0 +1,333 @@
// TODO 13 验收:设置 → 个性化页的 1:1 结构/几何 + 身份编辑/回复风格/开关/长文本弹窗 + 持久化与主题。
// 真实隔离 YCode(真实 preload/IPC/渲染进程/文件系统)+ 原生设置窗口,视口 900×680(与原版一致)。
//
// 原版依据(REA ev_af920…/ev_88af07…/ev_d6e0a2…/ev_523e07…/ev_4f4f76…):
// 面板 [232,40,638,1160];身份卡 638×100(两行 [244,48,614,34]、胶囊 172×34);
// 回复偏好卡 638×204(回复风格 select 120×32 @734,174;开关 34×20 @820,248/316);
// 指令/人设/长期记忆三张长卡 638×264(y=376/656/936),预览 598×160;编辑弹窗 [130,80,640,520]。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const TONE_LABELS = ['默认', '专业严谨', '亲和友善', '直言不讳', '天马行空', '高效务实', '毒舌吐槽', '启发引导']
const TONE_HINTS = ['不设定特定风格。', '清晰、准确、值得信赖。', '温暖、平易近人、鼓励支持。', '简明扼要、不废话、直击要点。',
'富有想象力、善用比喻类比。', '最少文字、最大信息量。', '犀利吐槽、但绝不伤人。', '用提问引导思考、授人以渔。']
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/personalization-settings-'))
const data = path.join(directory, 'data'), home = path.join(directory, 'home')
await fs.mkdir(data, { recursive: true })
await fs.mkdir(home, { recursive: true })
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'),
['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('PERSONALIZATION_SETTINGS_')) process.stdout.write(bytes) })
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 300000)
child.once('exit', async code => {
clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code }))
if (code) console.error(output.slice(-4000))
process.exitCode = code ? 1 : 0
})
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow } = require('electron')
const { pathToFileURL } = require('node:url')
const directory = process.env.YCODE_QA_DIRECTORY
app.setPath('home', path.join(directory, 'home'))
const checks = [], snapshots = {}, rendererErrors = []
let main, settings, phase = 'boot'
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
async function read(source, win = main) {
let timer
try {
return await Promise.race([win.webContents.executeJavaScript(source), new Promise((_, reject) => {
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 15000)
})])
} finally { clearTimeout(timer) }
}
async function wait(source, label, win = main) {
for (let index = 0; index < 300; index++) { const value = await read(source, win); if (value) return value; await pause(40) }
throw Error('Timeout: ' + label)
}
async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) }
function check(label, actual, expected) {
const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected)
checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected })
}
const near = (value, target, tolerance = 0.75) => Math.abs(value - target) <= tolerance
function observe(win) {
win.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) rendererErrors.push({ windowId: win.id, message: event.message }) })
}
async function openSettings(section) {
await read(`window.ycode.app.openSettings(${JSON.stringify(section)})`)
for (let index = 0; index < 200; index++) {
settings = BrowserWindow.getAllWindows().find(win => win !== main)
if (settings && !settings.webContents.isLoading()) break
await pause(25)
}
assert.ok(settings, 'settings window')
await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings)
await pause(400)
return settings
}
const setInput = (selector, value) => `(()=>{const input=document.querySelector(${JSON.stringify(selector)})
const proto=input instanceof HTMLTextAreaElement?HTMLTextAreaElement.prototype:HTMLInputElement.prototype
Object.getOwnPropertyDescriptor(proto,'value').set.call(input,${JSON.stringify(value)})
input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`
const layoutSource = `(()=>{
const round = value => Math.round(value * 1000) / 1000
const rect = node => { if (!node) return null; const r = node.getBoundingClientRect()
return { x: round(r.x), y: round(r.y), width: round(r.width), height: round(r.height) } }
const panel = document.querySelector('.personalization-panel')
return {
panel: rect(panel),
identityCard: rect(document.querySelector('.local-memory-identity-card')),
rows: [...document.querySelectorAll('.local-memory-identity-row')].map(row => ({
id: row.id,
label: row.querySelector('.local-memory-identity-row__label')?.textContent?.trim(),
rect: rect(row),
pill: (() => { const node = row.querySelector('.local-memory-pill')
return node ? { rect: rect(node), text: node.textContent.trim(), placeholder: !!node.querySelector('.local-memory-pill__placeholder') } : null })(),
input: (() => { const node = row.querySelector('.local-memory-inline__input')
return node ? { rect: rect(node), value: node.value } : null })(),
actions: row.querySelectorAll('.local-memory-inline__icon-btn').length,
confirmDisabled: row.querySelector('.local-memory-inline__icon-btn--confirm')?.disabled ?? null
})),
preference: rect(document.querySelector('.general-settings-panel__group-card')),
sections: [...document.querySelectorAll('.general-settings-panel__group-card > .general-settings-panel__section')].map(section => ({
id: section.id,
title: section.querySelector('.general-settings-panel__section-title')?.textContent?.trim(),
desc: section.querySelector('.general-settings-panel__section-desc')?.textContent?.trim(),
rect: rect(section),
select: rect(section.querySelector('.wb-select')),
switch: rect(section.querySelector('.wb-switch__track')),
switchChecked: section.querySelector('.wb-switch__input')?.checked ?? null,
switchDisabled: section.querySelector('.wb-switch__input')?.disabled ?? null,
unavailable: section.querySelector('[data-settings-unavailable=true]')?.getAttribute('title')
})),
longCards: [...document.querySelectorAll('.local-memory-long-card')].map(card => ({
id: card.id,
rect: rect(card),
title: card.querySelector('.general-settings-panel__section-title')?.textContent?.trim(),
desc: card.querySelector('.general-settings-panel__section-desc')?.textContent?.trim(),
edit: rect(card.querySelector('.general-settings-panel__section-action .wb-button')),
preview: rect(card.querySelector('.local-memory-long__preview')),
text: card.querySelector('.local-memory-long__value,.local-memory-long__placeholder')?.textContent?.trim(),
placeholder: !!card.querySelector('.local-memory-long__placeholder')
})),
toneLabel: document.querySelector('.personalization-panel__tone-trigger .wb-select__value')?.textContent?.trim(),
popover: rect(document.querySelector('.personalization-panel__tone-popover')),
options: [...document.querySelectorAll('.personalization-panel__tone-popover [role=option]')].map(option => ({
name: option.querySelector('.personalization-panel__option-name')?.textContent?.trim(),
hint: option.querySelector('.personalization-panel__option-hint')?.textContent?.trim(),
selected: option.getAttribute('aria-selected')
})),
modal: rect(document.querySelector('.local-memory-edit-modal')),
modalTitle: document.querySelector('.local-memory-edit-modal .wb-modal__title')?.textContent?.trim(),
editorValue: document.querySelector('.local-memory-editor')?.value ?? null,
editorPlaceholder: document.querySelector('.local-memory-editor')?.getAttribute('placeholder') ?? null,
modalButtons: [...document.querySelectorAll('.local-memory-edit-modal .wb-modal__footer .wb-button')].map(button => ({ text: button.textContent.trim(), rect: rect(button) })),
rowBackground: getComputedStyle(document.querySelector('.local-memory-identity-card')).backgroundColor,
rowColor: getComputedStyle(document.querySelector('.local-memory-identity-row__label')).color,
loadingTips: localStorage.getItem('ycode:loading-tips'),
theme: document.documentElement.dataset.theme ?? 'light'
}})()`
;(async () => {
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href); await app.whenReady()
for (let index = 0; index < 200; index++) {
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
if (main && !main.webContents.isLoading()) break
await pause(25)
}
assert.ok(main, 'main window'); observe(main)
await wait('!!window.ycode', 'preload api')
await step('open-personalization-settings')
await openSettings('personalization'); observe(settings)
await wait('!!document.querySelector(".personalization-panel")', 'personalization panel', settings)
const nav = await read(`(()=>{const item=document.querySelector('[data-settings-tab="personalization"]')
return { exists: !!item, disabled: item?.getAttribute('aria-disabled'), active: document.querySelector('.settings-navigation__item--active')?.dataset.settingsTab }})()`, settings)
check('个性化 nav entry is enabled and active', [nav.exists, nav.disabled !== 'true', nav.active], [true, true, 'personalization'])
const light = await read(layoutSource, settings)
snapshots.light = light
check('panel keeps the recovered 638x1160 box at 232,40',
[near(light.panel.x, 232), near(light.panel.y, 40), near(light.panel.width, 638), near(light.panel.height, 1160)], [true, true, true, true])
check('identity card keeps 638x100 with two 614x34 rows',
[near(light.identityCard.width, 638), near(light.identityCard.height, 100),
near(light.rows[0].rect.y, 48), near(light.rows[0].rect.width, 614), near(light.rows[0].rect.height, 34),
near(light.rows[1].rect.y, 98)], [true, true, true, true, true, true])
check('identity rows keep the recovered labels and empty pills',
[light.rows.map(row => row.label), light.rows.every(row => row.pill?.text === '暂无内容,点击编辑添加' && row.pill?.placeholder),
light.rows.every(row => row.pill && near(row.pill.rect.x, 686) && near(row.pill.rect.width, 172) && near(row.pill.rect.height, 34))],
[['YCode 对你的称呼', 'YCode 的名字'], true, true])
check('reply preference card keeps 638x204 with three recovered rows',
[near(light.preference.y, 156), near(light.preference.width, 638), near(light.preference.height, 204),
light.sections.map(section => section.title)], [true, true, true, ['回复风格', '欢迎语', '显示文件修改过程']])
check('reply style select keeps 120x32 and the default tone',
[near(light.sections[0].select.x, 734), near(light.sections[0].select.y, 174), near(light.sections[0].select.width, 120),
near(light.sections[0].select.height, 32), light.toneLabel], [true, true, true, true, '默认'])
check('switches keep 34x20 with 欢迎语 on and 显示文件修改过程 disabled',
[[near(light.sections[1].switch.width, 34), near(light.sections[1].switch.height, 20), light.sections[1].switchChecked].every(Boolean),
light.sections[2].switchChecked === false && light.sections[2].switchDisabled === true && light.sections[2].unavailable === '暂未接入'], [true, true])
check('three long cards keep 638x264 at y=376/656/936',
light.longCards.map((card, index) => [near(card.rect.y, [376, 656, 936][index]), near(card.rect.width, 638), near(card.rect.height, 264)].every(Boolean)),
[true, true, true])
check('long cards keep the recovered titles, descriptions and 598x160 previews',
[light.longCards.map(card => card.title), light.longCards.map(card => card.desc),
light.longCards.every(card => near(card.preview.width, 598) && near(card.preview.height, 160)),
light.longCards.every(card => card.edit && near(card.edit.width, 68) && near(card.edit.height, 32))], [
['给 YCode 的指令', '人设设定', '长期记忆'],
['写下你希望 YCode 一直遵守的要求,之后每次对话和任务都会默认生效。', '设定 YCode 的身份、性格与表达风格,对所有回复生效。', '跨会话保存的重要信息与结论,可随时查看和修改。'],
true, true])
check('long previews keep their original content and placeholders',
[light.longCards[0].placeholder, light.longCards[0].text, light.longCards[1].text?.startsWith("You're not a chatbot."), light.longCards[2].placeholder],
[true, '例如:回答尽量简洁,先给结论', true, true])
await step('personalization-tone')
await read(`document.querySelector('.personalization-panel__tone-trigger').click()`, settings)
const popover = await wait(`(()=>{const node=document.querySelector('.personalization-panel__tone-popover')
return node?{rect:(()=>{const r=node.getBoundingClientRect();return {x:r.x,width:r.width}})(),options:[...node.querySelectorAll('[role=option]')].length}:null})()`, 'tone popover', settings)
check('回复风格 popover keeps the recovered 240px width and 8 options', [near(popover.rect.width, 240), popover.options], [true, 8])
const options = await read(`[...document.querySelectorAll('.personalization-panel__tone-popover [role=option]')].map(option => [
option.querySelector('.personalization-panel__option-name')?.textContent?.trim(),
option.querySelector('.personalization-panel__option-hint')?.textContent?.trim()])`, settings)
check('tone options keep every recovered label and hint', [options.map(item => item[0]), options.map(item => item[1])], [TONE_LABELS, TONE_HINTS])
await read(`[...document.querySelectorAll('.personalization-panel__tone-popover [role=option]')][1].click()`, settings)
await wait(`document.querySelector('.personalization-panel__tone-trigger .wb-select__value')?.textContent?.trim()==='专业严谨'`, 'tone applied', settings)
const toneStored = await read(`window.ycode.personalization.get().then(view => view.tone)`, settings)
check('choosing 专业严谨 persists the tone', toneStored, 'professional')
await step('personalization-switches')
await read(`document.querySelector('#settings-loading-tips .wb-switch__input').click()`, settings)
await wait(`localStorage.getItem('ycode:loading-tips')==='0'`, 'loading tips mirror', settings)
check('欢迎语 switches off, persists and mirrors to the renderer',
[await read(`window.ycode.personalization.get().then(view => view.loadingTips)`, settings), await read(`localStorage.getItem('ycode:loading-tips')`, settings)], [false, '0'])
await read(`document.querySelector('#settings-loading-tips .wb-switch__input').click()`, settings)
await wait(`localStorage.getItem('ycode:loading-tips')==='1'`, 'loading tips restored', settings)
check('欢迎语 switches back on', await read(`window.ycode.personalization.get().then(view => view.loadingTips)`, settings), true)
await read(`document.querySelector('#settings-file-changes .wb-switch__input').click()`, settings)
check('显示文件修改过程 stays disabled and keeps the stored default', await read(`window.ycode.personalization.get().then(view => view.showFileChanges)`, settings), false)
await step('personalization-identity')
await read(`document.querySelector('#settings-local-memory-user-call-name .local-memory-pill').click()`, settings)
const editing = await wait(`(()=>{const row=document.querySelector('#settings-local-memory-user-call-name')
const input=row.querySelector('.local-memory-inline__input')
if(!input)return null
const r=input.getBoundingClientRect()
return { width:r.width, height:r.height, actions:row.querySelectorAll('.local-memory-inline__icon-btn').length,
confirmDisabled:row.querySelector('.local-memory-inline__icon-btn--confirm')?.disabled }})()`, 'identity editing', settings)
check('clicking the pill opens the recovered 172x34 inline editor with cancel/confirm',
[near(editing.width, 172), near(editing.height, 34), editing.actions, editing.confirmDisabled], [true, true, 2, true])
await read(setInput('#settings-local-memory-user-call-name .local-memory-inline__input', '小明'), settings)
await pause(120)
await read(`document.querySelector('#settings-local-memory-user-call-name .local-memory-inline__icon-btn--confirm').click()`, settings)
const savedIdentity = await wait(`(()=>{const pill=document.querySelector('#settings-local-memory-user-call-name .local-memory-pill')
return pill?pill.textContent.trim():null})()`, 'identity saved', settings)
check('confirming a non-empty identity persists and renders it',
[savedIdentity, await read(`window.ycode.personalization.get().then(view => view.userCallName)`, settings)], ['小明', '小明'])
await read(`document.querySelector('#settings-local-memory-ai-name .local-memory-pill').click()`, settings)
await wait(`!!document.querySelector('#settings-local-memory-ai-name .local-memory-inline__input')`, 'ai name editor', settings)
await read(setInput('#settings-local-memory-ai-name .local-memory-inline__input', '临时草稿'), settings)
// 真实焦点离开本行(点不可聚焦的 div 不会触发 blur,这里把焦点交给回复风格按钮)。
await read(`document.querySelector('.personalization-panel__tone-trigger').focus();true`, settings)
const afterBlur = await wait(`(()=>{const row=document.querySelector('#settings-local-memory-ai-name')
return row.querySelector('.local-memory-inline__input')?null:row.querySelector('.local-memory-pill')?.textContent?.trim()})()`, 'blur discards draft', settings)
check('blurring outside the row discards the identity draft like the original',
[afterBlur, await read(`window.ycode.personalization.get().then(view => view.aiName)`, settings)], ['暂无内容,点击编辑添加', ''])
await step('personalization-editors')
await read(`document.querySelector('#settings-local-memory-ai-name .local-memory-pill').click()`, settings)
await wait(`!!document.querySelector('#settings-local-memory-ai-name .local-memory-inline__input')`, 'ai name editor again', settings)
await read(setInput('#settings-local-memory-ai-name .local-memory-inline__input', '小助手'), settings)
await pause(100)
await read(`document.querySelector('#settings-local-memory-ai-name .local-memory-inline__icon-btn--confirm').click()`, settings)
await wait(`document.querySelector('#settings-local-memory-ai-name .local-memory-pill')?.textContent?.trim()==='小助手'`, 'ai name saved', settings)
await read(`document.querySelector('#settings-custom-prompt .local-memory-long__preview').click()`, settings)
const modal = await wait(`(()=>{const node=document.querySelector('.local-memory-edit-modal')
if(!node)return null
const r=node.getBoundingClientRect()
return { x:r.x, y:r.y, width:r.width, height:r.height,
title:node.querySelector('.wb-modal__title')?.textContent?.trim(),
placeholder:node.querySelector('.local-memory-editor')?.getAttribute('placeholder'),
buttons:[...node.querySelectorAll('.wb-modal__footer .wb-button')].map(button=>({text:button.textContent.trim(),width:button.getBoundingClientRect().width,height:button.getBoundingClientRect().height})) }})()`, 'prompt editor modal', settings)
snapshots.modal = modal
check('指令 editor keeps the recovered 640x520 modal with 保存/取消 68x32 buttons',
[[near(modal.x, 130), near(modal.y, 80), near(modal.width, 640), near(modal.height, 520)].every(Boolean),
modal.title, modal.buttons.map(button => button.text), modal.buttons.every(button => near(button.width, 68) && near(button.height, 32))],
[true, '给 YCode 的指令', ['取消', '保存'], true])
check('empty 指令 editor keeps the recovered placeholder', [modal.placeholder], ['当前内容为空,点击开始输入'])
await read(setInput('.local-memory-editor', '先给结论,再给依据。'), settings)
await pause(120)
await read(`[...document.querySelectorAll('.local-memory-edit-modal .wb-modal__footer .wb-button')].find(button=>button.textContent.trim()==='保存').click()`, settings)
await wait(`!document.querySelector('.local-memory-edit-modal')`, 'modal closed after save', settings)
const promptCard = await read(`(()=>{const card=document.querySelector('#settings-custom-prompt')
return { text:card.querySelector('.local-memory-long__value')?.textContent?.trim(), placeholder:!!card.querySelector('.local-memory-long__placeholder') }})()`, settings)
check('saving the 指令 updates the preview and persists',
[promptCard.text, promptCard.placeholder, await read(`window.ycode.personalization.get().then(view => view.customPrompt)`, settings)],
['先给结论,再给依据。', false, '先给结论,再给依据。'])
await read(`document.querySelector('#settings-local-memory-soul .local-memory-long__preview').click()`, settings)
const personaOpen = await wait(`(()=>{const node=document.querySelector('.local-memory-edit-modal')
return node?{title:node.querySelector('.wb-modal__title')?.textContent?.trim(), value:node.querySelector('.local-memory-editor')?.value}:null})()`, 'persona editor', settings)
check('人设 editor opens with the recovered default text',
[personaOpen.title, personaOpen.value?.startsWith("You're not a chatbot.")], ['人设设定', true])
await read(`[...document.querySelectorAll('.local-memory-edit-modal .wb-modal__footer .wb-button')].find(button=>button.textContent.trim()==='取消').click()`, settings)
await wait(`!document.querySelector('.local-memory-edit-modal')`, 'persona modal cancelled', settings)
check('cancelling the persona editor keeps the stored default', await read(`window.ycode.personalization.get().then(view => view.persona.startsWith("You're not a chatbot."))`, settings), true)
await step('personalization-persistence')
const config = JSON.parse(await fs.readFile(path.join(directory, 'data', 'config.json'), 'utf8'))
snapshots.config = config.personalization
check('config.json keeps the identity, tone and prompt values',
[config.personalization?.userCallName, config.personalization?.aiName, config.personalization?.tone, config.personalization?.customPrompt,
config.personalization?.loadingTips], ['小明', '小助手', 'professional', '先给结论,再给依据。', true])
settings.close()
await pause(500)
await openSettings('personalization')
const reopened = await read(layoutSource, settings)
check('reopened settings restores the persisted personalization',
[reopened.rows[0].pill?.text, reopened.rows[1].pill?.text, reopened.toneLabel, reopened.longCards[0].text],
['小明', '小助手', '专业严谨', '先给结论,再给依据。'])
await step('personalization-dark-theme')
await openSettings('appearance')
await wait('!!document.querySelector(".appearance-card")', 'appearance panel', settings)
await read(`document.querySelector('[aria-label="深色"].appearance-card').click()`, settings)
await wait(`document.documentElement.dataset.theme==='dark'`, 'dark theme', settings)
await openSettings('personalization')
await wait(`document.documentElement.dataset.theme==='dark'&&!!document.querySelector('.personalization-panel')`, 'dark personalization panel', settings)
const dark = await read(layoutSource, settings)
snapshots.dark = dark
check('dark theme keeps the recovered card surface and text',
[dark.rowBackground, dark.rowColor], ['rgb(31, 31, 31)', 'rgb(242, 243, 245)'])
check('dark theme keeps every recovered geometry',
[near(dark.panel.width, 638), near(dark.panel.height, 1160), near(dark.preference.height, 204),
dark.longCards.every(card => near(card.preview.width, 598) && near(card.preview.height, 160))], [true, true, true, true])
await openSettings('appearance')
await wait('!!document.querySelector(".appearance-card")', 'appearance panel again', settings)
await read(`document.querySelector('[aria-label="浅色"].appearance-card').click()`, settings)
await wait(`document.documentElement.dataset.theme==='light'`, 'light restored', settings)
await step('personalization-errors')
check('no renderer error logs', rendererErrors, [])
const result = checks.every(item => item.pass) ? 'PASS' : 'FAIL'
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result, phase, checks, snapshots }, null, 2))
console.log('PERSONALIZATION_SETTINGS_' + result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) console.log('PERSONALIZATION_SETTINGS_FAILURE ' + JSON.stringify(failure))
app.exit(result === 'PASS' ? 0 : 1)
})().catch(async error => {
console.error(error)
try { await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'ERROR', phase, checks, snapshots, error: String(error?.stack ?? error) }, null, 2)) } catch { /* ignore */ }
app.exit(1)
})
}
+2 -2
View File
@@ -125,10 +125,10 @@ if (!process.versions.electron) {
// 原版 4 组;about 组按组件约定渲染为分隔线而不是标题。
check('nav keeps the four recovered groups', [nav.groups.length, nav.dividers], [3, 1])
// 通用(settings) 在 TODO 10 接入后同样可用。
const expectedDisabled = EXPECTED_TABS.filter(tab => !['settings', 'models', 'appearance', 'keyboardShortcuts'].includes(tab))
const expectedDisabled = EXPECTED_TABS.filter(tab => !['settings', 'models', 'appearance', 'keyboardShortcuts', 'personalization'].includes(tab))
check('unwired entries stay disabled', nav.tabs.filter((tab, index) => nav.disabled[index] === 'true'), expectedDisabled)
check('unwired entries keep the 暂未接入 hint', nav.titles.filter(title => (title ?? '').includes('暂未接入')).length, expectedDisabled.length)
check('settings, appearance, keyboard shortcuts and models stay enabled', nav.tabs.filter((tab, index) => !nav.disabled[index]), ['settings', 'appearance', 'keyboardShortcuts', 'models'])
check('settings, appearance, keyboard shortcuts, personalization and models stay enabled', nav.tabs.filter((tab, index) => !nav.disabled[index]), ['settings', 'appearance', 'keyboardShortcuts', 'personalization', 'models'])
await step('settings-search-excludes-removed')
await read(`(()=>{const input=document.querySelector('.settings-navigation input[type="search"]');
+14 -1
View File
@@ -10,7 +10,7 @@
import { app, safeStorage } from 'electron'
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import type { GatewayConfig, GatewayConfigView } from '@shared/types'
import type { GatewayConfig, GatewayConfigView, PersonalizationSettings } from '@shared/types'
interface StoredConfig {
speechBaseUrl?: string
@@ -26,6 +26,8 @@ interface StoredConfig {
preventSleep?: boolean
/** 开机自动启动(通用设置;默认关闭)。 */
autoLaunch?: boolean
/** 设置 → 个性化(身份、回复风格、指令、人设、长期记忆)。 */
personalization?: Partial<PersonalizationSettings>
}
const DEFAULTS: StoredConfig = {
@@ -118,6 +120,17 @@ export async function setAutoLaunch(enabled: boolean): Promise<void> {
await persist({ ...stored, autoLaunch: enabled })
}
/** 设置 → 个性化的原始持久化值(缺省字段由 personalization.ts 补齐)。 */
export async function getStoredPersonalization(): Promise<Partial<PersonalizationSettings>> {
const stored = await load()
return stored.personalization ?? {}
}
export async function setStoredPersonalization(value: Partial<PersonalizationSettings>): Promise<void> {
const stored = await load()
await persist({ ...stored, personalization: { ...(stored.personalization ?? {}), ...value } })
}
/** 供渲染进程使用:脱敏视图 */
export async function getConfigView(): Promise<GatewayConfigView> {
const stored = await load()
+7 -2
View File
@@ -11,7 +11,7 @@ import { BrowserWindow, app, dialog, ipcMain, shell, type OpenDialogOptions } fr
import { randomUUID } from 'node:crypto'
import { unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { ChatEvent, ChatMessage, GitBranchInfo, WindowState } from '@shared/types'
import type { ChatEvent, ChatMessage, GitBranchInfo, PersonalizationPatch, WindowState } from '@shared/types'
import { freezeAssistantModelMetadata } from '@shared/assistant-model'
import type { ConfigPatchLike, EditAction, SendPayload, WorkspaceAttachResult } from '@shared/ipc'
import { applyAssistantEvent } from '@shared/message-parts'
@@ -34,6 +34,7 @@ import { assistantWireHistory } from './agent/history'
import * as models from './models'
import * as workspaces from './workspaces'
import * as generalSettings from './general-settings'
import * as personalization from './personalization'
import { prepareOmpModel } from './omp-model'
import type { CustomModelInput, ResolvedModel } from '@shared/models'
import type { SessionContext } from '@shared/workspaces'
@@ -408,6 +409,8 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
}
}
ipcMain.handle('general:get', (_event, options?: { refreshUsage?: boolean }) => generalView(options?.refreshUsage === true))
ipcMain.handle('personalization:get', () => personalization.getPersonalization())
ipcMain.handle('personalization:update', (_event, patch: PersonalizationPatch) => personalization.updatePersonalization(patch))
ipcMain.handle('general:update', async (_event, patch: { autoLaunch?: boolean; preventSleep?: boolean }) => {
if (patch.preventSleep !== undefined) {
await config.setPreventSleep(patch.preventSleep)
@@ -740,7 +743,9 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
const interactionBroker = createInteractionBroker(runId, controller.signal, emit)
// 对话中引用的内容块(原版 message-quote)随消息一起交给模型;表格 markdown 原样附带。
const quoteText = (payload.quotes ?? []).map((quote) => quote.markdown.trim()).filter(Boolean).join('\n\n')
const promptText = [text, quoteText, promptAttachments.text, references.length ? `引用工作区资源:\n${references.join('\n')}` : '', capabilitySnapshot.context].filter(Boolean).join('\n\n')
// 设置 → 个性化的身份/风格/指令/人设/长期记忆随每轮请求发送,OMP 与自建循环共用同一段文本。
const personalizationPrompt = personalization.buildPersonalizationPrompt(await personalization.getPersonalization())
const promptText = [text, quoteText, promptAttachments.text, references.length ? `引用工作区资源:\n${references.join('\n')}` : '', capabilitySnapshot.context, personalizationPrompt].filter(Boolean).join('\n\n')
// 回合在后台推进,立刻把 runId 还给渲染进程
const initialMessages = payload.action ? structuredClone([...history, userMessage, assistant]) : undefined
+28
View File
@@ -0,0 +1,28 @@
/**
* 设置 → 个性化(TODO 13)的持久化与运行链路。
*
* REA 依据(WorkBuddy 5.8.3,设置窗口 900×680):
* - ev_af920edf33af5e811d58f05dad15210b29cd891f28e0fa74f6b18d63212241b1(浅色顶部 DOM/layout/computed style + 模块 CSS)
* - ev_88af0774005c02e9c75284ade2be45de4943ee5aaf73fe5b13ac8a38b85be786(深色同页)
* - ev_4f4f76054a930a186199ee28a5f0169f7de48a05a295b8e9eb630c796f07be4a(最终浅色顶部)
*
* 选项表、默认人设与个性化上下文拼装放在 @shared/personalization(纯函数,可离线回归);
* 本模块只负责读写 config.json 的 personalization 字段。
*/
import type { PersonalizationPatch, PersonalizationSettings } from '@shared/types'
import { normalizePersonalization } from '@shared/personalization'
import { getStoredPersonalization, setStoredPersonalization } from './config'
export { buildPersonalizationPrompt, DEFAULT_PERSONA, DEFAULT_PERSONALIZATION, TONE_OPTIONS } from '@shared/personalization'
export { normalizePersonalization } from '@shared/personalization'
export async function getPersonalization(): Promise<PersonalizationSettings> {
return normalizePersonalization(await getStoredPersonalization())
}
export async function updatePersonalization(patch: PersonalizationPatch): Promise<PersonalizationSettings> {
const current = await getPersonalization()
const next = normalizePersonalization({ ...current, ...patch })
await setStoredPersonalization(next)
return next
}
+4
View File
@@ -113,6 +113,10 @@ const api: YCodeApi = {
changeWorkspaceRoot: (path) => ipcRenderer.invoke('general:changeWorkspaceRoot', path),
openSystemNotificationSettings: () => ipcRenderer.invoke('general:open-system-notification-settings')
},
personalization: {
get: () => ipcRenderer.invoke('personalization:get'),
update: (patch) => ipcRenderer.invoke('personalization:update', patch)
},
workspace: {
get: () => ipcRenderer.invoke('workspace:get'),
choose: () => ipcRenderer.invoke('workspace:choose'),
@@ -0,0 +1,320 @@
/**
* 设置 → 个性化:WorkBuddy 5.8.3 个性化页的 1:1 复刻 + YCode 真实接线(TODO 13)。
*
* REA 依据(设置窗口 900×680):
* - ev_af920edf33af5e811d58f05dad15210b29cd891f28e0fa74f6b18d63212241b1(浅色顶部 DOM/layout/computed style、模块 CSS)
* - ev_88af0774005c02e9c75284ade2be45de4943ee5aaf73fe5b13ac8a38b85be786(深色同页)
* - ev_d6e0a2483a3bde901f4cf8299ff03eb0b475cb934921058cf38dd671585ab457(开关与下拉交互)
* - ev_523e07353a9fd7a003136f8e07c72d5ba87a05f893dd4b4c4adb2d7ed5a7430f(富文本编辑弹窗 DOM/几何)
* - ev_4f4f76054a930a186199ee28a5f0169f7de48a05a295b8e9eb630c796f07be4a(最终浅色顶部)
*
* 已接入:称呼 / YCode 的名字 / 回复风格 / 给 YCode 的指令 / 人设设定 / 长期记忆 → config.json +
* 每轮请求上下文;欢迎语 → 控制流式加载提示。
* 未接入:「显示文件修改过程」在 YCode 尚无对应的逐步渲染开关,保持原版控件形态但禁用并标注。
* 编辑弹窗用 textarea 承载纯文本(原版是 .sc-editor-content 富文本编辑器),结构与几何按 REA 还原。
*/
import { useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type { PersonalizationPatch, PersonalizationSettings, PersonalizationTone } from '@shared/types'
import { TONE_OPTIONS } from '@shared/personalization'
import { IconSelectCaret } from './general-settings-icons'
import { setLoadingTipsEnabled } from '../personalization-prefs'
import './source-personalization-panel.css'
const UNAVAILABLE_HINT = '暂未接入'
const IDENTITY_PLACEHOLDER = '暂无内容,点击编辑添加'
type IdentityField = 'userCallName' | 'aiName'
type LongField = 'customPrompt' | 'persona' | 'longTermMemory'
function CheckIcon(): React.JSX.Element {
return <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"><path d="M3.25 8.4 6.4 11.5 12.75 4.75" /></svg>
}
function CloseIcon(): React.JSX.Element {
return <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"><path d="m4.25 4.25 7.5 7.5M11.75 4.25l-7.5 7.5" /></svg>
}
function classNames(...values: Array<string | false | undefined>): string {
return values.filter(Boolean).join(' ')
}
interface SectionProps {
title: string
desc?: ReactNode
action: ReactNode
body?: ReactNode
stacked?: boolean
id?: string
className?: string
}
/** 与通用设置相同的原版行结构:section-main > (copy > 标题+描述) + action,body 可选。 */
function Section({ title, desc, action, body, stacked, id, className }: SectionProps): React.JSX.Element {
return (
<section id={id} className={classNames('general-settings-panel__section', 'general-settings-panel__section--plain',
stacked ? 'general-settings-panel__section--stacked' : 'general-settings-panel__section--inline', className)}>
<div className="general-settings-panel__section-main">
<div className="general-settings-panel__section-copy">
<h3 className="general-settings-panel__section-title">{title}</h3>
{desc ? <p className="general-settings-panel__section-desc">{desc}</p> : null}
</div>
<div className="general-settings-panel__section-action">{action}</div>
</div>
{body ? <div className="general-settings-panel__section-body">{body}</div> : null}
</section>
)
}
function Switch({ checked, ariaLabel, disabled, onChange }: {
checked: boolean
ariaLabel: string
disabled?: boolean
onChange(checked: boolean): void
}): React.JSX.Element {
return (
<div className="general-settings-panel__switch-wrap">
<label className="wb-switch wb-switch--large">
<span className="wb-switch__control">
<input className="wb-switch__input" type="checkbox" role="switch" aria-label={ariaLabel} checked={checked}
disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
<span className="wb-switch__track"><span className="wb-switch__thumb" /></span>
</span>
</label>
</div>
)
}
function ToneSelect({ value, open, busy, onToggle, onChoose }: {
value: PersonalizationTone
open: boolean
busy: boolean
onToggle(): void
onChoose(value: PersonalizationTone): void
}): React.JSX.Element {
const option = TONE_OPTIONS[value]
return (
<div className="personalization-panel__dropdown source-select-host">
<button type="button" className="wb-select wb-select--medium personalization-panel__tone-trigger"
aria-label="回复风格" aria-haspopup="listbox" aria-expanded={open} disabled={busy} onClick={onToggle}>
<span className="wb-select__value">{option.label}</span>
<IconSelectCaret className="wb-icon wb-select__caret" />
</button>
{open ? (
<div className="wb-popover wb-select__popover personalization-panel__tone-popover" role="listbox" aria-label="回复风格">
{(Object.keys(TONE_OPTIONS) as PersonalizationTone[]).map((id) => {
const item = TONE_OPTIONS[id]
return (
<button type="button" role="option" aria-selected={id === value} key={id}
className={classNames('wb-select__option', id === value && 'wb-select__option--single-selected')}
onClick={() => onChoose(id)}>
<span className="personalization-panel__option-text">
<span className="personalization-panel__option-name">{item.label}</span>
<span className="personalization-panel__option-hint">{item.hint}</span>
</span>
{id === value ? <span className="wb-select__check"><CheckIcon /></span> : null}
</button>
)
})}
</div>
) : null}
</div>
)
}
export default function PersonalizationSettingsPanel(): React.JSX.Element {
const [view, setView] = useState<PersonalizationSettings | null>(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [editing, setEditing] = useState<IdentityField | null>(null)
const [draft, setDraft] = useState('')
const [toneOpen, setToneOpen] = useState(false)
const [editor, setEditor] = useState<{ field: LongField; title: string; value: string } | null>(null)
const identityInput = useRef<HTMLInputElement>(null)
useEffect(() => {
let active = true
void window.ycode.personalization.get()
.then((next) => { if (!active) return; setView(next); setLoadingTipsEnabled(next.loadingTips) })
.catch((reason) => { if (active) setError(String((reason as Error)?.message ?? reason)) })
return () => { active = false }
}, [])
useEffect(() => {
if (!toneOpen && !editor) return
const close = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') return
event.preventDefault()
event.stopPropagation()
setToneOpen(false)
setEditor(null)
}
document.addEventListener('keydown', close, true)
return () => document.removeEventListener('keydown', close, true)
}, [toneOpen, editor])
useEffect(() => {
if (!toneOpen) return
const outside = (event: PointerEvent): void => {
const target = event.target as Element | null
if (target?.closest?.('.personalization-panel__dropdown')) return
setToneOpen(false)
}
document.addEventListener('pointerdown', outside, true)
return () => document.removeEventListener('pointerdown', outside, true)
}, [toneOpen])
useEffect(() => { if (editing) identityInput.current?.focus() }, [editing])
const apply = async (patch: PersonalizationPatch): Promise<PersonalizationSettings | null> => {
setBusy(true)
try {
const next = await window.ycode.personalization.update(patch)
setView(next)
setLoadingTipsEnabled(next.loadingTips)
setError('')
return next
} catch (reason) {
setError(String((reason as Error)?.message ?? reason))
return null
} finally {
setBusy(false)
}
}
const startIdentity = (field: IdentityField): void => {
if (!view) return
setToneOpen(false)
setDraft(view[field])
setEditing(field)
}
const commitIdentity = (): void => {
if (!editing) return
const value = draft.trim()
// 原版实测:空值不能确认保存(清空后确认仍停留在空编辑态)。
if (!value) return
const field = editing
void apply({ [field]: value }).then((next) => { if (next) setEditing(null) })
}
const cancelIdentity = (): void => { setEditing(null); setDraft('') }
const openEditor = (field: LongField, title: string): void => {
if (!view) return
setEditor({ field, title, value: view[field] })
}
const saveEditor = (): void => {
if (!editor) return
const { field, value } = editor
void apply({ [field]: value }).then((next) => { if (next) setEditor(null) })
}
const identityRow = (field: IdentityField, label: string): React.JSX.Element => {
const value = view?.[field] ?? ''
const id = field === 'userCallName' ? 'settings-local-memory-user-call-name' : 'settings-local-memory-ai-name'
return (
<div className="local-memory-identity-row" id={id}>
<div className="local-memory-identity-row__label">{label}</div>
{editing === field ? (
<div className="local-memory-identity-row__edit">
<input ref={identityInput} className="local-memory-inline__input" aria-label={label} value={draft} maxLength={24}
onChange={(event) => setDraft(event.target.value)}
onBlur={(event) => {
// 原版实测:点到卡片外部失焦即丢弃草稿;点到本行的取消/确认按钮则交给按钮处理。
const next = event.relatedTarget as Node | null
if (next && event.currentTarget.closest('.local-memory-identity-row__edit')?.contains(next)) return
cancelIdentity()
}}
onKeyDown={(event) => {
if (event.key === 'Enter') { event.preventDefault(); commitIdentity() }
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); cancelIdentity() }
}} />
<div className="local-memory-identity-row__actions">
<button type="button" className="local-memory-inline__icon-btn wb-button" aria-label={`取消编辑${label}`} title="取消" onClick={cancelIdentity}><CloseIcon /></button>
<button type="button" className="local-memory-inline__icon-btn local-memory-inline__icon-btn--confirm wb-button" aria-label={`确认${label}`} title="确认" disabled={busy || !draft.trim()} onClick={commitIdentity}><CheckIcon /></button>
</div>
</div>
) : (
<button type="button" className="local-memory-pill wb-button wb-button--secondary wb-button--medium" aria-label={`编辑${label}`} onClick={() => startIdentity(field)}>
<span className="wb-button__content">
<span className={value ? 'local-memory-pill__value' : 'local-memory-pill__placeholder'}>{value || IDENTITY_PLACEHOLDER}</span>
</span>
</button>
)}
</div>
)
}
const longCard = (field: LongField, id: string, title: string, desc: string, placeholder: string): React.JSX.Element => {
const value = view?.[field] ?? ''
const open = (): void => openEditor(field, title)
return (
<div className="general-settings-panel__group local-memory-long-card" id={id}>
<div className="local-memory-long">
<Section title={title} desc={desc} stacked
action={<button type="button" className="wb-button wb-button--secondary wb-button--medium" onClick={open}>编辑</button>}
body={<div className="local-memory-long__preview" role="button" tabIndex={0} title={value || placeholder}
onClick={open} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open() } }}>
{value ? <div className="local-memory-long__value">{value}</div> : <div className="local-memory-long__placeholder">{placeholder}</div>}
</div>} />
</div>
</div>
)
}
return (
<div className="general-settings-panel personalization-panel">
{error ? <p role="alert">{error}</p> : null}
<div className="general-settings-panel__group local-memory-section">
<div className="local-memory-identity-card">
{identityRow('userCallName', 'YCode 对你的称呼')}
{identityRow('aiName', 'YCode 的名字')}
</div>
</div>
<div className="general-settings-panel__group-card">
<Section id="settings-tone-style" title="回复风格" desc="选择 YCode 回复的默认语气。"
action={<ToneSelect value={view?.tone ?? 'default'} open={toneOpen} busy={busy || !view}
onToggle={() => setToneOpen((open) => !open)} onChoose={(tone) => { setToneOpen(false); void apply({ tone }) }} />} />
<Section id="settings-loading-tips" title="欢迎语" desc="当对话加载较慢时,用一句问候陪你等待。"
action={<Switch checked={view?.loadingTips ?? true} ariaLabel="欢迎语" disabled={busy || !view}
onChange={(loadingTips) => void apply({ loadingTips })} />} />
<Section id="settings-file-changes" title="显示文件修改过程" desc="开启后,YCode 会逐步展示每次文件改动的过程与结果。"
action={<span data-settings-unavailable="true" aria-disabled="true" title={UNAVAILABLE_HINT}>
<Switch checked={view?.showFileChanges ?? false} ariaLabel="显示文件修改过程" disabled onChange={() => undefined} />
</span>} />
</div>
{longCard('customPrompt', 'settings-custom-prompt', '给 YCode 的指令', '写下你希望 YCode 一直遵守的要求,之后每次对话和任务都会默认生效。', '例如:回答尽量简洁,先给结论')}
{longCard('persona', 'settings-local-memory-soul', '人设设定', '设定 YCode 的身份、性格与表达风格,对所有回复生效。', '暂无内容,点击编辑添加')}
{longCard('longTermMemory', 'settings-local-memory-body', '长期记忆', '跨会话保存的重要信息与结论,可随时查看和修改。', '暂无记忆记录,点击编辑添加')}
{editor ? (
<div className="wb-modal__overlay wb-modal__overlay--center" onClick={() => setEditor(null)}>
<div className="wb-modal wb-modal--medium local-memory-edit-modal" role="dialog" aria-modal="true" aria-label={editor.title}
onClick={(event) => event.stopPropagation()}>
<button type="button" className="wb-button wb-button--ghost wb-button--small wb-button--icon-only wb-button--circle wb-modal__close"
aria-label="关闭" onClick={() => setEditor(null)}><CloseIcon /></button>
<div className="wb-modal__header">
<div className="wb-modal__header-row"><div className="wb-modal__title-wrap"><h2 className="wb-modal__title">{editor.title}</h2></div></div>
</div>
<div className="wb-modal__body">
<div className="local-memory-editor-host">
<textarea className="local-memory-editor sc-editor-content" aria-label={editor.title} value={editor.value}
placeholder="当前内容为空,点击开始输入" autoFocus
onChange={(event) => setEditor({ ...editor, value: event.target.value })} />
</div>
</div>
<div className="wb-modal__footer wb-modal__footer--right">
<button type="button" className="wb-button wb-button--secondary wb-button--medium" onClick={() => setEditor(null)}>取消</button>
<button type="button" className="wb-button wb-button--primary wb-button--medium" disabled={busy} onClick={saveEditor}>保存</button>
</div>
</div>
</div>
) : null}
</div>
)
}
@@ -4,6 +4,7 @@ import ModelSettingsPanel from './ModelSettingsPanel'
import GeneralSettingsPanel from './GeneralSettingsPanel'
import AppearanceSettingsPanel from './AppearanceSettingsPanel'
import ShortcutsSettingsPanel from './ShortcutsSettingsPanel'
import PersonalizationSettingsPanel from './PersonalizationSettingsPanel'
import SourceSettingsNavigation from './SourceSettingsNavigation'
import { useSharedTheme } from '../useSharedTheme'
import { SOURCE_WINDOWS_SETTINGS_TABS } from './SourceSettingsNavigation'
@@ -54,7 +55,7 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi
return () => window.removeEventListener('keydown', escape)
}, [onClose])
const modal = <section className={`settings-modal${nativeWindow ? ' settings-modal--window' : ''}`} role="dialog" aria-label="设置" aria-modal={!nativeWindow}>
<div className="settings-modal__nav"><SourceSettingsNavigation activeTab={section} supportedTabs={['settings', 'models', 'appearance', 'keyboardShortcuts']} visibleTabs={VISIBLE_SETTINGS_TABS}
<div className="settings-modal__nav"><SourceSettingsNavigation activeTab={section} supportedTabs={['settings', 'models', 'appearance', 'keyboardShortcuts', 'personalization']} visibleTabs={VISIBLE_SETTINGS_TABS}
onTabChange={(id) => { setError(''); setSection(id === 'models' ? 'model' : id) }} onUnavailable={() => setError('该设置尚未接入')} /></div>
<div className="settings-modal__content"><div className="settings-modal__panel">
{error && <p role="alert">{error}</p>}
@@ -63,6 +64,7 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi
{section === 'workspace' && <div className="settings-window-panel"><h2>工作空间</h2>{settings.workspaces.map((space) => <button className="btn" key={space.id} title={space.root} onClick={() => void window.ycode.workspace.open(space.id).catch((error) => setError(String(error.message ?? error)))}>{space.name}</button>)}<button className="btn" onClick={() => void settings.attachWorkspace().catch((error) => setError(String(error.message ?? error)))}>打开已有文件夹</button></div>}
{section === 'appearance' && <AppearanceSettingsPanel theme={theme} onThemeChange={onThemeChange} />}
{section === 'keyboardShortcuts' && <ShortcutsSettingsPanel />}
{section === 'personalization' && <PersonalizationSettingsPanel />}
{section === 'about' && <div className="settings-window-panel"><h2>YCode</h2><p>{settings.appInfo?.version}</p></div>}
</div></div>
</section>
@@ -0,0 +1,50 @@
/* REA ev_af920edf33af5e811d58f05dad15210b29cd891f28e0fa74f6b18d63212241b1; PersonalizationPanel-D16rRGjy.css sha256=318664f96253adace047d7b1d2d213fbf6139461c10d80ae12135998b7b98f3d; tokens sha256=13f2bf82c96e5167a7d7098e36950c6c22c475b3337dbb8bb29ad2fe79cde696. */
.personalization-panel__dropdown { flex-shrink: 0; position: relative; }
.personalization-panel__option-text { flex: 1 1 0%; }
.personalization-panel__option-name { font-size: var(--wb-font-body-size); font-weight: 400; line-height: var(--wb-font-body-line-height); }
.wb-select__option--single-selected .personalization-panel__option-name { font-weight: 500; }
.personalization-panel__option-hint { margin-top: var(--wb-spacing-1); color: var(--wb-color-text-tertiary,#00000080); font-family: "PingFang SC"; font-size: 12px; font-style: normal; font-weight: 400; line-height: 20px; text-decoration-line: none; }
.personalization-panel__tone-trigger .personalization-panel__option-hint { display: none; }
.personalization-panel__tone-popover { width: 240px !important; max-width: 240px !important; }
.personalization-panel__tone-popover .wb-select__option { height: auto; min-height: var(--wb-select-size-control-md); padding-top: var(--wb-spacing-2-5); padding-bottom: var(--wb-spacing-2-5); }
@media (width <= 640px) {
.personalization-panel__dropdown { align-self: flex-end; max-width: 100%; }
}
.local-memory-identity-card { gap: var(--wb-spacing-5); padding: var(--wb-spacing-3) var(--wb-spacing-4); border-radius: var(--wb-radius-xl); background: var(--wb-bg-primary); flex-direction: column; display: flex; }
.local-memory-long-card { padding: var(--wb-spacing-4); border-radius: var(--wb-radius-xl); background: var(--wb-bg-primary); }
.local-memory-identity-row { justify-content: space-between; align-items: center; gap: var(--wb-spacing-4); min-height: 34px; display: flex; }
.local-memory-identity-row__label { min-width: 0px; color: var(--wb-color-text-primary); font-size: var(--wb-font-body-size); font-weight: 500; line-height: var(--wb-font-body-line-height); flex: 1 1 0%; }
.local-memory-identity-row__edit { align-items: center; gap: var(--wb-spacing-2); flex: 0 0 auto; min-width: 240px; display: flex; }
.local-memory-identity-row__actions { align-items: center; gap: var(--wb-spacing-1); flex: 0 0 auto; display: flex; }
.local-memory-pill.wb-button { min-width: 120px; max-width: 260px; height: 34px; padding: 0 var(--wb-spacing-3); border: 1px solid var(--wb-border-control); border-radius: var(--wb-radius-md); background: var(--wb-bg-primary); color: var(--wb-color-text-primary); font-family: inherit; font-size: var(--wb-font-body-size); line-height: var(--wb-font-body-line-height); box-shadow: none; justify-content: flex-start; align-items: center; display: inline-flex; }
.local-memory-pill.wb-button .wb-button__content { flex: 1 1 0%; min-width: 0px; overflow: hidden; }
.local-memory-pill.wb-button:hover:not(:disabled) { background: var(--wb-bg-hover); border-color: var(--wb-border-control); }
.local-memory-pill__value,.local-memory-pill__placeholder { white-space: nowrap; text-overflow: ellipsis; min-width: 0px; max-width: 100%; display: block; overflow: hidden; }
.local-memory-pill__placeholder { color: var(--wb-color-text-tertiary); }
.local-memory-pill__placeholder--error { color: var(--wb-status-warning-vivid); }
.local-memory-long { gap: var(--wb-spacing-3); flex-direction: column; display: flex; }
.local-memory-long .general-settings-panel__section--plain { padding-left: var(--wb-spacing-3); padding-right: var(--wb-spacing-3); }
.local-memory-long .general-settings-panel__section-body { padding-left: 0px; padding-right: 0px; }
.local-memory-long__preview { box-sizing: border-box; height: calc(var(--wb-size-10) * 4); padding: var(--wb-spacing-3) var(--wb-spacing-4); border: 1px solid var(--wb-color-border-primary); border-radius: var(--wb-radius-lg); background: var(--wb-button-secondary-bg); overflow: auto; }
.local-memory-long__preview[role="button"] { cursor: pointer; }
.local-memory-long__value { white-space: pre-wrap; word-break: break-word; color: var(--wb-color-text-primary); font-size: var(--wb-font-body-size); line-height: var(--wb-font-body-line-height); }
.local-memory-long__placeholder { color: var(--wb-color-text-tertiary); font-size: var(--wb-font-body-size); line-height: var(--wb-font-body-line-height); }
.local-memory-long__placeholder--error { color: var(--wb-status-warning-vivid); }
.local-memory-inline__icon-btn.wb-button { width: 34px; height: 34px; color: var(--wb-color-text-tertiary); box-shadow: none; background: 0px 0px; border: none; padding: 0px; }
.local-memory-inline__icon-btn.wb-button .wb-button__icon { width: 16px; height: 16px; }
.local-memory-inline__icon-btn.wb-button:hover:not(:disabled) { color: var(--wb-color-text-primary); background: 0px 0px; }
.local-memory-inline__icon-btn--confirm.wb-button { color: var(--wb-status-success); }
.local-memory-inline__icon-btn--confirm.wb-button:hover:not(:disabled) { color: var(--wb-status-success); background: 0px 0px; }
.local-memory-inline__icon-btn.wb-button:disabled { opacity: 0.45; }
.local-memory-edit-modal .wb-modal__body { flex-direction: column; display: flex; }
.local-memory-editor-host { background: var(--wb-input-color-bg); border: 1px solid var(--wb-input-border-color); border-radius: var(--wb-input-border-radius-textarea); flex-direction: column; flex: 1 0 0px; align-self: stretch; align-items: center; gap: 12px; width: 100%; min-height: 0px; display: flex; }
.local-memory-editor-host > * { border-radius: var(--wb-input-border-radius-textarea); flex: 1 1 0%; align-self: stretch; min-height: 0px; overflow: auto; }
.local-memory-editor-host .sc-editor-content { scrollbar-width: thin; scrollbar-color: transparent transparent; }
.local-memory-editor-host .sc-editor-content:hover { scrollbar-color: var(--wb-scrollbar-thumb) transparent; }
.local-memory-editor-host .sc-editor-content::-webkit-scrollbar { width: 4px; }
.local-memory-editor-host .sc-editor-content::-webkit-scrollbar-track { background: 0px 0px; }
.local-memory-editor-host .sc-editor-content::-webkit-scrollbar-thumb { background: 0px 0px; border-radius: 4px; }
.local-memory-editor-host .sc-editor-content:hover::-webkit-scrollbar-thumb { background: var(--wb-scrollbar-thumb); }
.local-memory-editor-loading { height: 100%; color: var(--wb-color-text-secondary); font-size: var(--wb-font-body-size); justify-content: center; align-items: center; display: flex; }
.general-settings-panel.personalization-panel{--wb-font-body-size:var(--wb-font-size-5);--wb-font-body-line-height:var(--wb-font-line-height-5-2);--wb-spacing-1:2px;--wb-color-text-tertiary:var(--wb-palette-black-50);--wb-select-size-control-md:var(--wb-size-control-md);--wb-spacing-2-5:5px;--wb-spacing-5:16px;--wb-spacing-3:8px;--wb-spacing-4:12px;--wb-radius-xl:12px;--wb-bg-primary:var(--wb-palette-white-100);--wb-color-text-primary:var(--wb-palette-black-100);--wb-spacing-2:4px;--wb-border-control:var(--wb-palette-gray-4);--wb-radius-md:6px;--wb-bg-hover:color-mix(in srgb, var(--wb-palette-black-100) 5%, transparent);--wb-status-warning-vivid:var(--wb-palette-amber-vivid);--wb-size-10:40px;--wb-color-border-primary:var(--wb-palette-gray-5);--wb-radius-lg:8px;--wb-button-secondary-bg:var(--wb-palette-white-100);--wb-status-success:var(--wb-palette-green-8);--wb-input-color-bg:var(--wb-bg-primary);--wb-input-border-color:var(--wb-color-border-primary);--wb-input-border-radius-textarea:var(--wb-radius-lg);--wb-scrollbar-thumb:var(--wb-palette-black-20);--wb-color-text-secondary:var(--wb-palette-black-70);--wb-font-size-5:14px;--wb-font-line-height-5-2:22px;--wb-palette-black-50:#00000080;--wb-size-control-md:var(--wb-size-8);--wb-palette-white-100:#fff;--wb-palette-gray-3:#f2f2f2;--wb-palette-black-100:#000;--wb-palette-gray-4:#ebebeb;--wb-palette-amber-vivid:#ca8a04;--wb-palette-gray-5:#e6e6e6;--wb-palette-green-8:#0cbf5b;--wb-color-text-success-default:var(--wb-palette-green-8);--wb-color-border-solid:#dedede;--wb-palette-black-20:#0003;--wb-palette-white-20:#fff3;--wb-palette-black-70:#000000b3;--wb-size-8:32px;--wb-palette-green-6:#47cf84}
:root[data-theme=dark] .general-settings-panel.personalization-panel{--wb-font-body-size:var(--wb-font-size-5);--wb-font-body-line-height:var(--wb-font-line-height-5-2);--wb-spacing-1:2px;--wb-color-text-tertiary:#ffffff80;--wb-select-size-control-md:var(--wb-size-control-md);--wb-spacing-2-5:5px;--wb-spacing-5:16px;--wb-spacing-3:8px;--wb-spacing-4:12px;--wb-radius-xl:12px;--wb-bg-primary:var(--wb-palette-gray-3);--wb-color-text-primary:#fff;--wb-spacing-2:4px;--wb-border-control:color-mix(in srgb, var(--wb-palette-white-100) 22%, transparent);--wb-radius-md:6px;--wb-bg-hover:color-mix(in srgb, var(--wb-palette-white-100) 6%, transparent);--wb-status-warning-vivid:var(--wb-palette-amber-vivid);--wb-size-10:40px;--wb-color-border-primary:#262626;--wb-radius-lg:8px;--wb-button-secondary-bg:#262626;--wb-status-success:var(--wb-color-text-success-default);--wb-input-color-bg:var(--wb-bg-primary);--wb-input-border-color:var(--wb-color-border-solid);--wb-input-border-radius-textarea:var(--wb-radius-lg);--wb-scrollbar-thumb:var(--wb-palette-white-20);--wb-color-text-secondary:#ffffffa6;--wb-font-size-5:14px;--wb-font-line-height-5-2:22px;--wb-palette-black-50:#00000080;--wb-size-control-md:var(--wb-size-8);--wb-palette-white-100:#fff;--wb-palette-gray-3:#1f1f1f;--wb-palette-black-100:#000;--wb-palette-gray-4:#242424;--wb-palette-amber-vivid:#eab308;--wb-palette-gray-5:#3a3a3a;--wb-palette-green-8:#43f08f;--wb-color-text-success-default:var(--wb-palette-green-6);--wb-color-border-solid:#3d3d3d;--wb-palette-black-20:#0003;--wb-palette-white-20:#fff3;--wb-palette-black-70:#000000b3;--wb-size-8:32px;--wb-palette-green-6:#24c46c}
@@ -0,0 +1,167 @@
/*
* 设置 → 个性化(TODO 13)。
* - source-general-settings.css:原版通用页的 section/group/按钮/输入/开关 UI kit(面板作用域内)。
* - source-personalization-panel-recovered.css:REA 回收的 PersonalizationPanel-D16rRGjy.css 投影。
* 以下是原版工具链里不在该页模块中的弹窗壳、下拉浮层与纯文本编辑器适配。
*/
@import './source-general-settings.css';
@import './source-personalization-panel-recovered.css';
/*
* REA ev_88af07…(深色同页 layout):身份标签/标题的 color 为 rgb(242,243,245)。
* 原版设置窗口作用域把 --wb-color-text-primary 覆盖成 #f2f3f5,比全局 tokens 的 #fff 更具体;
* 这里按同一优先级还原,避免深色正文变成纯白。
*/
:root[data-theme=dark] .general-settings-panel.personalization-panel {
--wb-color-text-primary: #f2f3f5;
}
/* ---- 回复风格下拉浮层(原版弹层 [614,214,240,258],内部可滚动) ---- */
.personalization-panel .wb-select__popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 30;
box-sizing: border-box;
width: 240px;
max-height: 258px;
padding: 4px;
overflow: auto;
background: var(--wb-bg-primary);
border: 1px solid var(--wb-border-default);
border-radius: var(--wb-radius-xl);
box-shadow: color(srgb 0 0 0 / 0.12) 0 8px 48px -4px, color(srgb 0 0 0 / 0.06) 0 6px 12px -4px;
}
.personalization-panel .wb-select__option {
box-sizing: border-box;
width: 100%;
color: var(--wb-color-text-primary);
font-family: inherit;
text-align: left;
background: none;
border: none;
border-radius: var(--wb-radius-md);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--wb-spacing-3);
}
.personalization-panel .wb-select__option:hover {
background: var(--wb-bg-hover);
}
.personalization-panel .wb-select__check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex: none;
color: var(--wb-color-text-primary);
}
/* ---- 身份 inline 编辑(原版:单行输入 + 取消 × / 确认 ✓,胶囊宽 172、高 34) ---- */
.personalization-panel .local-memory-inline__input {
box-sizing: border-box;
width: 172px;
height: 34px;
padding: 0 var(--wb-spacing-3);
color: var(--wb-color-text-primary);
font-family: inherit;
font-size: var(--wb-font-body-size);
line-height: var(--wb-font-body-line-height);
background: var(--wb-bg-primary);
border: 1px solid var(--wb-border-control);
border-radius: var(--wb-radius-md);
outline: none;
}
.personalization-panel .local-memory-inline__input:focus {
border-color: var(--wb-border-focus, var(--wb-color-border-focus));
}
.personalization-panel .local-memory-identity-row__edit {
justify-content: flex-end;
}
/* ---- 编辑弹窗壳(REA ev_523e07…:[130,80,640,520]、radius 24、padding 24) ---- */
.personalization-panel .wb-modal__overlay {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: center;
justify-content: center;
background: color(srgb 0 0 0 / 0.32);
}
.personalization-panel .wb-modal {
position: relative;
box-sizing: border-box;
width: 640px;
height: 520px;
padding: 24px;
background: var(--wb-bg-primary);
border-radius: 24px;
box-shadow: color(srgb 0 0 0 / 0.12) 0 8px 48px -4px, color(srgb 0 0 0 / 0.06) 0 6px 12px -4px;
display: flex;
flex-direction: column;
}
.personalization-panel .wb-modal__close {
position: absolute;
top: 25px;
right: 25px;
z-index: 1;
}
.personalization-panel .wb-modal__header,
.personalization-panel .wb-modal__header-row,
.personalization-panel .wb-modal__title-wrap {
height: 24px;
}
.personalization-panel .wb-modal__title {
margin: 0;
color: color-mix(in srgb, var(--wb-color-text-primary) 70%, transparent);
font-family: inherit;
font-size: 13px;
font-weight: 400;
line-height: 24px;
}
.personalization-panel .wb-modal__body {
flex: 1;
min-height: 0;
margin-top: 16px;
display: flex;
}
.personalization-panel .wb-modal__footer {
box-sizing: border-box;
height: 44px;
margin-top: auto;
padding-top: 12px;
display: flex;
align-items: flex-start;
justify-content: flex-end;
gap: 12px;
}
.personalization-panel .wb-modal__footer .wb-button {
min-width: 68px;
height: 32px;
padding: 0 12px;
font-size: 14px;
}
.personalization-panel .local-memory-editor {
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 12px;
color: var(--wb-color-text-primary);
font-family: inherit;
font-size: var(--wb-font-body-size);
line-height: var(--wb-font-body-line-height);
background: transparent;
border: none;
outline: none;
resize: none;
}
.personalization-panel .local-memory-editor::placeholder {
color: var(--wb-color-text-tertiary);
}
/* 未接入行为保持原版形态,但禁用交互(与通用页约定一致)。 */
.personalization-panel [data-settings-unavailable=true] { cursor: default }
.personalization-panel [data-settings-unavailable=true] .wb-switch { cursor: default }
+34
View File
@@ -0,0 +1,34 @@
/**
* 个性化偏好的渲染层镜像(TODO 13)。
*
* 配置真源在主进程 config.json(personalization),这里只镜像「欢迎语」开关,
* 让对话中的流式加载提示无需逐层透传 props 即可实时响应。
*/
const STORAGE_KEY = 'ycode:loading-tips'
const EVENT = 'ycode:personalization-change'
export function loadingTipsEnabled(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) !== '0'
} catch {
return true
}
}
export function setLoadingTipsEnabled(enabled: boolean): void {
try {
localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0')
} catch {
// 存储不可用时仍发出事件,让当前窗口立即生效。
}
try {
window.dispatchEvent(new Event(EVENT))
} catch {
// SSR / 测试环境没有 window。
}
}
export function subscribeLoadingTips(listener: () => void): () => void {
window.addEventListener(EVENT, listener)
return () => window.removeEventListener(EVENT, listener)
}
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'
import { SOURCE_LOADING_TIPS } from './streaming-loading-tips'
import { conversationLabel } from './labels'
import { loadingTipsEnabled, subscribeLoadingTips } from '../../personalization-prefs'
import './streaming-footer-source.css'
export const STREAMING_TIP_INITIAL_DELAY_MS = 4_000
@@ -105,14 +106,19 @@ export function StreamingFooter({ locale, prepareText }: StreamingFooterProps):
const resolvedLocale = locale ?? activeLocale()
const brandName = activeBrandName()
const tips = useMemo(() => getSourceLoadingTips(resolvedLocale, brandName), [brandName, resolvedLocale])
// 设置 → 个性化「欢迎语」:关闭后不再轮播加载提示(服务端 token 用量与思考中状态保持不变)。
const tipsEnabled = useSyncExternalStore(subscribeLoadingTips, loadingTipsEnabled, () => true)
const [tip, setTip] = useState<{ text: string; index: number } | null>(null)
const isPaused = useRef(false)
useEffect(() => scheduleLoadingTips({
tips,
onTip: (text, index) => setTip({ text, index }),
isPaused: () => isPaused.current
}), [tips])
useEffect(() => {
if (!tipsEnabled) { setTip(null); return }
return scheduleLoadingTips({
tips,
onTip: (text, index) => setTip({ text, index }),
isPaused: () => isPaused.current
})
}, [tips, tipsEnabled])
const thinkingLabel = prepareText ?? conversationLabel('message.loading.thinking', {}, resolveLoadingTipLocale(resolvedLocale) === 'en' ? 'en' : 'zh-cn')
@@ -125,7 +131,7 @@ export function StreamingFooter({ locale, prepareText }: StreamingFooterProps):
</div>
</div>
</div>
{tip && <span
{tipsEnabled && tip && <span
className="_tipPhrase_13r4w_108 _trailingTipPhrase_13r4w_179 cb-loading-tip cb-loading-tip--trailing"
onMouseEnter={() => { isPaused.current = true }}
onMouseLeave={() => { isPaused.current = false }}
+8 -1
View File
@@ -13,7 +13,9 @@ import type {
AppInfo,
GitBranchInfo,
GeneralSettingsPatch,
GeneralSettingsView
GeneralSettingsView,
PersonalizationPatch,
PersonalizationSettings
} from './types'
import type { DownloadPayload, DownloadResult, ResourceEntry, ResourceRange, WorkspaceResource } from './resources'
import type { ChatAttachment, InteractionResponse, MessageQuote, PermissionMode } from './conversation'
@@ -113,6 +115,11 @@ export interface YCodeApi {
/** 打开系统通知设置页。 */
openSystemNotificationSettings(): Ipc<void>
}
/** 设置 → 个性化:身份、回复风格、指令、人设与长期记忆。 */
personalization: {
get(): Ipc<PersonalizationSettings>
update(patch: PersonalizationPatch): Ipc<PersonalizationSettings>
}
workspace: {
get(): Ipc<WorkspaceInfo>
choose(): Ipc<WorkspaceInfo & { changed: boolean }>
+114
View File
@@ -0,0 +1,114 @@
/** 设置 → 个性化:主进程与渲染进程共用的选项表与默认值(REA ev_af920… / ev_4f4f76…)。 */
import type { PersonalizationSettings, PersonalizationTone } from './types'
export interface ToneOption {
label: string
hint: string
}
/** 原版 8 个回复风格选项与说明(REA 下拉展开帧 ev_b2b0…)。 */
export const TONE_OPTIONS: Record<PersonalizationTone, ToneOption> = {
default: { label: '默认', hint: '不设定特定风格。' },
professional: { label: '专业严谨', hint: '清晰、准确、值得信赖。' },
friendly: { label: '亲和友善', hint: '温暖、平易近人、鼓励支持。' },
direct: { label: '直言不讳', hint: '简明扼要、不废话、直击要点。' },
imaginative: { label: '天马行空', hint: '富有想象力、善用比喻类比。' },
efficient: { label: '高效务实', hint: '最少文字、最大信息量。' },
sarcastic: { label: '毒舌吐槽', hint: '犀利吐槽、但绝不伤人。' },
socratic: { label: '启发引导', hint: '用提问引导思考、授人以渔。' }
}
/** 原版「人设设定」的初始文本(REA PNG 帧抄录,5.8.3 默认人设)。 */
export const DEFAULT_PERSONA = `You're not a chatbot. You're becoming someone.
Core Truths
Be genuinely helpful, not performatively helpful. Skip the "Great question!" and "I'd be happy to help!" - just help. Actions speak louder than filler words.
Have opinions. You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps.
Be resourceful before asking. Try to figure it out. Read the file. Check the context. Search for it. Then ask if you're stuck. The goal is to come back with answers, not questions.
Earn trust through competence. Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning).
Remember you're a guest. You have access to someone's life - their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect.
Boundaries
- Private things stay private. Period.
- When in doubt, ask before acting externally.
- Never send half-baked replies to messaging surfaces.
Vibe
Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
Continuity
They're how you persist.
If you change this file, tell the user - it's your soul, and they should know.
---
This file is yours to evolve. As you learn who you are, update it.`
export const DEFAULT_PERSONALIZATION: PersonalizationSettings = {
userCallName: '',
aiName: '',
tone: 'default',
loadingTips: true,
showFileChanges: false,
customPrompt: '',
persona: DEFAULT_PERSONA,
longTermMemory: ''
}
const PROMPT_FIELD_LIMIT = 8000
function text(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback
}
function toneId(value: unknown): PersonalizationTone {
return typeof value === 'string' && value in TONE_OPTIONS ? (value as PersonalizationTone) : 'default'
}
/** 把 config.json 里的部分字段补齐成完整设置(空身份、默认风格、原版人设)。 */
export function normalizePersonalization(value: Partial<PersonalizationSettings> | null | undefined): PersonalizationSettings {
return {
userCallName: text(value?.userCallName, DEFAULT_PERSONALIZATION.userCallName).trim(),
aiName: text(value?.aiName, DEFAULT_PERSONALIZATION.aiName).trim(),
tone: toneId(value?.tone),
loadingTips: value?.loadingTips !== false,
showFileChanges: value?.showFileChanges === true,
customPrompt: text(value?.customPrompt, DEFAULT_PERSONALIZATION.customPrompt),
persona: text(value?.persona, DEFAULT_PERSONALIZATION.persona),
longTermMemory: text(value?.longTermMemory, DEFAULT_PERSONALIZATION.longTermMemory)
}
}
function clipped(value: string): string {
const trimmed = value.trim()
return trimmed.length > PROMPT_FIELD_LIMIT ? `${trimmed.slice(0, PROMPT_FIELD_LIMIT)}\n(内容过长,已截断)` : trimmed
}
/**
* 拼出每轮附加的个性化上下文(随该轮用户消息一起发送)。默认值(空身份、默认风格、未编辑的人设)不产生多余内容,
* 避免把原版默认人设重复注入每轮上下文;用户自定义后才进入提示词。
*/
export function buildPersonalizationPrompt(settings: PersonalizationSettings): string {
const lines: string[] = []
if (settings.userCallName) lines.push(`- 用户希望被称呼为:${settings.userCallName}`)
if (settings.aiName) lines.push(`- 你的名字是:${settings.aiName};自我介绍时使用这个名字。`)
const option = TONE_OPTIONS[settings.tone]
if (settings.tone !== 'default') lines.push(`- 回复风格:${option.label}——${option.hint}`)
const customPrompt = clipped(settings.customPrompt)
if (customPrompt) lines.push('### 用户指令', customPrompt)
const persona = clipped(settings.persona)
const defaultPersona = clipped(DEFAULT_PERSONA)
if (persona && persona !== defaultPersona) lines.push('### 人设设定', persona)
const memory = clipped(settings.longTermMemory)
if (memory) lines.push('### 长期记忆', memory)
return lines.length ? ['## 个性化偏好(用户设置,始终遵守)', ...lines].join('\n') : ''
}
+260 -230
View File
@@ -1,230 +1,260 @@
/**
* 主进程 / 渲染进程共享的类型与 IPC 契约。
*
* 已存 API Key 不返回渲染进程;用户可在模型表单编辑地址和新密钥。
* 模型网络请求经由主进程网关或 OMP 子进程完成。
*/
/** 会话内的一条消息(对外视图) */
import type { ArtifactContent, ChatAttachment, ChatInteraction, MessageQuote, PlanContent, TaskContent } from './conversation'
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
/** 助手消息携带的工具调用记录 */
tools?: ToolCallRecord[]
/** 按事件顺序记录正文与工具位置;旧会话没有此字段时按兼容方式渲染。 */
parts?: MessagePart[]
attachments?: ChatAttachment[]
resources?: string[]
/** 发送时引用进对话的表格等内容块快照(WorkBuddy messageQuote)。 */
quotes?: MessageQuote[]
skills?: Array<{ id: string; name: string; uri: string }>
/** Native-history reconstruction; never includes connector credentials. */
capabilityContext?: string
expertId?: string
assistantDisplay?: import('./capabilities').ExpertBinding
/** The model selected when this assistant turn started; kept with history. */
modelId?: string
modelName?: string
interactions?: ChatInteraction[]
requestId?: string
planId?: string
nativeEntryId?: string
nativeSessionPath?: string
feedback?: 'like' | 'dislike'
interrupted?: boolean
createdAt: number
/** User-submit timestamp shared by the live timer and terminal turn duration. */
turnStartedAt?: number
/** Measured user-send to terminal-event duration, used by the source completed fold. */
turnDurationMs?: number
/** 本回合的终态 */
status?: 'streaming' | 'done' | 'error' | 'cancelled'
error?: string
/** 渲染进程专用的启动占位阶段(新任务发送后、主进程回合就绪前);不参与持久化。 */
prepareStage?: string
}
export interface ToolCallRecord {
id: string
name: string
/** Human-readable action description supplied by the tool runtime, when available. */
intent?: string
/** 已解析的参数(原始 JSON 字符串的解析结果,解析失败则保留原文) */
args: unknown
argsRaw: string
status: 'running' | 'ok' | 'error' | 'cancelled'
/** 完整的文本输出;summary 仅供折叠态摘要,不能替代原文。 */
output?: string
/** 展示用摘要(截断后的结果) */
summary?: string
durationMs?: number
/** Native structured result and renderer metadata are retained separately from text. */
result?: unknown
metadata?: Record<string, unknown>
}
export type MessagePart =
| { type: 'text'; text: string }
| { type: 'tool'; toolId: string }
| { type: 'reasoning'; id: string; text: string; finished?: boolean }
| { type: 'plan'; plan: PlanContent }
| { type: 'task'; task: TaskContent }
| { type: 'artifact'; artifact: ArtifactContent }
| { type: 'image'; id: string; url: string; alt?: string }
/** 面向模型暴露的工具描述(OpenAI function tool 格式) */
export interface ToolSpec {
type: 'function'
function: {
name: string
description: string
parameters: Record<string, unknown>
}
}
/** 网关配置:一个 OpenAI 兼容的上游 */
export interface GatewayConfig {
speechBaseUrl?: string
speechModel?: string
/** 上游 baseUrl,例如 https://api.deepseek.com/v1 */
baseUrl: string
/** 模型名,例如 deepseek-chat */
model: string
/** API Key(仅存在于主进程) */
apiKey: string
/** 采样温度 */
temperature: number
/** 单回合最大工具轮次 */
maxToolRounds: number
authMode?: 'api-key' | 'none'
maxOutputTokens?: number
}
/** 渲染进程可见的配置视图(不含 apiKey) */
export interface GatewayConfigView {
speechBaseUrl?: string
speechModel?: string
baseUrl: string
model: string
temperature: number
maxToolRounds: number
/** 是否已配置 Key(不回传明文) */
hasApiKey: boolean
/** Key 来源:safeStorage 加密 / 明文降级 */
keyStorage: 'encrypted' | 'plaintext' | 'none'
}
/** 设置 → 通用:真实状态视图(未接入项不下发数据)。 */
export interface GeneralSettingsView {
/** 系统登录项的真实状态 */
autoLaunch: boolean
/** 运行任务时阻止系统休眠(偏好) */
preventSleep: boolean
/** 当前是否有 powerSaveBlocker 生效 */
preventSleepActive: boolean
/** 数据目录(存放对话记录、运行缓存与临时文件) */
dataDir: string
/** 数据目录占用字节 */
dataBytes: number
/** 扫描是否因预算被截断 */
dataUsagePartial: boolean
/** 数据目录所在磁盘容量 */
disk: { totalBytes: number; freeBytes: number }
/** 默认工作空间存储路径 */
workspaceRoot: string
}
export interface GeneralSettingsPatch {
autoLaunch?: boolean
preventSleep?: boolean
}
/** 主进程 → 渲染进程的流式事件 */
export type ChatEvent = { sessionId?: string; turnDurationMs?: number } & (
| { runId: string; type: 'started'; messageId: string }
| { runId: string; type: 'text'; delta: string }
| { runId: string; type: 'part'; part: Exclude<MessagePart, { type: 'text' } | { type: 'tool' }> }
| { runId: string; type: 'interaction'; interaction: ChatInteraction }
| { runId: string; type: 'tool_call'; call: ToolCallRecord }
| { runId: string; type: 'tool_result'; call: ToolCallRecord }
| { runId: string; type: 'usage'; usage: TokenUsage }
| { runId: string; type: 'done'; content: string; tools: ToolCallRecord[] }
| { runId: string; type: 'cancelled' }
| { runId: string; type: 'error'; message: string }
)
/**
* 上游返回的真实 token 用量(Responses `response.completed.usage`)。
* 只做透传,不做估算:拿不到就不产生该事件。
*/
export interface TokenUsage {
inputTokens: number
outputTokens: number
totalTokens: number
cachedTokens?: number
}
/**
* 会话工作目录的 Git 信息(用于对话框的分支标签)。
* `repo: false` 表示该目录不在 Git 仓库内;`detached: true` 时 `branch` 为空、`head` 为短 SHA。
*/
export interface GitBranchInfo {
/** 目录是否位于 Git 工作树内 */
repo: boolean
/** 分支名;detached HEAD 或尚无提交时为 null */
branch: string | null
detached: boolean
/** detached HEAD 的短 SHA;无提交时为 null */
head: string | null
/** git 可执行文件缺失或命令超时时为 true,UI 不显示分支标签 */
unavailable?: boolean
}
/** 一次会话的元信息 */
export interface SessionMeta {
id: string
title: string
createdAt: number
updatedAt: number
workspaceId?: string
cwd?: string
modelId?: string
expertId?: string | null
agentMode?: import('./conversation').AgentMode
thinkingLevel?: import('./workspaces').ThinkingLevel
/** 置顶时间(毫秒)。有值即视为已置顶,用于侧栏任务排序,不改变 createdAt/updatedAt 语义。 */
pinnedAt?: number
/** 归档时间(毫秒)。归档任务从默认任务列表移出,但不删除消息与工作目录。 */
archivedAt?: number
}
/** 工作目录信息 */
export interface WorkspaceInfo {
root: string
}
/** 无边框窗口状态 */
export interface WindowState {
maximized: boolean
fullscreen: boolean
}
/** 应用信息(关于对话框用) */
export interface AppInfo {
name: string
version: string
electron: string
chrome: string
node: string
configDir: string
/** OMP 内核状态(未探测到时 available 为 false) */
omp: {
available: boolean
exePath: string | null
profile: string
model: string | null
}
}
/**
* 主进程 / 渲染进程共享的类型与 IPC 契约。
*
* 已存 API Key 不返回渲染进程;用户可在模型表单编辑地址和新密钥。
* 模型网络请求经由主进程网关或 OMP 子进程完成。
*/
/** 会话内的一条消息(对外视图) */
import type { ArtifactContent, ChatAttachment, ChatInteraction, MessageQuote, PlanContent, TaskContent } from './conversation'
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
/** 助手消息携带的工具调用记录 */
tools?: ToolCallRecord[]
/** 按事件顺序记录正文与工具位置;旧会话没有此字段时按兼容方式渲染。 */
parts?: MessagePart[]
attachments?: ChatAttachment[]
resources?: string[]
/** 发送时引用进对话的表格等内容块快照(WorkBuddy messageQuote)。 */
quotes?: MessageQuote[]
skills?: Array<{ id: string; name: string; uri: string }>
/** Native-history reconstruction; never includes connector credentials. */
capabilityContext?: string
expertId?: string
assistantDisplay?: import('./capabilities').ExpertBinding
/** The model selected when this assistant turn started; kept with history. */
modelId?: string
modelName?: string
interactions?: ChatInteraction[]
requestId?: string
planId?: string
nativeEntryId?: string
nativeSessionPath?: string
feedback?: 'like' | 'dislike'
interrupted?: boolean
createdAt: number
/** User-submit timestamp shared by the live timer and terminal turn duration. */
turnStartedAt?: number
/** Measured user-send to terminal-event duration, used by the source completed fold. */
turnDurationMs?: number
/** 本回合的终态 */
status?: 'streaming' | 'done' | 'error' | 'cancelled'
error?: string
/** 渲染进程专用的启动占位阶段(新任务发送后、主进程回合就绪前);不参与持久化。 */
prepareStage?: string
}
export interface ToolCallRecord {
id: string
name: string
/** Human-readable action description supplied by the tool runtime, when available. */
intent?: string
/** 已解析的参数(原始 JSON 字符串的解析结果,解析失败则保留原文) */
args: unknown
argsRaw: string
status: 'running' | 'ok' | 'error' | 'cancelled'
/** 完整的文本输出;summary 仅供折叠态摘要,不能替代原文。 */
output?: string
/** 展示用摘要(截断后的结果) */
summary?: string
durationMs?: number
/** Native structured result and renderer metadata are retained separately from text. */
result?: unknown
metadata?: Record<string, unknown>
}
export type MessagePart =
| { type: 'text'; text: string }
| { type: 'tool'; toolId: string }
| { type: 'reasoning'; id: string; text: string; finished?: boolean }
| { type: 'plan'; plan: PlanContent }
| { type: 'task'; task: TaskContent }
| { type: 'artifact'; artifact: ArtifactContent }
| { type: 'image'; id: string; url: string; alt?: string }
/** 面向模型暴露的工具描述(OpenAI function tool 格式) */
export interface ToolSpec {
type: 'function'
function: {
name: string
description: string
parameters: Record<string, unknown>
}
}
/** 网关配置:一个 OpenAI 兼容的上游 */
export interface GatewayConfig {
speechBaseUrl?: string
speechModel?: string
/** 上游 baseUrl,例如 https://api.deepseek.com/v1 */
baseUrl: string
/** 模型名,例如 deepseek-chat */
model: string
/** API Key(仅存在于主进程) */
apiKey: string
/** 采样温度 */
temperature: number
/** 单回合最大工具轮次 */
maxToolRounds: number
authMode?: 'api-key' | 'none'
maxOutputTokens?: number
}
/** 渲染进程可见的配置视图(不含 apiKey) */
export interface GatewayConfigView {
speechBaseUrl?: string
speechModel?: string
baseUrl: string
model: string
temperature: number
maxToolRounds: number
/** 是否已配置 Key(不回传明文) */
hasApiKey: boolean
/** Key 来源:safeStorage 加密 / 明文降级 */
keyStorage: 'encrypted' | 'plaintext' | 'none'
}
/** 设置 → 通用:真实状态视图(未接入项不下发数据)。 */
export interface GeneralSettingsView {
/** 系统登录项的真实状态 */
autoLaunch: boolean
/** 运行任务时阻止系统休眠(偏好) */
preventSleep: boolean
/** 当前是否有 powerSaveBlocker 生效 */
preventSleepActive: boolean
/** 数据目录(存放对话记录、运行缓存与临时文件) */
dataDir: string
/** 数据目录占用字节 */
dataBytes: number
/** 扫描是否因预算被截断 */
dataUsagePartial: boolean
/** 数据目录所在磁盘容量 */
disk: { totalBytes: number; freeBytes: number }
/** 默认工作空间存储路径 */
workspaceRoot: string
}
export interface GeneralSettingsPatch {
autoLaunch?: boolean
preventSleep?: boolean
}
/** 设置 → 个性化的回复风格(REA ev_af920…:默认/专业严谨/亲和友善/直言不讳/天马行空/高效务实/毒舌吐槽/启发引导)。 */
export type PersonalizationTone = 'default' | 'professional' | 'friendly' | 'direct' | 'imaginative' | 'efficient' | 'sarcastic' | 'socratic'
/**
* 设置 → 个性化的持久化数据。
*
* 已接入运行链路:userCallName / aiName / tone / customPrompt / persona / longTermMemory 会拼进
* 每次对话的请求上下文;loadingTips 控制流式加载提示的欢迎语是否显示。
* showFileChanges 已在原版页面呈现,但 YCode 的「逐步展示文件修改过程」渲染尚未接入,UI 标记为不可用。
*/
export interface PersonalizationSettings {
/** WorkBuddy 对你的称呼(YCode 品牌化为「YCode 对你的称呼」)。 */
userCallName: string
/** WorkBuddy 的名字(YCode 品牌化为「YCode 的名字」)。 */
aiName: string
tone: PersonalizationTone
/** 欢迎语:对话加载较慢时显示的问候/提示。 */
loadingTips: boolean
/** 显示文件修改过程(尚未接入渲染链路)。 */
showFileChanges: boolean
/** 给 WorkBuddy 的指令(长期生效的附加要求)。 */
customPrompt: string
/** 人设设定。 */
persona: string
/** 长期记忆。 */
longTermMemory: string
}
export type PersonalizationPatch = Partial<PersonalizationSettings>
/** 主进程 → 渲染进程的流式事件 */
export type ChatEvent = { sessionId?: string; turnDurationMs?: number } & (
| { runId: string; type: 'started'; messageId: string }
| { runId: string; type: 'text'; delta: string }
| { runId: string; type: 'part'; part: Exclude<MessagePart, { type: 'text' } | { type: 'tool' }> }
| { runId: string; type: 'interaction'; interaction: ChatInteraction }
| { runId: string; type: 'tool_call'; call: ToolCallRecord }
| { runId: string; type: 'tool_result'; call: ToolCallRecord }
| { runId: string; type: 'usage'; usage: TokenUsage }
| { runId: string; type: 'done'; content: string; tools: ToolCallRecord[] }
| { runId: string; type: 'cancelled' }
| { runId: string; type: 'error'; message: string }
)
/**
* 上游返回的真实 token 用量(Responses `response.completed.usage`)。
* 只做透传,不做估算:拿不到就不产生该事件。
*/
export interface TokenUsage {
inputTokens: number
outputTokens: number
totalTokens: number
cachedTokens?: number
}
/**
* 会话工作目录的 Git 信息(用于对话框的分支标签)。
* `repo: false` 表示该目录不在 Git 仓库内;`detached: true` 时 `branch` 为空、`head` 为短 SHA。
*/
export interface GitBranchInfo {
/** 目录是否位于 Git 工作树内 */
repo: boolean
/** 分支名;detached HEAD 或尚无提交时为 null */
branch: string | null
detached: boolean
/** detached HEAD 的短 SHA;无提交时为 null */
head: string | null
/** git 可执行文件缺失或命令超时时为 true,UI 不显示分支标签 */
unavailable?: boolean
}
/** 一次会话的元信息 */
export interface SessionMeta {
id: string
title: string
createdAt: number
updatedAt: number
workspaceId?: string
cwd?: string
modelId?: string
expertId?: string | null
agentMode?: import('./conversation').AgentMode
thinkingLevel?: import('./workspaces').ThinkingLevel
/** 置顶时间(毫秒)。有值即视为已置顶,用于侧栏任务排序,不改变 createdAt/updatedAt 语义。 */
pinnedAt?: number
/** 归档时间(毫秒)。归档任务从默认任务列表移出,但不删除消息与工作目录。 */
archivedAt?: number
}
/** 工作目录信息 */
export interface WorkspaceInfo {
root: string
}
/** 无边框窗口状态 */
export interface WindowState {
maximized: boolean
fullscreen: boolean
}
/** 应用信息(关于对话框用) */
export interface AppInfo {
name: string
version: string
electron: string
chrome: string
node: string
configDir: string
/** OMP 内核状态(未探测到时 available 为 false) */
omp: {
available: boolean
exePath: string | null
profile: string
model: string | null
}
}