From 0dc088f1f770c862f5b697fb4c7b0fae2abf58b1 Mon Sep 17 00:00:00 2001 From: "xiaohan.yuan" Date: Sat, 17 Oct 2026 10:46:40 +0800 Subject: [PATCH] feat(settings): replicate the WorkBuddy personalization panel and wire identity, tone and prompts (TODO 13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/复刻待办清单-2026-10-17.md | 38 +- package.json | 1 + scripts/personalization-prompt-regression.ts | 45 ++ scripts/rea/probe-todo13-dark-stable.json | 12 + scripts/rea/probe-todo13-light-stable.json | 12 + .../rea/probe-todo13-memory-edit-button.json | 16 + .../probe-todo13-memory-editor-cancel.json | 15 + .../probe-todo13-open-appearance-dark.json | 14 + scripts/rea/probe-todo13-open-appearance.json | 14 + ...robe-todo13-open-personalization-dark.json | 14 + ...obe-todo13-open-personalization-light.json | 14 + ...robe-todo13-persona-edit-button-retry.json | 15 + .../rea/probe-todo13-persona-edit-button.json | 16 + .../probe-todo13-persona-editor-cancel.json | 20 + .../probe-todo13-persona-editor-close.json | 16 + .../probe-todo13-persona-preview-click.json | 13 + ...todo13-personalization-ai-name-cancel.json | 16 + ...-personalization-custom-prompt-cancel.json | 12 + ...-personalization-custom-prompt-editor.json | 13 + ...sonalization-custom-prompt-save-empty.json | 15 + .../probe-todo13-personalization-editors.json | 16 + ...be-todo13-personalization-full-scroll.json | 22 + ...-todo13-personalization-identity-blur.json | 15 + ...sonalization-identity-cancel-existing.json | 14 + ...-personalization-identity-close-empty.json | 13 + ...todo13-personalization-identity-hover.json | 11 + ...do13-personalization-identity-restore.json | 15 + ...-todo13-personalization-identity-save.json | 15 + ...e-todo13-personalization-interactions.json | 19 + ...13-personalization-open-custom-prompt.json | 11 + .../probe-todo13-personalization-open.json | 14 + ...obe-todo13-personalization-return-top.json | 14 + .../probe-todo13-personalization-scroll.json | 17 + .../rea/probe-todo13-preview-open-close.json | 25 + .../probe-todo13-restore-call-name-space.json | 15 + scripts/rea/probe-todo13-restore-light.json | 8 + scripts/rea/probe-todo13-select-dark.json | 8 + .../probe-todo13-tone-dropdown-scroll.json | 17 + scripts/rea/recover-personalization-panel.mjs | 85 +++ scripts/verify-personalization-settings.cjs | 333 ++++++++++++ scripts/verify-settings-nav.cjs | 4 +- src/main/config.ts | 15 +- src/main/ipc.ts | 9 +- src/main/personalization.ts | 28 + src/preload/index.ts | 4 + .../PersonalizationSettingsPanel.tsx | 320 ++++++++++++ .../src/components/SettingsDialog.tsx | 4 +- ...source-personalization-panel-recovered.css | 50 ++ .../source-personalization-panel.css | 167 ++++++ src/renderer/src/personalization-prefs.ts | 34 ++ .../conversation/StreamingFooter.tsx | 20 +- src/shared/ipc.ts | 9 +- src/shared/personalization.ts | 114 ++++ src/shared/types.ts | 490 ++++++++++-------- 54 files changed, 2038 insertions(+), 248 deletions(-) create mode 100644 scripts/personalization-prompt-regression.ts create mode 100644 scripts/rea/probe-todo13-dark-stable.json create mode 100644 scripts/rea/probe-todo13-light-stable.json create mode 100644 scripts/rea/probe-todo13-memory-edit-button.json create mode 100644 scripts/rea/probe-todo13-memory-editor-cancel.json create mode 100644 scripts/rea/probe-todo13-open-appearance-dark.json create mode 100644 scripts/rea/probe-todo13-open-appearance.json create mode 100644 scripts/rea/probe-todo13-open-personalization-dark.json create mode 100644 scripts/rea/probe-todo13-open-personalization-light.json create mode 100644 scripts/rea/probe-todo13-persona-edit-button-retry.json create mode 100644 scripts/rea/probe-todo13-persona-edit-button.json create mode 100644 scripts/rea/probe-todo13-persona-editor-cancel.json create mode 100644 scripts/rea/probe-todo13-persona-editor-close.json create mode 100644 scripts/rea/probe-todo13-persona-preview-click.json create mode 100644 scripts/rea/probe-todo13-personalization-ai-name-cancel.json create mode 100644 scripts/rea/probe-todo13-personalization-custom-prompt-cancel.json create mode 100644 scripts/rea/probe-todo13-personalization-custom-prompt-editor.json create mode 100644 scripts/rea/probe-todo13-personalization-custom-prompt-save-empty.json create mode 100644 scripts/rea/probe-todo13-personalization-editors.json create mode 100644 scripts/rea/probe-todo13-personalization-full-scroll.json create mode 100644 scripts/rea/probe-todo13-personalization-identity-blur.json create mode 100644 scripts/rea/probe-todo13-personalization-identity-cancel-existing.json create mode 100644 scripts/rea/probe-todo13-personalization-identity-close-empty.json create mode 100644 scripts/rea/probe-todo13-personalization-identity-hover.json create mode 100644 scripts/rea/probe-todo13-personalization-identity-restore.json create mode 100644 scripts/rea/probe-todo13-personalization-identity-save.json create mode 100644 scripts/rea/probe-todo13-personalization-interactions.json create mode 100644 scripts/rea/probe-todo13-personalization-open-custom-prompt.json create mode 100644 scripts/rea/probe-todo13-personalization-open.json create mode 100644 scripts/rea/probe-todo13-personalization-return-top.json create mode 100644 scripts/rea/probe-todo13-personalization-scroll.json create mode 100644 scripts/rea/probe-todo13-preview-open-close.json create mode 100644 scripts/rea/probe-todo13-restore-call-name-space.json create mode 100644 scripts/rea/probe-todo13-restore-light.json create mode 100644 scripts/rea/probe-todo13-select-dark.json create mode 100644 scripts/rea/probe-todo13-tone-dropdown-scroll.json create mode 100644 scripts/rea/recover-personalization-panel.mjs create mode 100644 scripts/verify-personalization-settings.cjs create mode 100644 src/main/personalization.ts create mode 100644 src/renderer/src/components/PersonalizationSettingsPanel.tsx create mode 100644 src/renderer/src/components/source-personalization-panel-recovered.css create mode 100644 src/renderer/src/components/source-personalization-panel.css create mode 100644 src/renderer/src/personalization-prefs.ts create mode 100644 src/shared/personalization.ts diff --git a/docs/复刻待办清单-2026-10-17.md b/docs/复刻待办清单-2026-10-17.md index 2086a25..6838502 100644 --- a/docs/复刻待办清单-2026-10-17.md +++ b/docs/复刻待办清单-2026-10-17.md @@ -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. 记忆与进化 diff --git a/package.json b/package.json index 1d56f6e..1a9c636 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/personalization-prompt-regression.ts b/scripts/personalization-prompt-regression.ts new file mode 100644 index 0000000..5bfbad6 --- /dev/null +++ b/scripts/personalization-prompt-regression.ts @@ -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') diff --git a/scripts/rea/probe-todo13-dark-stable.json b/scripts/rea/probe-todo13-dark-stable.json new file mode 100644 index 0000000..16b8c05 --- /dev/null +++ b/scripts/rea/probe-todo13-dark-stable.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-light-stable.json b/scripts/rea/probe-todo13-light-stable.json new file mode 100644 index 0000000..9a392f7 --- /dev/null +++ b/scripts/rea/probe-todo13-light-stable.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-memory-edit-button.json b/scripts/rea/probe-todo13-memory-edit-button.json new file mode 100644 index 0000000..bc1b373 --- /dev/null +++ b/scripts/rea/probe-todo13-memory-edit-button.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-memory-editor-cancel.json b/scripts/rea/probe-todo13-memory-editor-cancel.json new file mode 100644 index 0000000..ef8a640 --- /dev/null +++ b/scripts/rea/probe-todo13-memory-editor-cancel.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-open-appearance-dark.json b/scripts/rea/probe-todo13-open-appearance-dark.json new file mode 100644 index 0000000..32bd36f --- /dev/null +++ b/scripts/rea/probe-todo13-open-appearance-dark.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-open-appearance.json b/scripts/rea/probe-todo13-open-appearance.json new file mode 100644 index 0000000..92cb924 --- /dev/null +++ b/scripts/rea/probe-todo13-open-appearance.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-open-personalization-dark.json b/scripts/rea/probe-todo13-open-personalization-dark.json new file mode 100644 index 0000000..a2f8c29 --- /dev/null +++ b/scripts/rea/probe-todo13-open-personalization-dark.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-open-personalization-light.json b/scripts/rea/probe-todo13-open-personalization-light.json new file mode 100644 index 0000000..d81c7c7 --- /dev/null +++ b/scripts/rea/probe-todo13-open-personalization-light.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-persona-edit-button-retry.json b/scripts/rea/probe-todo13-persona-edit-button-retry.json new file mode 100644 index 0000000..47674d5 --- /dev/null +++ b/scripts/rea/probe-todo13-persona-edit-button-retry.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-persona-edit-button.json b/scripts/rea/probe-todo13-persona-edit-button.json new file mode 100644 index 0000000..657c174 --- /dev/null +++ b/scripts/rea/probe-todo13-persona-edit-button.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-persona-editor-cancel.json b/scripts/rea/probe-todo13-persona-editor-cancel.json new file mode 100644 index 0000000..ad07b5b --- /dev/null +++ b/scripts/rea/probe-todo13-persona-editor-cancel.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-persona-editor-close.json b/scripts/rea/probe-todo13-persona-editor-close.json new file mode 100644 index 0000000..3564531 --- /dev/null +++ b/scripts/rea/probe-todo13-persona-editor-close.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-persona-preview-click.json b/scripts/rea/probe-todo13-persona-preview-click.json new file mode 100644 index 0000000..bc1d71d --- /dev/null +++ b/scripts/rea/probe-todo13-persona-preview-click.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-ai-name-cancel.json b/scripts/rea/probe-todo13-personalization-ai-name-cancel.json new file mode 100644 index 0000000..e03567f --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-ai-name-cancel.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-custom-prompt-cancel.json b/scripts/rea/probe-todo13-personalization-custom-prompt-cancel.json new file mode 100644 index 0000000..9afc297 --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-custom-prompt-cancel.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-custom-prompt-editor.json b/scripts/rea/probe-todo13-personalization-custom-prompt-editor.json new file mode 100644 index 0000000..b2bee75 --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-custom-prompt-editor.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-custom-prompt-save-empty.json b/scripts/rea/probe-todo13-personalization-custom-prompt-save-empty.json new file mode 100644 index 0000000..f9b6cfe --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-custom-prompt-save-empty.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-editors.json b/scripts/rea/probe-todo13-personalization-editors.json new file mode 100644 index 0000000..248c638 --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-editors.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-full-scroll.json b/scripts/rea/probe-todo13-personalization-full-scroll.json new file mode 100644 index 0000000..551480c --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-full-scroll.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-identity-blur.json b/scripts/rea/probe-todo13-personalization-identity-blur.json new file mode 100644 index 0000000..03e23fd --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-identity-blur.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-identity-cancel-existing.json b/scripts/rea/probe-todo13-personalization-identity-cancel-existing.json new file mode 100644 index 0000000..6090c24 --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-identity-cancel-existing.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-identity-close-empty.json b/scripts/rea/probe-todo13-personalization-identity-close-empty.json new file mode 100644 index 0000000..8c7565c --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-identity-close-empty.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-identity-hover.json b/scripts/rea/probe-todo13-personalization-identity-hover.json new file mode 100644 index 0000000..7ad77b1 --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-identity-hover.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-identity-restore.json b/scripts/rea/probe-todo13-personalization-identity-restore.json new file mode 100644 index 0000000..5ab6d85 --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-identity-restore.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-identity-save.json b/scripts/rea/probe-todo13-personalization-identity-save.json new file mode 100644 index 0000000..a05f8ca --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-identity-save.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-interactions.json b/scripts/rea/probe-todo13-personalization-interactions.json new file mode 100644 index 0000000..46a3516 --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-interactions.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-open-custom-prompt.json b/scripts/rea/probe-todo13-personalization-open-custom-prompt.json new file mode 100644 index 0000000..ace0256 --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-open-custom-prompt.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-open.json b/scripts/rea/probe-todo13-personalization-open.json new file mode 100644 index 0000000..3dd4e5f --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-open.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-return-top.json b/scripts/rea/probe-todo13-personalization-return-top.json new file mode 100644 index 0000000..f630fef --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-return-top.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-personalization-scroll.json b/scripts/rea/probe-todo13-personalization-scroll.json new file mode 100644 index 0000000..301ecc8 --- /dev/null +++ b/scripts/rea/probe-todo13-personalization-scroll.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-preview-open-close.json b/scripts/rea/probe-todo13-preview-open-close.json new file mode 100644 index 0000000..84de38f --- /dev/null +++ b/scripts/rea/probe-todo13-preview-open-close.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-restore-call-name-space.json b/scripts/rea/probe-todo13-restore-call-name-space.json new file mode 100644 index 0000000..da3756f --- /dev/null +++ b/scripts/rea/probe-todo13-restore-call-name-space.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-restore-light.json b/scripts/rea/probe-todo13-restore-light.json new file mode 100644 index 0000000..637dcd7 --- /dev/null +++ b/scripts/rea/probe-todo13-restore-light.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-select-dark.json b/scripts/rea/probe-todo13-select-dark.json new file mode 100644 index 0000000..cc44b12 --- /dev/null +++ b/scripts/rea/probe-todo13-select-dark.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-todo13-tone-dropdown-scroll.json b/scripts/rea/probe-todo13-tone-dropdown-scroll.json new file mode 100644 index 0000000..3fd02f9 --- /dev/null +++ b/scripts/rea/probe-todo13-tone-dropdown-scroll.json @@ -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 } + ] +} diff --git a/scripts/rea/recover-personalization-panel.mjs b/scripts/rea/recover-personalization-panel.mjs new file mode 100644 index 0000000..4808d97 --- /dev/null +++ b/scripts/rea/recover-personalization-panel.mjs @@ -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') diff --git a/scripts/verify-personalization-settings.cjs b/scripts/verify-personalization-settings.cjs new file mode 100644 index 0000000..0b54ddc --- /dev/null +++ b/scripts/verify-personalization-settings.cjs @@ -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) + }) +} diff --git a/scripts/verify-settings-nav.cjs b/scripts/verify-settings-nav.cjs index 7754f56..22c5286 100644 --- a/scripts/verify-settings-nav.cjs +++ b/scripts/verify-settings-nav.cjs @@ -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"]'); diff --git a/src/main/config.ts b/src/main/config.ts index 34b5dae..c6f77f4 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -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 } const DEFAULTS: StoredConfig = { @@ -118,6 +120,17 @@ export async function setAutoLaunch(enabled: boolean): Promise { await persist({ ...stored, autoLaunch: enabled }) } +/** 设置 → 个性化的原始持久化值(缺省字段由 personalization.ts 补齐)。 */ +export async function getStoredPersonalization(): Promise> { + const stored = await load() + return stored.personalization ?? {} +} + +export async function setStoredPersonalization(value: Partial): Promise { + const stored = await load() + await persist({ ...stored, personalization: { ...(stored.personalization ?? {}), ...value } }) +} + /** 供渲染进程使用:脱敏视图 */ export async function getConfigView(): Promise { const stored = await load() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index fbc5d1d..1236aa0 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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 diff --git a/src/main/personalization.ts b/src/main/personalization.ts new file mode 100644 index 0000000..b42f4f5 --- /dev/null +++ b/src/main/personalization.ts @@ -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 { + return normalizePersonalization(await getStoredPersonalization()) +} + +export async function updatePersonalization(patch: PersonalizationPatch): Promise { + const current = await getPersonalization() + const next = normalizePersonalization({ ...current, ...patch }) + await setStoredPersonalization(next) + return next +} diff --git a/src/preload/index.ts b/src/preload/index.ts index d492b73..f090986 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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'), diff --git a/src/renderer/src/components/PersonalizationSettingsPanel.tsx b/src/renderer/src/components/PersonalizationSettingsPanel.tsx new file mode 100644 index 0000000..c0bb0c5 --- /dev/null +++ b/src/renderer/src/components/PersonalizationSettingsPanel.tsx @@ -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 +} + +function CloseIcon(): React.JSX.Element { + return +} + +function classNames(...values: Array): 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 ( +
+
+
+

{title}

+ {desc ?

{desc}

: null} +
+
{action}
+
+ {body ?
{body}
: null} +
+ ) +} + +function Switch({ checked, ariaLabel, disabled, onChange }: { + checked: boolean + ariaLabel: string + disabled?: boolean + onChange(checked: boolean): void +}): React.JSX.Element { + return ( +
+ +
+ ) +} + +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 ( +
+ + {open ? ( +
+ {(Object.keys(TONE_OPTIONS) as PersonalizationTone[]).map((id) => { + const item = TONE_OPTIONS[id] + return ( + + ) + })} +
+ ) : null} +
+ ) +} + +export default function PersonalizationSettingsPanel(): React.JSX.Element { + const [view, setView] = useState(null) + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [editing, setEditing] = useState(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(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 => { + 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 ( +
+
{label}
+ {editing === field ? ( +
+ 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() } + }} /> +
+ + +
+
+ ) : ( + + )} +
+ ) + } + + 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 ( +
+
+
编辑} + body={
{ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open() } }}> + {value ?
{value}
:
{placeholder}
} +
} /> +
+
+ ) + } + + return ( +
+ {error ?

{error}

: null} + +
+
+ {identityRow('userCallName', 'YCode 对你的称呼')} + {identityRow('aiName', 'YCode 的名字')} +
+
+ +
+
setToneOpen((open) => !open)} onChoose={(tone) => { setToneOpen(false); void apply({ tone }) }} />} /> +
void apply({ loadingTips })} />} /> +
+ undefined} /> + } /> +
+ + {longCard('customPrompt', 'settings-custom-prompt', '给 YCode 的指令', '写下你希望 YCode 一直遵守的要求,之后每次对话和任务都会默认生效。', '例如:回答尽量简洁,先给结论')} + {longCard('persona', 'settings-local-memory-soul', '人设设定', '设定 YCode 的身份、性格与表达风格,对所有回复生效。', '暂无内容,点击编辑添加')} + {longCard('longTermMemory', 'settings-local-memory-body', '长期记忆', '跨会话保存的重要信息与结论,可随时查看和修改。', '暂无记忆记录,点击编辑添加')} + + {editor ? ( +
setEditor(null)}> +
event.stopPropagation()}> + +
+

{editor.title}

+
+
+
+