From e3e26b6bd6d22005c2ba3bf7346f74b917df965d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 14:00:45 +0800 Subject: [PATCH 01/18] docs: record bounded local coding runtime diagnosis --- ...261014-diagnose-coding-runtime-8b41c9e2.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20261014-diagnose-coding-runtime-8b41c9e2.md diff --git a/.project-docs/30-worklog/tasks/20261014-diagnose-coding-runtime-8b41c9e2.md b/.project-docs/30-worklog/tasks/20261014-diagnose-coding-runtime-8b41c9e2.md new file mode 100644 index 00000000..55956382 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-diagnose-coding-runtime-8b41c9e2.md @@ -0,0 +1,48 @@ +# Task: Diagnose unavailable local programming runtime + +## Identity + +- Task ID: 20261014-diagnose-coding-runtime-8b41c9e2 +- Mode: Feature +- Branch: codex/20261014-diagnose-coding-runtime-8b41c9e2-diagnose-coding-runtime +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-diagnose-coding-runtime-8b41c9e2 +- Base commit: f34e651774beb6a2a213b5711f2ffb887d22d4b7 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Diagnose the current trial Code runtime-unavailable notice using source, immutable runtime artifacts and bounded application startup diagnostics; explain what is actually known and the evidence gap. + +## Intent And Constraints + +- This request asks why; no product/source fix, app restart, user worker, paid model call, UI mutation, profile/credential/chat-history read or deployment. Isolated owned checkout based on the just-published f34e651. Read only startup diagnostics and runtime dependency metadata. Preserve current runtime and all peer/primary work. + +## Outcome + +- Direct failure stage identified: the Pi Agent Server starts and reaches server.ready, then the managed session-open path fails and cleans up with open_failure. The original exception is not retained by current diagnostics, so the specific underlying cause cannot honestly be identified from existing evidence. No runtime or product changes made. + +## Verification + +- No tests or paid/provider invocation required for this read-only diagnosis. Source trace independently reviewed; current native-launch.log has three server.ready→open_failure cycles, no diagnostic field/original stack, and later background_sleep events do not prove recovery. Actual Main/server artifacts match active436 manifest. Pi0.84.2 files/modules/required API exports are present and resolve in the actual retained runtime; Node version meets its engine. E2E=1 is real but does not disable coding composition/Pi. No secrets/profile state read. + +## Follow-ups + +- For a repair continuation, capture a bounded sanitized original exception at the managed session-open boundary, reproduce the failing initialization without a paid prompt, then fix its actual cause and verify the real Code session. Avoid guessing a model/network cause, blindly reinstalling dependencies or treating E2E as a disable flag. This diagnostic checkout is retained ready for that same issue; no source fix or publication was requested. + +## Promotion Candidates + +- None recorded. + +## Project Context Loaded + +- Task ID20261014-diagnose-coding-runtime-8b41c9e2, Feature, branch/worktree/basef34e651 exactly verified through official start/status;69 peers assessed through only their Scope/Intent/Promotion sections. Two old placeholder scopes remain unknown; no concrete conflict with read-only diagnosis. +- Loaded startup/memory-index and own record; canonical project-positioning/current-state/decision/system/business/evidence/reflection/commitment/stale documents are byte-identical to350f7d7 already read in this conversation. Product goal and Main-owned auth/runtime/project boundaries remain; Pi0.84.2 is the only supported coding runtime. No old provider/runtime fallback or credential handling change. +- Gate Passed for bounded diagnosis. Plan: trace public error→actual prepare/open stage, independently check retained artifacts/dependencies and sanitized existing startup events, report supported facts and missing evidence. + +## Evidence And Boundary + +- `electron/coding-runtime/conversation-service.ts:88–114` maps ordinary exceptions without publicError to503 CODING_RUNTIME_UNAVAILABLE. `electron/api/routes/coding-route-errors.ts:39` supplies the fixed Chinese message. Snapshot417–428/prepare753–788 opens the runtime even before sending a prompt. +- `electron/coding-runtime/pi/runtime.ts:238` managed opener includes provider projection/resources/extensions and SDK session opening;487–491 cleanup reasonopen_failure rethrows without preserving the raw error in current application logs. Existing server.ready events prove server startup/import but not successful thread/session initialization. +- Current stage native-launch.log contains E2E startup atline4; Main575 creates coding composition regardless. E2E alters startup effects/background idle timing, not a blanket Pi disable. Actual runtime/server/Main hashes match the active436 source; development resolves Pi CLI via snapshot node_modules→primary, not packaged resources/pi-runtime. Pi package/version,7server module paths and6required APIs are present. These checks do not prove every generated session/resource/configuration is valid. +- Read-only independent source and artifact reviewers agree: log evidence does not distinguish the original provider/configuration/extension/session exception. Specific diagnosis requires new sanitized exception evidence; do not call a hypothesis the proven root cause. From f1dabdb358ff5f2228fbef014f9e659c156ccc10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 14:20:17 +0800 Subject: [PATCH 02/18] feat: allow editing local WeChat card names --- .../20261014-wechat-card-name-7b36a2d9.md | 54 ++++++++ README.md | 2 + src/pages/CloudAgents/ChannelAccounts.tsx | 122 ++++++++++++++++-- src/pages/CloudAgents/wechat-card-names.ts | 22 ++++ tests/e2e/cloud-agent-channels.spec.ts | 28 ++++ tests/unit/channel-accounts-page.test.tsx | 97 ++++++++++++++ 6 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261014-wechat-card-name-7b36a2d9.md create mode 100644 src/pages/CloudAgents/wechat-card-names.ts diff --git a/.project-docs/30-worklog/tasks/20261014-wechat-card-name-7b36a2d9.md b/.project-docs/30-worklog/tasks/20261014-wechat-card-name-7b36a2d9.md new file mode 100644 index 00000000..dae38767 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-wechat-card-name-7b36a2d9.md @@ -0,0 +1,54 @@ +# Task: Make WeChat card display name editable + +## Identity + +- Task ID: 20261014-wechat-card-name-7b36a2d9 +- Mode: Feature +- Branch: codex/20261014-wechat-card-name-7b36a2d9-wechat-card-name +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-wechat-card-name-7b36a2d9 +- Base commit: 436f7ba1eab500e41866f890f49816ff622ef6fe +- Owner: codex +- Status: In Progress + +## Scope + +- Make the WeChat card display name editable as an account-scoped local remark; save/cancel/Enter/Escape, reload persistence and failure feedback. Preserve current complete temporary runtime436f7ba and deliver the Renderer correction. + +## Intent And Constraints + +- User explicitly requests the card name (微信4) to be editable. Display remarks are local UI preferences keyed by tenant/user/stable channel account ID, following existing device-only card removal. No cloud rename operation exists; do not rename the real WeChat profile or invent server sync. Preserve all binding/QR/revision/health/operation recovery/auto-reply contracts. No live rename value, account binding mutation, model request, cloud deployment, main merge/push/cleanup or Main restart is authorized/needed. + +## Project Context Loaded + +- Task/mode/branch/worktree/base exactly match successful official check/start/status: new managed feature worktree from436f7ba1eab500e41866f890f49816ff622ef6fe, the actual current Renderer/Main source. Runtime metadata is fresh: MainPID35142/Host58003, consolidated-five-20261014-2d069b40, same trial profile/bundle. +- Read AGENTS, read-before-planning/memory-index, own record, positioning, historical integrated current-state, decision-index/system overview, community boundary, success criteria, domain WeChat identity rules, personal-cloud ADR User-Owned/Scan-Bound sections and evidence/reflection/commitment indexes. Integrated documents are historical; pointer/artifact source identify current app. +- All72 peer Scope/Intent/Promotion sections were read only. Another same-name placeholder9e3a72c4 was investigated via Agent--渠道 thread: its naming turn is interrupted and the human explicitly said to ignore it; latest agent work stops naming and diagnoses offline readiness. It remains untouched; current chat still explicitly requests card-name editing. The binding diagnostic and other ready WeChat tasks do not define a different card-name authority. No unresolved semantic conflict. +- Existing shared/Main API exposes display_name only at create; no rename/update operation. Core account ID/router/revision/health remain authoritative. Local remarks are presentation preferences, not cloned cloud configuration; no cross-device promise. Design skill/database reviewed: retain current card palette/type, visible pencil focus target, accessible labeled field and responsive wrapping. +- Evidence/constraints: existing unit/native fixtures cover binding/recovery and must remain valid. New tests focus persistence by stable identity, account isolation, cancel/validation/storage failure and no remote mutation. Explicit edit state must block automatic restore-replies start and its async preflight; auth changes hide/close old drafts immediately and writes verify current owner. +- Likely files: ChannelAccounts.tsx, a small local remark preference helper, README and current channel unit/native fixtures. Unknown: native saved/idle UI availability for refresh; check at delivery. Gate: Passed before implementation plan. + +## Plan + +1. Add current-account local remark preference and inline pencil/edit/save/cancel controls; preserve original account DTO and consistent title/aria/dialog display. Handle validation, storage failure and owner switching, guard restore preflight during editing. +2. Run focused unit/native rename and binding regressions, typecheck, scoped lint and default build. Document exact local preference scope. +3. Prepare immutable Renderer overlay over existing Main bytes, verify live saved/idle UI, reload through native CUA and confirm edit/save/cancel controls without changing the user's real card name. Finalize only after actual UI evidence. + +## Outcome + +- Added visible title/pencil editing, a labeled inline name field and save/cancel controls. Enter saves (except IME composition), Escape cancels; trim/nonempty/64-character validation and local storage failure keep the original display and draft. Remarks are scoped by tenant/user plus stable account ID, persist across refresh/revision/rebinding and are consistently used by card headings/aria/dialog title. Returning to the original source name clears its local override. +- Owner changes immediately hide the old editor/alias and close its draft; saves verify live auth owner. Missing/hidden cards close the editor rather than locking the page. Editing prevents competing binding actions and automatic restore start/preflight while read-only health refresh remains available. Main/API/DTO/account/revision/operation identities are unchanged. README describes local-only remarks honestly. +- Renderer build ready; immutable overlay preparation and actual native open/cancel verification remain before runtime finalization. + +## Verification + +- Typecheck, scoped ESLint, default full build:vite (NIANCODE_RENDERER_ONLY unset), git diff --check and task-aware doc drift passed. Independent source review found no blockers. +- 116 channel unit cases passed, including remark persistence after revision change, cancel/Escape, validation/storage retry, user+tenant isolation, malformed storage, vanished-card unlock and Worker recovery without enabling during editing. Initial storage-failure fixture spy targeted Storage.prototype despite the suite's own mock; corrected to localStorage and reran all116 successfully. +- Isolated Electron channel lifecycle scenario passed8.0s, including added keyboard edit/Escape/Enter, 64-character long remark, real reload persistence, original-name restoration and zero extra remote operations. Existing synthetic binding/switch/unbind/recovery assertions also passed. Reviewed compact980px editor screenshot; controls/badge fit, saved long name has no card overflow. Logs `/tmp/makelore-wechatname7b36-{unit,type,lint,build,electron}.log`, images `/tmp/makelore-wechatname7b36-electron`. + +## Follow-ups + +- None recorded. + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index 02b1e42a..93ae0a51 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - 界面语言:仅保留中文;系统语言和历史设置中的其他语言会自动归一为中文。 - 品牌资产:生产 SVG、PNG、应用图标、托盘图标与安装器视觉位于 [`resources/brand/`](resources/brand/README.md) 和 `resources/icons/`。 +微信卡片名称可点击名称旁的铅笔修改,支持保存、取消、Enter 保存与 Escape 取消。备注名按当前账号与微信账号 ID 保存在本机,刷新或重新绑定后保留;不修改真实微信昵称或云端名称,不跨设备同步。保存失败保留原名称与编辑内容,可重试。 + ## 安装与开发 项目使用 `package.json` 中固定版本的 pnpm。 diff --git a/src/pages/CloudAgents/ChannelAccounts.tsx b/src/pages/CloudAgents/ChannelAccounts.tsx index 60680149..82f18d01 100644 --- a/src/pages/CloudAgents/ChannelAccounts.tsx +++ b/src/pages/CloudAgents/ChannelAccounts.tsx @@ -6,6 +6,7 @@ import { Link2, Loader2, MoreHorizontal, + Pencil, Plus, QrCode, RefreshCw, @@ -34,6 +35,7 @@ import type { import { CloudAvatar } from './CloudAvatar'; import { CloudRecovery } from './CloudRecovery'; import { CloudWechatQr } from './CloudWechatQr'; +import { MAX_WECHAT_CARD_NAME_LENGTH, readWechatCardNames, wechatCardNameKey } from './wechat-card-names'; type Flow = { mode: 'new' | 'switch' | 'reconnect' | 'unbind' | 'delete'; @@ -42,6 +44,7 @@ type Flow = { target: string; }; type Expected = { target: string | null; enabled: boolean; disconnected?: boolean }; +type NameEdit = { ownerKey: string; accountId: string; value: string; error: string }; const errorText = (error: unknown) => (error instanceof Error ? error.message : '操作没有完成,请重试'); const isChannelManagementOperation = (operation: string) => /channelaccount|channelbinding|^wechat/iu.test(operation); @@ -155,6 +158,22 @@ export function ChannelAccounts() { const navigate = useNavigate(); const user = useAuthStore((state) => state.user); const cardStorageKey = cardPreferenceKey(user); + const nameStorageKey = wechatCardNameKey(user); + const [cardNames, setCardNames] = useState(() => ({ + key: nameStorageKey, values: readWechatCardNames(nameStorageKey), + })); + const [nameEdit, setNameEdit] = useState(null); + const nameEditRef = useRef(null); + const editingName = nameEdit?.ownerKey === nameStorageKey ? nameEdit : null; + useEffect(() => { + setCardNames({ key: nameStorageKey, values: readWechatCardNames(nameStorageKey) }); + nameEditRef.current = null; + setNameEdit(null); + }, [nameStorageKey]); + const cardName = (account: CloudUserChannelAccount) => + cardNames.key === nameStorageKey && Object.hasOwn(cardNames.values, account.id) + ? cardNames.values[account.id] + : account.display_name; const [removedCards, setRemovedCards] = useState(() => ({ key: cardStorageKey, revisions: readRemovedCards(cardStorageKey), })); @@ -209,7 +228,50 @@ export function ChannelAccounts() { const ownerLogoutPending = accounts.some((account) => account.blockers.includes('wechat_disconnect_pending') ); - const disabled = Boolean(busy) || restoringReplies || recoveryBlocked || !recoveryLoaded || ownerLogoutPending; + const disabled = Boolean(busy) || restoringReplies || Boolean(editingName) || recoveryBlocked || !recoveryLoaded || ownerLogoutPending; + const cancelNameEdit = () => { + nameEditRef.current = null; + setNameEdit(null); + }; + const editingAccountVisible = !editingName || visibleAccounts.some((account) => account.id === editingName.accountId); + useEffect(() => { + if (accountsLoaded && !editingAccountVisible) { + nameEditRef.current = null; + setNameEdit(null); + } + }, [accountsLoaded, editingAccountVisible]); + const startNameEdit = (account: CloudUserChannelAccount) => { + if (!nameStorageKey || busyRef.current || restoringReplies || flow || editingName) return; + const edit = { ownerKey: nameStorageKey, accountId: account.id, value: cardName(account), error: '' }; + nameEditRef.current = edit; + setNameEdit(edit); + setMore(''); + }; + const saveNameEdit = () => { + if (!editingName) return; + if (wechatCardNameKey(useAuthStore.getState().user) !== editingName.ownerKey) { + cancelNameEdit(); + return; + } + const value = editingName.value.trim(); + if (!value || value.length > MAX_WECHAT_CARD_NAME_LENGTH) { + setNameEdit({ ...editingName, error: `请填写1–${MAX_WECHAT_CARD_NAME_LENGTH}个字符的名称。` }); + return; + } + const account = accounts.find((item) => item.id === editingName.accountId); + if (!account) { cancelNameEdit(); return; } + const values = { ...readWechatCardNames(nameStorageKey) }; + if (value === account.display_name) delete values[account.id]; + else values[account.id] = value; + try { localStorage.setItem(nameStorageKey, JSON.stringify(values)); } + catch { + setNameEdit({ ...editingName, error: '名称没有保存:暂时无法保存到这台设备,请重试。' }); + return; + } + if (wechatCardNameKey(useAuthStore.getState().user) === nameStorageKey) + setCardNames({ key: nameStorageKey, values }); + cancelNameEdit(); + }; const pendingFor = (id: string) => pending.some( (item) => isChannelManagementOperation(item.operation) && item.input.channel_account_id === id @@ -573,7 +635,7 @@ export function ChannelAccounts() { if ( loading || !accountsLoaded || !agentsLoaded || agentsError || !recoveryLoaded || recoveryBlocked || ownerLogoutPending || - busy || busyRef.current || restoringReplies || flow || more || + busy || busyRef.current || restoringReplies || flow || more || editingName || checkingIds.length || replyRestoreStopped.current ) return; const candidate = accounts.find((account) => @@ -587,7 +649,7 @@ export function ChannelAccounts() { setRestoringReplies(true); void run('restore-replies', candidate.id, async () => { const [catalog, recovery] = await Promise.all([loadAgents(), cloudAgentsApi.recovery()]); - if (!alive.current || flowRef.current) return { restored: false }; + if (!alive.current || flowRef.current || nameEditRef.current) return { restored: false }; agentsRef.current = catalog; setAgents(catalog); setPending(recovery.pending); @@ -599,7 +661,7 @@ export function ChannelAccounts() { if (!account || account.target_agent_slug !== candidate.target_agent_slug) return { restored: false, stop: true }; if ( - !alive.current || flowRef.current || ownerPending.current || + !alive.current || flowRef.current || nameEditRef.current || ownerPending.current || observed.some((item) => item.blockers.includes('wechat_disconnect_pending')) || !canRestoreReplies(account, catalog) ) return { restored: false }; @@ -619,7 +681,7 @@ export function ChannelAccounts() { if (alive.current) setRestoringReplies(false); }); }, [accounts, accountsLoaded, agents, agentsLoaded, agentsError, recoveryLoaded, - recoveryBlocked, ownerLogoutPending, busy, restoringReplies, flow, more, + recoveryBlocked, ownerLogoutPending, busy, restoringReplies, flow, more, editingName, checkingIds, loading, run, readAccounts, operationId, verifyFinal]); useEffect(() => { if (flow || recoveryBlocked || ownerLogoutPending || !accounts.some((account) => @@ -848,6 +910,8 @@ export function ChannelAccounts() { > {visibleAccounts.map((account) => { const agent = agents.find((item) => item.slug === account.target_agent_slug); + const displayName = cardName(account); + const edit = editingName?.accountId === account.id ? editingName : null; const uncertain = !recoveryLoaded || checkingIds.includes(account.id) || pendingFor(account.id); const targetDisabled = agents.some( @@ -868,15 +932,51 @@ export function ChannelAccounts() { return (
-
+

微信

-

- {account.display_name} -

+ {edit ? ( +
{ event.preventDefault(); saveNameEdit(); }}> + setNameEdit({ ...edit, value: event.target.value, error: '' })} + onKeyDown={(event) => { + if (event.key === 'Escape') { event.preventDefault(); cancelNameEdit(); } + if (event.key === 'Enter' && event.nativeEvent.isComposing) event.preventDefault(); + }} + className="h-10 min-w-0 rounded-xl" + /> +

+ {edit.error ? {edit.error} : '备注名仅保存在这台电脑的当前账号中。'} +

+
+ + +
+
+ ) : ( +

+ +

+ )}
{flow?.mode === 'new' ? '选一个智能体,再用微信扫码。' - : (selected?.display_name ?? '微信绑定')} + : (selected ? cardName(selected) : '微信绑定')} {feedback} diff --git a/src/pages/CloudAgents/wechat-card-names.ts b/src/pages/CloudAgents/wechat-card-names.ts new file mode 100644 index 00000000..51fe8cad --- /dev/null +++ b/src/pages/CloudAgents/wechat-card-names.ts @@ -0,0 +1,22 @@ +import type { AuthUser } from '@/stores/auth'; + +export const MAX_WECHAT_CARD_NAME_LENGTH = 64; + +export function wechatCardNameKey(user: AuthUser | null) { + return user?.userId + ? 'makelore.wechat-card-names:' + JSON.stringify([user.tenantId, user.userId]) + : ''; +} + +export function readWechatCardNames(key: string): Record { + if (!key) return {}; + try { + const value: unknown = JSON.parse(localStorage.getItem(key) ?? '{}'); + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.fromEntries(Object.entries(value).flatMap(([id, name]) => { + if (typeof name !== 'string') return []; + const trimmed = name.trim(); + return trimmed && trimmed.length <= MAX_WECHAT_CARD_NAME_LENGTH ? [[id, trimmed]] : []; + })); + } catch { return {}; } +} diff --git a/tests/e2e/cloud-agent-channels.spec.ts b/tests/e2e/cloud-agent-channels.spec.ts index 450ee90d..a46a7412 100644 --- a/tests/e2e/cloud-agent-channels.spec.ts +++ b/tests/e2e/cloud-agent-channels.spec.ts @@ -578,6 +578,34 @@ test('WeChat binding cards connect an account and switch its Agent through the c ).toHaveCount(0); await expect(legacyCard.getByText('状态待确认', { exact: true })).toHaveCount(0); await expect(spareCard.getByRole('button', { name: '绑定智能体', exact: true })).toBeVisible(); + const callsBeforeRemarks = await app.evaluate(() => (globalThis as typeof globalThis & { + cloudChannelFixtureCalls: { operation: string }[]; + }).cloudChannelFixtureCalls.filter((item) => item.operation !== 'channelAccounts')); + await spareCard.getByRole('button', { name: '修改微信卡片名称:备用微信', exact: true }).click(); + const cardNameField = page.getByRole('textbox', { name: '微信卡片名称', exact: true }); + await cardNameField.fill('未保存备注'); + await cardNameField.press('Escape'); + await expect(cardNameField).toHaveCount(0); + await spareCard.getByRole('button', { name: '修改微信卡片名称:备用微信', exact: true }).click(); + await cardNameField.fill('A'.repeat(64)); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(980, 700)); + await page.screenshot({ path: testInfo.outputPath('wechat-card-name-edit-compact.png'), fullPage: true }); + await cardNameField.press('Enter'); + const remarkedCard = page.getByRole('article', { name: 'A'.repeat(64) + '微信', exact: true }); + await expect(remarkedCard).toBeVisible(); + expect(await remarkedCard.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + await page.reload(); + await expect(remarkedCard).toBeVisible(); + await expect(remarkedCard.getByRole('button', { name: '绑定智能体', exact: true })).toBeVisible(); + await remarkedCard.getByRole('button', { name: '修改微信卡片名称:' + 'A'.repeat(64), exact: true }).click(); + await cardNameField.fill('备用微信'); + await cardNameField.press('Enter'); + await expect(spareCard).toBeVisible(); + const callsAfterRemarks = await app.evaluate(() => (globalThis as typeof globalThis & { + cloudChannelFixtureCalls: { operation: string }[]; + }).cloudChannelFixtureCalls.filter((item) => item.operation !== 'channelAccounts')); + expect(callsAfterRemarks).toEqual(callsBeforeRemarks); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1366, 900)); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page.getByText('活动记录', { exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0); diff --git a/tests/unit/channel-accounts-page.test.tsx b/tests/unit/channel-accounts-page.test.tsx index b6a76a8e..5b02e95a 100644 --- a/tests/unit/channel-accounts-page.test.tsx +++ b/tests/unit/channel-accounts-page.test.tsx @@ -197,6 +197,103 @@ afterEach(() => { vi.useRealTimers(); }); +const nameKey = (userId = 'creator', tenantId: string | null = null) => + 'makelore.wechat-card-names:' + JSON.stringify([tenantId, userId]); +async function editCardName(name = legacy.display_name) { + const current = await card(name); + fireEvent.click(within(current).getByRole('button', { name: '修改微信卡片名称:' + name })); + return within(current).getByRole('textbox', { name: '微信卡片名称' }); +} +it('saves a local card remark through refresh and revision changes without touching the binding', async () => { + const original = structuredClone(accounts); + const view = renderPage(); + const field = await editCardName(); + fireEvent.change(field, { target: { value: ' 工作微信 ' } }); + fireEvent.submit(field.closest('form')!); + expect(within(await card('工作微信')).getByRole('heading', { name: '工作微信' })).toBeVisible(); + expect(accounts).toEqual(original); + expect(writes()).toEqual([]); + view.unmount(); + accounts[0].revision += 1; + renderPage(); + expect(await card('工作微信')).toBeVisible(); + expect(await card(spare.display_name)).toBeVisible(); + expect(writes()).toEqual([]); +}); +it('cancels typed remarks with either Cancel or Escape', async () => { + renderPage(); + let field = await editCardName(); + fireEvent.change(field, { target: { value: '不保存的名称' } }); + fireEvent.click(screen.getByRole('button', { name: '取消', exact: true })); + expect(screen.queryByRole('textbox', { name: '微信卡片名称' })).not.toBeInTheDocument(); + field = await editCardName(); + fireEvent.change(field, { target: { value: '也不保存' } }); + fireEvent.keyDown(field, { key: 'Escape' }); + expect(await card()).toBeVisible(); + expect(localStorage.getItem(nameKey())).toBeNull(); + expect(writes()).toEqual([]); +}); +it('keeps the original name and typed draft on validation or storage failure, then allows retry', async () => { + renderPage(); + const field = await editCardName(); + fireEvent.change(field, { target: { value: ' ' } }); + fireEvent.submit(field.closest('form')!); + expect(screen.getByRole('alert')).toHaveTextContent('请填写1–64个字符'); + expect(localStorage.getItem(nameKey())).toBeNull(); + fireEvent.change(field, { target: { value: '生活微信' } }); + const write = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new Error('storage unavailable'); }); + fireEvent.submit(field.closest('form')!); + expect(screen.getByRole('alert')).toHaveTextContent('名称没有保存'); + expect(field).toHaveValue('生活微信'); + expect(await card()).toBeVisible(); + write.mockRestore(); + fireEvent.submit(field.closest('form')!); + expect(await card('生活微信')).toBeVisible(); + expect(writes()).toEqual([]); +}); +it.each([ + ['account', 'another-creator', null], + ['tenant', 'creator', 'another-tenant'], +] as const)('isolates card remarks and closes the old draft when the %s changes', async (_scope, userId, tenantId) => { + localStorage.setItem(nameKey(), JSON.stringify({ [legacy.id]: '本账号备注' })); + localStorage.setItem(nameKey(userId, tenantId), JSON.stringify({ [legacy.id]: '其他账号备注' })); + renderPage(); + const field = await editCardName('本账号备注'); + fireEvent.change(field, { target: { value: '旧账号未保存内容' } }); + await act(async () => useAuthStore.setState({ user: { username: userId, userId, tenantId, deptId: null, authorities: [] } })); + expect(screen.queryByRole('textbox', { name: '微信卡片名称' })).not.toBeInTheDocument(); + expect(await card('其他账号备注')).toBeVisible(); + expect(JSON.parse(localStorage.getItem(nameKey())!)[legacy.id]).toBe('本账号备注'); + expect(JSON.parse(localStorage.getItem(nameKey(userId, tenantId))!)[legacy.id]).toBe('其他账号备注'); +}); +it('ignores malformed local remarks and falls back to the original card name', async () => { + localStorage.setItem(nameKey(), JSON.stringify({ [legacy.id]: { name: 'not a string' }, [spare.id]: ' ' })); + renderPage(); + expect(await card()).toBeVisible(); + expect(await card(spare.display_name)).toBeVisible(); +}); +it('closes an edit when its card disappears on refresh and leaves the page usable', async () => { + renderPage(); + await editCardName(); + accounts = [{ ...spare }]; + fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' })); + await waitFor(() => expect(screen.queryByRole('textbox', { name: '微信卡片名称' })).not.toBeInTheDocument()); + expect(await card(spare.display_name)).toBeVisible(); + expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled(); + expect(writes()).toEqual([]); +}); +it('does not restore replies while a card name is being edited after Worker recovery', async () => { + accounts = [restorable({ worker_online: false, blockers: ['worker_offline', 'binding_paused'] })]; + vi.useFakeTimers({ shouldAdvanceTime: true }); + renderPage(); + await editCardName(); + accounts[0].worker_online = true; + accounts[0].blockers = ['binding_paused']; + await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); + expect(calls('enableChannelAccount')).toHaveLength(0); + expect(screen.getByRole('textbox', { name: '微信卡片名称' })).toBeVisible(); +}); + it.each([ ['none', []], ['drafts', [{ ...agentA, published_version: null }]], From 0d6ac413dcaa8a04c6db6bfab3aca835e0460ef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 14:25:44 +0800 Subject: [PATCH 03/18] fix: submit the confirmed project work source directory --- ...61014-release-source-directory-3ccabc06.md | 68 +++++++ README.md | 2 + electron/services/project-packager.ts | 74 ++++++-- src/lib/works-project-publish.ts | 9 +- tests/unit/project-packager.test.ts | 177 +++++++++++++++++- tests/unit/project-publish-action.test.tsx | 34 +++- tests/unit/project-release-builder.test.ts | 83 +++++++- tests/unit/works-project-publish.test.ts | 14 +- 8 files changed, 433 insertions(+), 28 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261014-release-source-directory-3ccabc06.md diff --git a/.project-docs/30-worklog/tasks/20261014-release-source-directory-3ccabc06.md b/.project-docs/30-worklog/tasks/20261014-release-source-directory-3ccabc06.md new file mode 100644 index 00000000..44635ecb --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-release-source-directory-3ccabc06.md @@ -0,0 +1,68 @@ +# Task: Use confirmed work engineering directory for release submission + +## Identity + +- Task ID: 20261014-release-source-directory-3ccabc06 +- Mode: Feature +- Branch: codex/20261014-release-source-directory-3ccabc06-release-source-directory +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-release-source-directory-3ccabc06 +- Base commit: f34e651774beb6a2a213b5711f2ffb887d22d4b7 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Repair existing work submission source selection by reusing the validated saved work directory and constrained work discovery used by opening. Preserve registered project metadata/history identity and release validation; add focused regression coverage and accurate failure guidance. + +## Intent And Constraints + +- User explicitly requested repair after read-only diagnosis. Do not move/rewrite the user's game or metadata, create conversations, call a model, submit to the real service, restart/update the current trial, merge/push, or clean other worktrees as part of this source repair. +- Keep registered root configuration, project id, submission binding and build concurrency identity unchanged; distinguish source root from project root. Saved launch is a directory clue, not publish readiness. Keep existing root Vite projects publishable without requiring dev scripts. + +## Project Context Loaded + +Task context: +- Official start/status verified feature task id, branch, absolute worktree and base f34e651774beb6a2a213b5711f2ffb887d22d4b7 (latest fetched origin/main). Primary checkout has unrelated dirty task documentation and is untouched. +- All74 peer scopes/222 required sections read/hash checked; no related semantic conflict. Work-entry and preview recovery require project-level identity, bounded unique discovery, no arbitrary command replay and no automatic model/conversation. + +Read: +- Read-before-planning, memory-index, own record, planning-gate and AGENTS. Reused unchanged canonical context from the diagnosis on identical base f34e651; re-read positioning, decision/system/domain/evidence/reflection/commitment/stale indexes and relevant current-state sections, ADR008, project metadata and release/work-entry source. + +Relevant understanding: +- Main owns project configuration and release packaging/build/preflight; project creation generates metadata/knowledge only and optional scaffold never overwrites existing code. +- The real alice project registers the outer root but has a verified saved Vite launch.directory for the inner engineering folder. Current opening already uses it; packager validates/scans only registered root. +- Directory selection must not change project id or cloud contracts and must retain workspace-to-source ancestry checks during packaging. Production publishing and installed-package verification are not implied by local tests. +- Files: work-entry-store/work-launcher, project-packager, release builder tests, publish failure projection and README. Existing dormant gallery pages are not restored; only the current project-config submit action is repaired. + +Gate result: Passed. + +## Plan + +1. Select a source folder in the packager using the same trusted saved work-entry and bounded discovery helpers as opening; retain registered-root configuration and root-project compatibility. +2. Verify the whole registered-root-to-source directory chain throughout validation/scan/streamed reads; package only source-relative files. +3. Add nested-source, ambiguity, unchanged root, missing input and path-race regression tests; correct misleading template-damage guidance, run focused checks, build and independent review. + +## Outcome + +- Implemented source selection in the shared packager used by full and summary release builders, including utility builds. Reads project type/id from registered-root configuration, then prefers loadWorkEntry's validated launch.directory. With no saved directory, preserves existing root-package release behavior (including missing/complex dev scripts), otherwise uses opening's existing bounded unique discovery. No URL probe, startup, model, metadata write or new persisted schema. +- Only the selected source is validated/scanned/zipped with source-relative archive paths. Registered-root identity, release busy key, version naming and submission binding remain unchanged; an inner .makelore config cannot override the registered project. +- Retained and repeatedly checked the whole registered-root-to-source directory identity chain during validation, recursive scan and streamed reads. Invalid/copy/traversal/symlink entries cannot authorize outside content; configured private/generated source folders are rejected. An ambiguous discovery reports PROJECT_SOURCE_UNCONFIRMED rather than choosing a candidate. +- Kept mandatory package/lock/index, Vite declaration, source exclusions/limits, immutable build snapshot and browser preflight behavior. Missing inputs in a valid selected source do not fall back to another/root game. +- Corrected missing-file guidance to describe absent/unreadable publishing inputs without claiming template deletion/damage; added safe actionable directory-ambiguity guidance. Updated README and meaningful regressions. Independent review approved with no blocker. + +## Verification + +- Exact pinned pnpm10.33.4 used (initial global fallback version check found11.25.0; all verification commands explicitly used10.33.4). Dependency directory is an ignored link to existing installed primary dependencies; no dependency/lock changes or download committed. +- Standard typecheck, strict targeted Main module-closure tsc, scoped ESLint and Renderer/Main/Preload/release-utility production build all passed. Logs: /tmp/makelore-release-source-typecheck-3ccabc06.log, /tmp/makelore-release-source-main-types-3ccabc06.log, /tmp/makelore-release-source-lint-3ccabc06.log, /tmp/makelore-release-source-build-3ccabc06.log. +- 219 related unit tests passed: work-entry/launcher/preview/release-job/routes/scaffold156; packager22 and builder3; publish failure projection/component38. New full/summary builder checks inspect source archives and immutable build snapshots, with every fixture user file unchanged; builder test uses node environment for native ZIP behavior. +- Native Electron2/2 passed: saved work survives full restart and restarts a stopped service without new conversation/model; production local preflight isolates requests and cleans temporary renderers. Isolated fixture user-data/home used; running trial/account untouched. Log: /tmp/makelore-release-source-electron-3ccabc06.log; artifacts /tmp/makelore-release-source-electron-3ccabc06. +- Scoped tests include nested Unicode source, outer valid root as fallback trap, inner absent/custom config, no saved entry unique discovery, multiple candidates, unsafe/copied entries, missing selected lock and ancestor substitution between scan/stream. Test logs: /tmp/release-source-directory-tests-final-3ccabc06.log and /tmp/makelore-release-source-regression-3ccabc06.log. +- No real-project file changes, paid model, dependency installation into the user's game, real submit/upload, app update, merge/push or other-worktree cleanup. Tests use controlled build runtime and do not claim actual production npm/build/review success. + +## Follow-ups + +- Ready for integration and temporary-app delivery when requested. Current running trial still contains the previous packager. No actual user project or real cloud submission was exercised. + +## Promotion Candidates + +- Target: ADR008 and architecture data-flow/module-map. Proposal: distinguish registered project root (identity/config/history/binding) from project-contained engineering source, reusing validated work entry and constrained discovery for submission while keeping release-specific validation and root compatibility. Evidence: product packager change,219 units,2 Electron cases and independent review. Future impact: prevent later opening/publishing features from reintroducing the root mismatch or using preview as release readiness proof. Semantic conflicts: none found in74 peer scopes; no cloud contract or metadata migration. Human confirmation: current repair direction explicitly authorized; canonical promotion still requires a serialized Integration task. diff --git a/README.md b/README.md index 02b1e42a..c6ed4c18 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 作品入口按项目记住已确认的本机页面和受限的打开方式。应用重启后先检查并复用原服务;服务停止时,可直接重新启动已有的 Vite 工程或普通静态网页,再显示实际可访问的页面,不要求当前对话选择模型,也不自动新建对话。旧项目可从已成功打开页面的工具回执恢复入口。空项目仍显示原有空状态;无法确定打开方式、缺少运行组件或入口改变时说明原因,由用户明确选择「让助手检查」后才调用编程助手。后台恢复只访问本机地址,保存的打开方式按项目真实目录校验,不保存或重放任意终端命令。 +提交作品优先使用打开作品时已记住并通过项目身份校验的工程目录;没有有效记录时,保留根目录工程的发布兼容性,或在项目内明确找到唯一的已有工程。游戏位于项目子目录时,只打包该工程中的源码,项目配置、对话身份和提交记录仍属于原登记项目。目录不明确时说明原因,不自动选择其他作品;确认目录后仍执行完整发布文件、依赖、构建与预检校验。 + 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建 Code 项目只要求选择目录:Main 自动生成内部项目 ID,并以内部 `interactive_ai_app` 类型创建 `.makelore/project.json` 与 `knowledge/`,不再让用户选择或查看项目身份、项目类型和模板;缺少项目 ID 的旧项目在读取时由 Main 自动补全。从列表移除项目只取消登记,不删除磁盘文件;在“新建项目”中直接选择已有项目文件夹会重新打开,并保留原有项目身份、类型、智能体、对话和知识文件。“新建下级文件夹”仍拒绝已存在的同名目录。创建成功后直接进入对话工作区,未创建智能体时只显示可选的设置入口,不再用初始化门禁遮挡工作区。已有 `custom` 项目继续受支持;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取并为项目启用官方 bundled `makelore.project-scaffold` 插件后,每个父智能体都可按需明确调用 `makelore-project-scaffold` Skill,无需伙伴分配;它以不覆盖既有路径的方式生成固定六文件 Vite 起步工程,不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。 创建智能体时,如果本次新操作被服务明确拒绝,会显示失败原因、保留名称和头像,并允许修改后重新提交;不会留下“结果尚未确认”提醒。断网、响应异常或已有待确认操作的重试失败仍保留原创建请求,可重试确认结果,避免重复创建。当前创建表单一旦出现结果不确定,即使移除提醒后重试被拒绝,也继续保留原请求;移除提醒不代表取消创建。创建页面会及时刷新本机恢复记录。服务器的字段校验错误只转换为固定中文提示,不展示内部错误或提交原文。一句话介绍仍可留空;创建与保存需要配套的智能体云端服务支持。 diff --git a/electron/services/project-packager.ts b/electron/services/project-packager.ts index 8043ce90..b9622d0a 100644 --- a/electron/services/project-packager.ts +++ b/electron/services/project-packager.ts @@ -10,6 +10,8 @@ import { import { dirname, isAbsolute, join, relative, sep } from 'node:path'; import type { ProjectType } from '../../shared/project-config'; import { readCodingProjectConfigV2 } from '../coding-projects/project-config'; +import { loadWorkEntry } from '../coding-runtime/work-entry-store'; +import { discoverWorkLaunch } from '../coding-runtime/work-launcher'; import { writeStoredZip } from './streaming-zip'; type PublishableProjectType = Exclude; @@ -181,10 +183,21 @@ export async function createStaticProjectPackage(input: { limits?: ProjectPackageLimits; }): Promise { const limits = input.limits ?? DEFAULT_LIMITS; - const projectPath = await resolveProjectDirectory(input.projectPath); - const projectType = await readPublishableProjectType(projectPath); + const projectRoot = await resolveProjectDirectory(input.projectPath); + const rootIdentity = await readSafeDirectoryIdentity(projectRoot, projectRoot); + const { projectType, projectId } = await readPublishableProjectConfig(projectRoot); + const sourceDirectory = await resolveSourceDirectory(projectRoot, projectId); + await assertDirectoryIdentityChain(projectRoot, [rootIdentity]); + const sourceChain = [rootIdentity]; + let projectPath = projectRoot; + for (const part of sourceDirectory.split('/').filter((part) => part !== '.')) { + projectPath = join(projectPath, part); + sourceChain.push(await readSafeDirectoryIdentity(projectRoot, projectPath)); + } + await assertDirectoryIdentityChain(projectRoot, sourceChain); await validateViteProject(projectPath, limits); - const scan = await scanProject(projectPath, limits); + await assertDirectoryIdentityChain(projectRoot, sourceChain); + const scan = await scanProject(projectPath, limits, projectRoot, sourceChain); const manifestBytes = Buffer.from(generatedManifest(projectType), 'utf8'); const sourceBytes = scan.sourceBytes + manifestBytes.length; if (sourceBytes > limits.maxSourceBytes) { @@ -217,7 +230,7 @@ export async function createStaticProjectPackage(input: { return { path: archivePath, source: { filePath: file.absolutePath }, - beforeRead: async () => assertStablePackageFile(projectPath, file), + beforeRead: async () => assertStablePackageFile(projectRoot, file), afterRead: async ({ size }) => { if (size !== file.size) { throw new ProjectPackageError( @@ -225,7 +238,7 @@ export async function createStaticProjectPackage(input: { `打包过程中项目文件发生变化:${file.archivePath}`, ); } - await assertStablePackageFile(projectPath, file); + await assertStablePackageFile(projectRoot, file); }, }; }), @@ -268,7 +281,10 @@ export async function createStaticProjectPackage(input: { }; } -async function readPublishableProjectType(projectPath: string): Promise { +async function readPublishableProjectConfig(projectPath: string): Promise<{ + projectType: PublishableProjectType; + projectId?: string; +}> { const result = await readCodingProjectConfigV2(projectPath); if (result.status === 'invalid') { throw new ProjectPackageError( @@ -282,7 +298,31 @@ async function readPublishableProjectType(projectPath: string): Promise { + const saved = projectId ? await loadWorkEntry({ id: projectId, path: projectRoot }) : null; + let directory = saved?.launch?.directory; + if (directory === undefined) { + // Existing root projects may build without a replayable dev script. Their + // release validation remains authoritative; opening need not be supported. + const rootPackage = await lstat(join(projectRoot, 'package.json')).catch(() => null); + if (rootPackage) return '.'; + try { + directory = (await discoverWorkLaunch(projectRoot))?.directory ?? '.'; + } catch { + throw new ProjectPackageError('PROJECT_SOURCE_UNCONFIRMED', '无法唯一确认当前作品的工程目录'); + } + } + if (directory !== '.') { + validateArchivePath(directory); + if (directory.split('/').some((part) => part.startsWith('.') || EXCLUDED_DIRECTORY_NAMES.has(part.toLowerCase()))) { + throw new ProjectPackageError('PROJECT_SOURCE_UNCONFIRMED', '作品工程目录不能是本地配置或生成目录'); + } + } + return directory; } async function resolveProjectDirectory(projectPath: string): Promise { @@ -386,6 +426,8 @@ async function readRequiredFile( async function scanProject( projectPath: string, limits: ProjectPackageLimits, + projectRoot: string, + sourceChain: DirectoryIdentity[], ): Promise { const files: PackageFile[] = []; const excludedPaths: string[] = []; @@ -396,14 +438,14 @@ async function scanProject( directory: string, parentChain: DirectoryIdentity[], ): Promise { - await assertDirectoryIdentityChain(projectPath, parentChain); - const before = await readSafeDirectoryIdentity(projectPath, directory); + await assertDirectoryIdentityChain(projectRoot, parentChain); + const before = await readSafeDirectoryIdentity(projectRoot, directory); const directoryChain = [...parentChain, before]; const entries = await readdir(directory, { withFileTypes: true }); - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); entries.sort((left, right) => left.name.localeCompare(right.name, 'en')); for (const entry of entries) { - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); const absolutePath = join(directory, entry.name); const archivePath = toArchivePath(relative(projectPath, absolutePath)); const isDirectory = entry.isDirectory(); @@ -412,7 +454,7 @@ async function scanProject( if (excludedPaths.length < 100) { excludedPaths.push(isDirectory ? `${archivePath}/` : archivePath); } - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); continue; } if (entry.isSymbolicLink()) { @@ -424,7 +466,7 @@ async function scanProject( validateArchivePath(archivePath); if (isDirectory) { await walk(absolutePath, directoryChain); - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); continue; } if (!entry.isFile()) { @@ -463,12 +505,12 @@ async function scanProject( `项目文件数超过 ${limits.maxFiles} 个限制`, ); } - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); } - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); } - await walk(projectPath, []); + await walk(projectPath, sourceChain); return { files, sourceBytes, diff --git a/src/lib/works-project-publish.ts b/src/lib/works-project-publish.ts index 7d08ef15..ca8f4fde 100644 --- a/src/lib/works-project-publish.ts +++ b/src/lib/works-project-publish.ts @@ -87,8 +87,13 @@ const PUBLISH_FAILURES: Record = { }, PROJECT_FILE_MISSING: { title: '项目文件不完整', - reason: '项目模板中的发布文件被删除或损坏。', - nextStep: '请让开发助手修复当前项目模板后重试。', + reason: '当前作品工程缺少发布所需的文件,或文件无法读取。', + nextStep: '请让开发助手检查当前作品工程,补齐发布文件后重试。', + }, + PROJECT_SOURCE_UNCONFIRMED: { + title: '需要确认提交哪个作品', + reason: '当前项目中无法明确找到唯一的作品工程。', + nextStep: '请让开发助手确认作品的工程位置,打开作品后再提交。', }, PROJECT_TYPE_UNPUBLISHABLE: { title: '这个项目没有配置发布方式', diff --git a/tests/unit/project-packager.test.ts b/tests/unit/project-packager.test.ts index 838a88fe..64043221 100644 --- a/tests/unit/project-packager.test.ts +++ b/tests/unit/project-packager.test.ts @@ -12,7 +12,7 @@ import { utimes, writeFile, } from 'node:fs/promises'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { @@ -41,6 +41,7 @@ const require = createRequire(import.meta.url); const AdmZip = require('adm-zip') as typeof import('adm-zip'); const tempDirectories: string[] = []; +const projectId = '11111111-1111-4111-8111-111111111111'; async function createProject(projectType: ProjectType = 'interactive_ai_app'): Promise { const projectPath = await mkdtemp(join(tmpdir(), 'makelore-project-packager-')); @@ -79,6 +80,40 @@ async function createProject(projectType: ProjectType = 'interactive_ai_app'): P return await realpath(projectPath); } +async function createNestedSource(projectPath: string, directory = 'games/Alice 的游戏'): Promise { + const source = join(projectPath, directory); + await mkdir(join(source, 'src'), { recursive: true }); + await writeFile(join(source, 'package.json'), JSON.stringify({ + name: 'selected-inner-game', packageManager: 'npm@10.9.2', + scripts: { dev: 'vite --host 0.0.0.0 --port 5179' }, devDependencies: { vite: '^7.0.0' }, + })); + await writeFile(join(source, 'package-lock.json'), JSON.stringify({ name: 'selected-inner-game', lockfileVersion: 3, packages: {} })); + await writeFile(join(source, 'index.html'), '
selected inner game
'); + await writeFile(join(source, 'src', 'main.ts'), 'console.log("inner source")\n'); + return source; +} + +async function saveSourceEntry(projectPath: string, directory: string, patch: Record = {}): Promise { + const configPath = join(projectPath, '.makelore', 'project.json'); + const config = JSON.parse(await readFile(configPath, 'utf8')); + await writeFile(configPath, JSON.stringify({ ...config, projectId })); + await writeFile(join(projectPath, '.makelore', 'work-entry.json'), JSON.stringify({ + version: 1, projectId, projectPath, url: 'http://127.0.0.1:5179/', + launch: { kind: 'vite', directory, args: [] }, ...patch, + })); +} + +async function archiveTarget(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-')); + tempDirectories.push(directory); + return join(directory, 'project.zip'); +} + +async function removeOuterSource(projectPath: string): Promise { + await Promise.all(['package.json', 'package-lock.json', 'index.html', 'src', 'public'] + .map(name => rm(join(projectPath, name), { recursive: true, force: true }))); +} + afterEach(async () => { openRaceHook.beforeOpen = null; await Promise.all( @@ -87,6 +122,146 @@ afterEach(async () => { }); describe('project packager', () => { + it.each([false, true])('packages the saved inner source at zip root using only outer project configuration (inner custom config=%s)', async (innerConfig) => { + const projectPath = await createProject(); + const source = await createNestedSource(projectPath); + if (innerConfig) { + await mkdir(join(source, '.makelore')); + await writeFile(join(source, '.makelore', 'project.json'), JSON.stringify( + createCodingProjectConfigV2('2026-08-09T00:00:00.000Z', 'custom', '22222222-2222-4222-8222-222222222222'), + )); + } + await mkdir(join(projectPath, 'knowledge')); + await mkdir(join(projectPath, 'sibling-assets')); + await writeFile(join(projectPath, 'knowledge', 'notes.txt'), 'private knowledge'); + await writeFile(join(projectPath, 'sibling-assets', 'cover.png'), 'unrelated image'); + await writeFile(join(projectPath, 'private.txt'), 'outer private file'); + await saveSourceEntry(projectPath, 'games/Alice 的游戏'); + const outerConfig = await readFile(join(projectPath, '.makelore', 'project.json')); + const savedEntry = await readFile(join(projectPath, '.makelore', 'work-entry.json')); + const archivePath = await archiveTarget(); + + const summary = await createStaticProjectPackage({ projectPath, archivePath }); + + const archive = new AdmZip(archivePath); + expect(archive.getEntries().map(entry => entry.entryName)).toEqual([ + 'index.html', 'niancode.yml', 'package-lock.json', 'package.json', 'src/main.ts', + ]); + expect(archive.readAsText('index.html')).toBe('
selected inner game
'); + expect(JSON.parse(archive.readAsText('package.json')).name).toBe('selected-inner-game'); + expect(summary.manifest.project_type).toBe('interactive_ai_app'); + expect(summary.fileCount).toBe(5); + expect(await readFile(join(projectPath, '.makelore', 'project.json'))).toEqual(outerConfig); + expect(await readFile(join(projectPath, '.makelore', 'work-entry.json'))).toEqual(savedEntry); + expect(await readFile(join(projectPath, 'private.txt'), 'utf8')).toBe('outer private file'); + }); + + it('discovers a unique nested standard Vite source without saving or rewriting project metadata', async () => { + const projectPath = await createProject(); + await removeOuterSource(projectPath); + await createNestedSource(projectPath); + const outerConfig = await readFile(join(projectPath, '.makelore', 'project.json')); + const archivePath = await archiveTarget(); + + await createStaticProjectPackage({ projectPath, archivePath }); + + expect(new AdmZip(archivePath).readAsText('index.html')).toBe('
selected inner game
'); + expect(new AdmZip(archivePath).getEntry('games/Alice 的游戏/index.html')).toBeNull(); + expect(await readFile(join(projectPath, '.makelore', 'project.json'))).toEqual(outerConfig); + await expect(readFile(join(projectPath, '.makelore', 'work-entry.json'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('rejects multiple nested candidates without choosing a game or producing an archive', async () => { + const projectPath = await createProject(); + await removeOuterSource(projectPath); + await createNestedSource(projectPath, 'first'); + await createNestedSource(projectPath, 'second'); + const archivePath = await archiveTarget(); + + await expect(createStaticProjectPackage({ projectPath, archivePath })) + .rejects.toMatchObject({ code: 'PROJECT_SOURCE_UNCONFIRMED' }); + await expect(readFile(archivePath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it.each([undefined, 'vite && node custom-dev.js'])('retains root Vite package compatibility for dev script %s', async (dev) => { + const projectPath = await createProject(); + const packagePath = join(projectPath, 'package.json'); + const packageJson = JSON.parse(await readFile(packagePath, 'utf8')); + await writeFile(packagePath, JSON.stringify({ ...packageJson, ...(dev ? { scripts: { dev } } : {}) })); + const archivePath = await archiveTarget(); + + await createStaticProjectPackage({ projectPath, archivePath }); + + expect(new AdmZip(archivePath).readAsText('index.html')).toBe('
'); + expect(JSON.parse(new AdmZip(archivePath).readAsText('package.json'))).toEqual({ + ...packageJson, ...(dev ? { scripts: { dev } } : {}), + }); + }); + + it('reports a missing lock in the saved source without falling back to the valid root or another game', async () => { + const projectPath = await createProject(); + const source = await createNestedSource(projectPath, 'selected'); + await createNestedSource(projectPath, 'other'); + await saveSourceEntry(projectPath, 'selected'); + await rm(join(source, 'package-lock.json')); + const archivePath = await archiveTarget(); + + await expect(createStaticProjectPackage({ projectPath, archivePath })) + .rejects.toMatchObject({ code: 'PROJECT_FILE_MISSING', message: expect.stringContaining('package-lock.json') }); + await expect(readFile(archivePath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it.each(['traversal', 'copied-project-id', 'copied-root', 'symlink'] as const)('ignores an unsafe %s saved directory and never packages its files', async (kind) => { + const projectPath = await createProject(); + const outside = await createProject(); + await writeFile(join(outside, 'external-secret.txt'), 'external private data'); + let directory = '.makelore/copied-source'; + let patch: Record = {}; + if (kind === 'traversal') directory = relative(projectPath, outside).split('\\').join('/'); + else if (kind === 'symlink') { + directory = '.makelore/linked-source'; + await symlink(outside, join(projectPath, directory), 'junction'); + } else { + await createNestedSource(projectPath, directory); + patch = kind === 'copied-project-id' + ? { projectId: '22222222-2222-4222-8222-222222222222' } + : { projectPath: outside }; + } + await saveSourceEntry(projectPath, directory, patch); + const archivePath = await archiveTarget(); + + await createStaticProjectPackage({ projectPath, archivePath }); + + const archive = new AdmZip(archivePath); + expect(archive.readAsText('index.html')).toBe('
'); + expect(archive.getEntries().map(entry => entry.entryName)).toEqual([ + 'index.html', 'niancode.yml', 'package-lock.json', 'package.json', 'public/icon.txt', 'src/main.ts', + ]); + expect(archive.readAsText('src/main.ts')).toBe('console.log("ready")\n'); + }); + + it('rejects a saved source ancestor replaced by a symlink between scan and streamed file reads', async () => { + const projectPath = await createProject(); + const source = await createNestedSource(projectPath, 'games/selected'); + await saveSourceEntry(projectPath, 'games/selected'); + const outside = await createProject(); + await createNestedSource(outside, 'selected'); + await writeFile(join(outside, 'selected', 'src', 'main.ts'), 'external private data'); + const archivePath = await archiveTarget(); + let replaced = false; + openRaceHook.beforeOpen = async path => { + if (replaced || path !== join(source, 'src', 'main.ts')) return; + replaced = true; + await rename(join(projectPath, 'games'), join(projectPath, 'games-original')); + await symlink(outside, join(projectPath, 'games'), 'junction'); + }; + + await expect(createStaticProjectPackage({ projectPath, archivePath })) + .rejects.toMatchObject({ code: 'PROJECT_SYMLINK_UNSUPPORTED' }); + expect(replaced).toBe(true); + await expect(readFile(archivePath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + it('generates a deterministic static manifest zip and excludes local or sensitive files', async () => { const projectPath = await createProject(); await mkdir(join(projectPath, 'node_modules', 'vite'), { recursive: true }); diff --git a/tests/unit/project-publish-action.test.tsx b/tests/unit/project-publish-action.test.tsx index cf63175b..b8dd2680 100644 --- a/tests/unit/project-publish-action.test.tsx +++ b/tests/unit/project-publish-action.test.tsx @@ -297,10 +297,25 @@ describe('ProjectPublishAction', () => { expect(failure).not.toHaveTextContent('token=secret'); }); - it('turns a local package error into instructions for the user', async () => { + it.each([ + { + code: 'PROJECT_FILE_MISSING', + message: 'D:/repo/space-cleaner/package-lock.json does not exist', + title: '项目文件不完整', + reason: '当前作品工程缺少发布所需的文件,或文件无法读取。', + nextStep: '请让开发助手检查当前作品工程,补齐发布文件后重试。', + }, + { + code: 'PROJECT_SOURCE_UNCONFIRMED', + message: 'Ambiguous sources: D:/repo/space-cleaner/app-a, D:/repo/space-cleaner/app-b', + title: '需要确认提交哪个作品', + reason: '当前项目中无法明确找到唯一的作品工程。', + nextStep: '请让开发助手确认作品的工程位置,打开作品后再提交。', + }, + ])('turns $code into safe guidance and allows retry without polling', async ({ code, message, title, reason, nextStep }) => { publishWorksProjectSourceMock.mockRejectedValue(Object.assign( - new Error('D:/repo/space-cleaner/package-lock.json does not exist'), - { code: 'PROJECT_FILE_MISSING' }, + new Error(message), + { code, statusCode: 400 }, )); render(); @@ -308,10 +323,17 @@ describe('ProjectPublishAction', () => { await flushSubmission(); const failure = screen.getByTestId('project-publish-failure'); - expect(failure).toHaveTextContent('项目文件不完整'); - expect(failure).toHaveTextContent('请让开发助手修复当前项目模板'); + expect(failure).toHaveTextContent(title); + expect(failure).toHaveTextContent(reason); + expect(failure).toHaveTextContent(nextStep); + expect(failure).not.toHaveTextContent(/模板|删除|损坏/); expect(failure).not.toHaveTextContent('D:/repo'); - expect(failure).not.toHaveTextContent('PROJECT_FILE_MISSING'); + expect(failure).not.toHaveTextContent(code); + expect(screen.getByRole('button', { name: '重新提交审核' })).toBeEnabled(); + + await advancePoll(); + + expect(publishWorksProjectSourceMock).toHaveBeenCalledOnce(); expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledOnce(); }); diff --git a/tests/unit/project-release-builder.test.ts b/tests/unit/project-release-builder.test.ts index 1ec24997..9ff34328 100644 --- a/tests/unit/project-release-builder.test.ts +++ b/tests/unit/project-release-builder.test.ts @@ -1,5 +1,8 @@ +// @vitest-environment node + import { createHash } from 'node:crypto'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -11,13 +14,89 @@ vi.mock('@electron/services/publish-runtime', () => ({ runElectronNode: runElectronNodeMock, PublishRuntimeError: class extends Error {}, })); -import { prepareProjectRelease } from '@electron/services/project-release-builder'; +import { prepareProjectRelease, prepareProjectReleaseSummary } from '@electron/services/project-release-builder'; import { startStaticReleaseServer, staticArtifactSnapshotFiles } from '@electron/services/static-release-server'; +const require = createRequire(import.meta.url); +const AdmZip = require('adm-zip') as typeof import('adm-zip'); + describe('project release builder', () => { let root: string | null = null; afterEach(async () => { if (root) await rm(root, { recursive: true, force: true }); root = null; vi.clearAllMocks(); }); + it.each(['full', 'summary'] as const)('builds a %s release from the saved inner source snapshot while preserving outer identity and all user files', async (mode) => { + root = await realpath(await mkdtemp(join(tmpdir(), 'release-builder-nested-'))); + const source = join(root, 'games', 'Alice 的游戏'); + const projectId = '11111111-1111-4111-8111-111111111111'; + await mkdir(join(root, '.makelore'), { recursive: true }); + await mkdir(join(root, 'knowledge')); + await mkdir(join(root, 'sibling-assets')); + await mkdir(join(source, 'src'), { recursive: true }); + await mkdir(join(source, '.makelore')); + const userFiles = new Map([ + [join(root, '.makelore', 'project.json'), JSON.stringify(createCodingProjectConfigV2('2026-08-09T00:00:00.000Z', 'interactive_ai_app', projectId))], + [join(root, '.makelore', 'work-entry.json'), JSON.stringify({ + version: 1, projectId, projectPath: root, url: 'http://127.0.0.1:5179/', + launch: { kind: 'vite', directory: 'games/Alice 的游戏', args: [] }, + })], + [join(root, 'knowledge', 'notes.txt'), 'outer private knowledge'], + [join(root, 'sibling-assets', 'unused.png'), 'unrelated image'], + [join(root, 'private.txt'), 'outer private file'], + [join(source, '.makelore', 'project.json'), JSON.stringify(createCodingProjectConfigV2('2026-08-09T00:00:00.000Z', 'custom', '22222222-2222-4222-8222-222222222222'))], + [join(source, 'package.json'), JSON.stringify({ name: 'inner-game', packageManager: 'npm@11.6.2', scripts: { dev: 'vite' }, devDependencies: { vite: '7.3.1' } })], + [join(source, 'package-lock.json'), JSON.stringify({ name: 'inner-game', lockfileVersion: 3, requires: true, packages: {} })], + [join(source, 'index.html'), '
inner source
'], + [join(source, 'src', 'main.ts'), 'console.log("user source")'], + ]); + for (const [path, content] of userFiles) await writeFile(path, content); + runElectronNodeMock.mockImplementation(async (input: { args: string[]; cwd: string }) => { + expect(input.cwd).not.toBe(root); + expect(input.cwd).not.toBe(source); + expect(await readFile(join(input.cwd, 'index.html'), 'utf8')).toBe('
inner source
'); + expect(JSON.parse(await readFile(join(input.cwd, 'package.json'), 'utf8')).name).toBe('inner-game'); + await expect(readFile(join(input.cwd, '.makelore', 'project.json'))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readFile(join(input.cwd, 'private.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); + if (input.args.includes('ci')) { + await mkdir(join(input.cwd, 'node_modules', 'vite', 'bin'), { recursive: true }); + await writeFile(join(input.cwd, 'node_modules', 'vite', 'package.json'), JSON.stringify({ version: '7.3.1' })); + await writeFile(join(input.cwd, 'src', 'main.ts'), 'changed only inside build snapshot'); + } else if (input.args.includes('build')) { + const output = input.args[input.args.indexOf('--outDir') + 1]; + await mkdir(output, { recursive: true }); + await writeFile(join(output, 'index.html'), `
built ${await readFile(join(input.cwd, 'src', 'main.ts'), 'utf8')}
`); + } + return { stdout: '', stderr: '' }; + }); + + const prepare = mode === 'full' ? prepareProjectRelease : prepareProjectReleaseSummary; + const release = await prepare({ projectPath: root, clientVersion: '2.0.0' }); + try { + expect(runElectronNodeMock).toHaveBeenCalledTimes(2); + expect(runElectronNodeMock.mock.calls[0][0].cwd).toBe(runElectronNodeMock.mock.calls[1][0].cwd); + const archive = new AdmZip(release.sourceArchive.path); + expect(archive.getEntries().map(entry => entry.entryName)).toEqual([ + 'index.html', 'niancode.yml', 'package-lock.json', 'package.json', 'src/main.ts', + ]); + expect(archive.readAsText('src/main.ts')).toBe('console.log("user source")'); + expect(release.sourceArchive.summary.manifest.project_type).toBe('interactive_ai_app'); + expect(release.contract.source_digest).toBe(createHash('sha256').update(await readFile(release.sourceArchive.path)).digest('hex')); + expect(new AdmZip(release.builtArchive.path).readAsText('index.html')).toBe('
built changed only inside build snapshot
'); + if ('staticArtifact' in release) { + expect(staticArtifactSnapshotFiles(release.staticArtifact)[0].bytes.toString()).toBe('
built changed only inside build snapshot
'); + } else { + expect(release.sourceArchive).not.toHaveProperty('bytes'); + expect(release.builtArchive).not.toHaveProperty('bytes'); + } + for (const [path, content] of userFiles) expect(await readFile(path, 'utf8')).toBe(content); + await expect(readFile(join(source, 'node_modules', 'vite', 'package.json'))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readFile(join(source, 'dist', 'index.html'))).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await release.dispose(); + } + await expect(readFile(release.sourceArchive.path)).rejects.toMatchObject({ code: 'ENOENT' }); + for (const [path, content] of userFiles) expect(await readFile(path, 'utf8')).toBe(content); + }); + it('builds from the exact source archive and returns the protocol-v1 canonical contract', async () => { root = await mkdtemp(join(tmpdir(), 'release-builder-test-')); await mkdir(join(root, '.makelore'), { recursive: true }); diff --git a/tests/unit/works-project-publish.test.ts b/tests/unit/works-project-publish.test.ts index a9f5a7ac..97b50e7a 100644 --- a/tests/unit/works-project-publish.test.ts +++ b/tests/unit/works-project-publish.test.ts @@ -8,7 +8,8 @@ describe('works project publish guidance', () => { it.each([ ['PUBLISH_PREFLIGHT_RUNTIME_ERROR', '作品打开时发生错误', '项目预览'], ['PUBLISH_PREFLIGHT_BLANK', '作品打开后没有内容', '移动端布局'], - ['PROJECT_FILE_MISSING', '项目文件不完整', '修复当前项目模板'], + ['PROJECT_FILE_MISSING', '项目文件不完整', '检查当前作品工程,补齐发布文件后重试'], + ['PROJECT_SOURCE_UNCONFIRMED', '需要确认提交哪个作品', '确认作品的工程位置,打开作品后再提交'], ['PROJECT_TYPE_UNPUBLISHABLE', '这个项目没有配置发布方式', '新建交互式 AI 应用项目'], ['DEPENDENCY_PREFETCH_FAILED', '旧版提交无法继续处理', '升级 Makelore'], ['OUTPUT_MISSING', '本次构建结果未通过平台校验', '本次构建结果'], @@ -26,6 +27,17 @@ describe('works project publish guidance', () => { expect(failure.nextStep).toContain(nextStep); }); + it.each([ + ['PROJECT_FILE_MISSING', '当前作品工程缺少发布所需的文件,或文件无法读取。'], + ['PROJECT_SOURCE_UNCONFIRMED', '当前项目中无法明确找到唯一的作品工程。'], + ])('explains %s without assuming that a template was deleted or damaged', (code, reason) => { + const failure = describeWorksPublishFailure(code, 400); + + expect(failure.reason).toBe(reason); + expect(JSON.stringify(failure)).not.toMatch(/模板|删除|损坏/); + expect(JSON.stringify(failure)).not.toContain(code); + }); + it.each([ [401, '需要重新登录'], [403, '当前账号不能发布这个作品'], From af639267198d908e43bcb0bd243e86be2a533427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 14:31:00 +0800 Subject: [PATCH 04/18] docs: record prepared WeChat name update awaiting native refresh --- .../30-worklog/tasks/20261014-wechat-card-name-7b36a2d9.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.project-docs/30-worklog/tasks/20261014-wechat-card-name-7b36a2d9.md b/.project-docs/30-worklog/tasks/20261014-wechat-card-name-7b36a2d9.md index dae38767..a32e5b87 100644 --- a/.project-docs/30-worklog/tasks/20261014-wechat-card-name-7b36a2d9.md +++ b/.project-docs/30-worklog/tasks/20261014-wechat-card-name-7b36a2d9.md @@ -37,7 +37,8 @@ - Added visible title/pencil editing, a labeled inline name field and save/cancel controls. Enter saves (except IME composition), Escape cancels; trim/nonempty/64-character validation and local storage failure keep the original display and draft. Remarks are scoped by tenant/user plus stable account ID, persist across refresh/revision/rebinding and are consistently used by card headings/aria/dialog title. Returning to the original source name clears its local override. - Owner changes immediately hide the old editor/alias and close its draft; saves verify live auth owner. Missing/hidden cards close the editor rather than locking the page. Editing prevents competing binding actions and automatic restore start/preflight while read-only health refresh remains available. Main/API/DTO/account/revision/operation identities are unchanged. README describes local-only remarks honestly. -- Renderer build ready; immutable overlay preparation and actual native open/cancel verification remain before runtime finalization. +- Product commitf1dabdb358ff5f2228fbef014f9e659c156ccc10 is built/prepared in immutable `.trial-runtime/wechat-card-name-20261014-7b36a2d9/runtime`. Source/default-build-log/full Renderer artifact hashes and actual full predecessor native proof passed; Main35142/Host58003/bootstrap/resources/profile preserved. File-only activation switches the stable origin and leaves current.json unchanged pending actual native proof. +- Root observed alice's Code#/chat with empty input/no files/disabled Send, then attempted native View Reload and Cmd-R. CUA repeatedly rejected actions because the window state changed or element IDs became invalid; no successful reload or live edit proof is claimed. Asked the user to refresh manually and open Agents/渠道/微信. Actual rename-open/cancel check and metadata finalization remain pending; no real card name was saved or binding/cloud operation performed. ## Verification From c812eb4c0786bf80f8bcb405e988d56ede3a878d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 14:35:23 +0800 Subject: [PATCH 05/18] docs: record Coding sidebar readability proposal --- .../20261014-coding-sidebar-text-9fe4278d.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20261014-coding-sidebar-text-9fe4278d.md diff --git a/.project-docs/30-worklog/tasks/20261014-coding-sidebar-text-9fe4278d.md b/.project-docs/30-worklog/tasks/20261014-coding-sidebar-text-9fe4278d.md new file mode 100644 index 00000000..6cc31f1a --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-coding-sidebar-text-9fe4278d.md @@ -0,0 +1,48 @@ +# Task: Compare Coding sidebar typography with Agents and Canvas + +## Identity + +- Task ID: 20261014-coding-sidebar-text-9fe4278d +- Mode: Feature +- Branch: codex/20261014-coding-sidebar-text-9fe4278d-coding-sidebar-text +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-coding-sidebar-text-9fe4278d +- Base commit: 436f7ba1eab500e41866f890f49816ff622ef6fe +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Read-only comparison of Coding sidebar text with Agents and Canvas, then propose a clearer text hierarchy. Implementation requires the user's later approval. + +## Intent And Constraints + +- User explicitly asks for a proposal first and forbids beginning product changes before approval. Preserve desktop focus, running trial, inputs, projects and conversations. No product edits, runtime update, model calls, merge/push or cleanup. + +## Outcome + +- All three modules share the same bundled Inter/Source Han sans font stack. Agents main navigation and Canvas project entries use14px/600 dark text for normal clickable items; selected/hover states are differentiated primarily by background. +- Coding project/create/gallery entries already use14px/600 dark navy. Its conversation titles and new-conversation action are12px/400 auxiliary blue-gray#64758a, with icons#7891af. The group header uses the same auxiliary gray. These styles explain the screenshot's weaker conversation-list hierarchy; no font replacement is needed. +- Proposed Coding-only scope: conversation titles and new-conversation action14px/500/#26384d; group heading and collapse action#506176 while remaining a secondary hierarchy; conversation icons one shade darker. Retain existing14px/600 project entries, pale-blue surface, icon tiles, selected/hover backgrounds, layout and interactions. Scope any adjustment to Coding rather than changing global/shared Canvas tokens. +- No product code or running application was changed. Proposal is complete; implementation remains awaiting human approval. + +## Verification + +- Read actual trial pointer identifying source436f7ba1eab500e41866f890f49816ff622ef6fe and isolated from that human-verifiable base. Source comparison: globals.css61/72, Sidebar.tsx425/572/580, ImageWorkspaceSidebar.tsx159/237, classroom-workspace.css7/22/28/33/39–46, CodingProjectConversations.tsx70–114. Independent reference comparison confirmed font/color/weight boundaries. +- Source-color calculation: current#64758a against Coding#f3f7fb gives4.38:1 contrast; proposed main#26384d11.11:1 and secondary#5061765.89:1. These are deterministic source palette calculations, not real-app screenshot measurements. +- No test/build/live inspection was needed for this proposal. Product tree stays byte-identical to the base; only this task record is changed. Documentation drift and diff checks recorded at completion. + +## Follow-ups + +- On approval, resume the same task and recheck the newest actual source/peer sidebar changes before applying the bounded Coding-only adjustment. Verify normal/selected/hover/disabled typography and layout in headless browser checks, then perform only separately authorized trial delivery. + +## Promotion Candidates + +- None recorded. + +## Project Context Loaded + +- Concurrent Gate passed: feature task20261014-coding-sidebar-text-9fe4278d, exact branch/worktree/base in Identity match official start/status. Primary occupied/dirty worktree remains untouched. +- Read entry, memory index, own task, positioning, integrated-state/decision/architecture and relevant domain/quality/evidence/reflection/commitment sections plus AGENTS. Product is a Main-owned creative client with a single light visual system. Existing palette and component typography take precedence over unrelated generic design search output. +- Official registry had78 owners including this task. Independent coordination review read77 peer records/231Scope, Intent and Promotion sections. All readable; Code-model-onboarding01e8cbed and old child-language6bf94d12 retain unknown placeholder scopes without a concrete dependency. No semantic blocker affects this read-only proposal. +- Adjacent Code gallery/footer tasks retain palette/layout; wide Code redesigne4132a1e was explicitly withdrawn and is not reused. Canvas's later a72d49e6 task changes its surface to pale yellow, superseding the older lavender state; no Canvas background proposal is made here. Source comparison uses the declared actual trial436f7ba snapshot and adopts no peer uncommitted product. +- Likely approved-edit paths would be CodingProjectConversations.tsx and Coding-scoped selectors in classroom-workspace.css. No shared global color/font change or backend/API alteration is justified. Planning Gate: Passed. From ca546c987c1ab8440dc51250372b7fabf9f4bb3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 14:41:09 +0800 Subject: [PATCH 06/18] Keep Canvas creation identity on retry and simplify the yellow rail --- ...0261014-canvas-create-recovery-a72d49e6.md | 80 ++++++++++ README.md | 2 +- .../layout/ImageWorkspaceSidebar.tsx | 13 +- src/lib/image-workspace.ts | 6 +- src/pages/Chat/classroom-workspace.css | 2 +- src/pages/ImageCanvas/DesignWorksRail.tsx | 8 +- src/stores/image-workspace.ts | 117 ++++++++++++--- tests/e2e/image-workspace-v2.spec.ts | 13 +- tests/unit/image-workspace-sidebar.test.tsx | 31 +++- tests/unit/image-workspace-store.test.ts | 141 +++++++++++++++++- 10 files changed, 379 insertions(+), 34 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261014-canvas-create-recovery-a72d49e6.md diff --git a/.project-docs/30-worklog/tasks/20261014-canvas-create-recovery-a72d49e6.md b/.project-docs/30-worklog/tasks/20261014-canvas-create-recovery-a72d49e6.md new file mode 100644 index 00000000..285709f1 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-canvas-create-recovery-a72d49e6.md @@ -0,0 +1,80 @@ +# Task: Remove creation copy and diagnose Canvas project creation timeouts + +## Identity + +- Task ID: 20261014-canvas-create-recovery-a72d49e6 +- Mode: Feature +- Branch: codex/20261014-canvas-create-recovery-a72d49e6-canvas-create-recovery +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-canvas-create-recovery-a72d49e6 +- Base commit: f34e651774beb6a2a213b5711f2ffb887d22d4b7 +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Remove the exact create-dialog description, change the Canvas rail from pale lavender to pale yellow, diagnose actual Canvas project creation timeouts, and implement the smallest evidenced safe recovery/fix. + +## Intent And Constraints + +- User explicitly requests the copy removal and timeout diagnosis, then steers the same task to pale-yellow background. Main remains the sole authenticated network authority; preserve real projects/drafts/accounts, recent sidebar changes, compact layout and idempotent command boundaries. Do not blindly replay unknown create requests, delete real data, trigger paid generation, deploy backend services, change runtime auth/contracts or edit other owned worktrees. Preserve primary checkout and all existing runtime features. Earlier user merge/push/temporary-app update/cleanup authorization persists, but fresh runtime state must be verified before delivery. + +## Outcome + +- Removed the exact desktop creation description and disabled its absent aria description; Canvas shared rail surface is pale yellow #fff9e8, with Code recipes preserved. Both creation dialogs hold a distinct creation intent. Any failed request for unchanged intent/name reuses its original server clientWorkspaceId; a new dialog or modified name creates a new intent, and repeated concurrent submissions share one in-flight request. Account/logout/reset generations prevent old responses from changing another account or reset store. Create/rename name input is disabled during submission. README synchronized. + +## Verification + +- Exact runtime failure confirmed through the current authenticated local Host API: Canvas bootstrap 504/30015ms with DESIGN_WORKSPACE_REQUEST_TIMEOUT. Native telemetry also showed simultaneous ~30s Canvas, Agent and teacher cloud failures. Public Square capabilities returned actual JSON200/0.17s, so reachable upstream was verified. Fresh current pointer/Main/PPID/profile guard allowed only NetworkService PID35175 SIGTERM; Chromium rebuilt PID71263. Main35142, Renderer35176 and pointer SHA remained unchanged. Same authenticated readonly bootstrap then returned200/116ms and 5 existing workspaces; requested Autumn/秋天 was absent. No create replay, model call, paid generation, binding operation or Main restart was performed. Underlying NetworkService hang trigger is not proven; recovery establishes the affected transport path. Receipt under .trial-runtime/canvas-create-recovery-20261014-a72d49e6/network-recovery-receipt.json. +- Standard typecheck, scoped lint, default full build:vite, whitespace and feature documentation drift passed; 81 focused unit tests in4 files passed, including API/store identities/auth/races and both UI scopes. Isolated Electron Canvas V2 acceptance passed (1 test, 7.0s), including desktop/compact split, draft/plan/production history and new creation popup; pale-yellow surface and absent exact description checked. Inspected final creation-dialog screenshot. Independent source/concurrency/auth review passed. Logs and screenshots retained outside source checkout in .trial-runtime/canvas-create-recovery-20261014-a72d49e6. + +## Follow-ups + +- Unified update coordination chat 01a12eba-e98b-76a3-8fd5-206f2820d623 notified under direct previously verified human coordination authorization. It is currently preparing a separate Code Main diagnostic change. Current runtime pointer still says436, but actual stable alias points at WeChat name-edit Renderer f1dab; that peer file activation is awaiting native metadata finalization. Any final build/update must preserve actual f1dab product changes and freeze the real runtime pair; do not overwrite them using stale current.json alone. Primary checkout remains occupied/untouched. Native user is actively navigating, so avoid racing UI reloads or discarding work; no whole-app restart has been performed. + +## Promotion Candidates + +- None recorded. + +## Project Context Loaded + +Task context: +- Task ID: 20261014-canvas-create-recovery-a72d49e6 +- Mode: Feature +- Branch: codex/20261014-canvas-create-recovery-a72d49e6-canvas-create-recovery +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-canvas-create-recovery-a72d49e6 +- Base commit: f34e651774beb6a2a213b5711f2ffb887d22d4b7 (fresh origin/main) +- Ownership: official start/check/status match; occupied dirty primary teacher-consensus checkout remains untouched. +- Other tasks: 74 peer records read-only Scope/Intent/Promotion inventory at /tmp/makelore-canvas-create-peer-scopes.json. Current WeChat local-remark task targets another Renderer surface and runtime436; runtime delivery must preserve it. Prior Canvas/sidebar/consolidation sources are ready and integrated; Code session diagnosis is independent. Old unrelated placeholder scope remains unknown, without a dependency. +- Overlap/conflict: no semantic conflict in source. Runtime updates require a fresh current pointer/build-pair check; no source record edits or adoption. + +Read: +- AGENTS.md, existing README product state and prior same-conversation accepted context +- .project-docs/05-agent-entry/read-before-planning.md, memory-index.md, planning-gate.md +- Own task, positioning, current-state integrated snapshot, decision-index, system-overview +- business-rules, success-criteria; evidence/reflection/commitment indexes in Canvas/runtime context +- adr-007-ai-design-living-form-v2.md; peer task Scope/Intent/Promotion sections +- maintain-project-docs skill; existing design skill remains applicable only to the requested color/copy consistency + +Relevant understanding: +- Goal: Chinese project-based creative client, preserving authoritative cloud Living Form, server reducer, quotes/tasks/assets. +- Current focus: all prior Canvas/Code rail refinements are in main f34; live full trial436/35142 uses a frozen compiled snapshot and main-owned development login. +- Active scope: exact description removal, pale-yellow rail, evidenced create timeout cause and recovery. +- Constraints/decisions: Main owns auth/network. Unknown mutations must reuse stable identities and reconcile canonical server state; no local semantic fallback or fabricated successful form. +- Evidence: current native Canvas shows project names but its main detail remains loading; screenshots show create timeout. Main createWorkspace performs POST then getWorkspace. Renderer currently generates a new clientWorkspaceId on every create retry. Neither observation proves which live leg timed out. +- Files/modules: ImageWorkspaceSidebar, shared sidebar CSS, DesignWorksRail compact dialog, image-workspace client/store, WorksSquareDesignWorkspace/request deadline, focused tests and runtime evidence. +- Stale/unknown: canonical presentation descriptions predate accepted V2 layout; preserve latest source. Live network stack may differ from source evidence, and NetworkService reset was once effective without a permanent root cause; do not assume recurrence or restart the whole app before precise read-only observations. + +Gate result: +- Passed + +## Plan + +- Remove the quoted description and use pale-yellow rail surface, keeping shared Code recipes. +- Independently trace the Main create/load timeout path and Renderer retry identity; obtain precise read-only current UI/network evidence to identify the failed stage before changing timeout or transport behavior. +- Fix evidenced behavior, verify timeout/retry and existing desktop/compact creation with focused tests, typecheck/lint/full build. Deliver through a fresh verified runtime tree while preserving active work; record truthful native and fixture limits, then commit/handoff or push within existing authorization. + +## Delivery Handoff + +- Product commit and final HEAD will be reported to the authorized coordination chat. This clean Ready source contains Renderer-only changes; Main/shared/resources are unchanged against runtime436. Source implementation and live network recovery are separately evidenced. +- A precise read-only real-project bootstrap recovered; no real Autumn project was created. Real UI color/copy activation remains for the combined update, because actual WeChat f1dab files are active while current.json still points at436, and another Code Main update is in progress. Preserve actual newer Renderer feature source and active user work rather than blindly replacing the old pointer's tree. +- Any different title or explicitly reopened create dialog is a new creation intent; an older transport-unknown project may still exist on the server. Never delete it or infer identity from a matching name. This patch prevents same-intent retries from making extra identities; it does not establish the underlying NetworkService hang trigger or prove real model generation. diff --git a/README.md b/README.md index 02b1e42a..7414f8a1 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 Agents 侧栏的「渠道」按每个微信一张卡展示备注、当前智能体与使用状态;一个微信同时只绑定一个智能体,多个微信可以使用同一智能体。绑定有效且服务正常时始终开启回复,不提供暂停或恢复开关;更换智能体保留登录与历史,完成重新登录后也开启回复。旧的关闭记录在页面可见时,重新核对登录、在线服务、可用目标、同步状态及新旧渠道操作恢复记录后串行开启;服务离线、退出待确认、目标不可用和结果未知时不自动写入。常用操作是“更换智能体”;“更多操作”内可以解除微信绑定,停止任务、清除目标并退出微信登录,历史保留,再使用需扫码。确认解绑后的卡片可选择“删除微信卡片”,只从这台设备的当前账号列表移除,刷新和重开后仍保留删除结果;其他设备及云端账号和聊天历史不受影响,同一微信重新绑定后再次显示。本机偏好按账号与租户隔离,删除前再次读取云端状态并检查原操作,保存失败时保留卡片并显示原因。添加流程为选择已开始使用的智能体、扫码、自动完成绑定;扫码确认、目标正确、启用成功且最终状态回读可用后才显示完成。微信服务离线时,卡片仅保留右上角“微信服务离线”状态,不显示重复的离线长段说明;绑定或重新连接失败的窗口仍说明原因,不把扫码成功当作回复可用。未知或未同步状态显示“状态待确认”。无可用智能体时说明创建和“开始使用”的前提,加载失败不冒充空态,已有微信仍可解绑。Main 持有凭据与原操作恢复;解除需要配套 Yuxi 用户级 `/channel-accounts/{id}/unbind`,旧后端不会通过改按钮文案或组合未确认操作假装完成。变更以最新读取状态为准,未完成操作保留原身份供显式恢复;失败在当前窗口可见,结果未知不自动重放。微信退出仍在处理中时,禁止新的连接和绑定变更,保留刷新与原操作恢复。旧请求无法安全恢复、账号关系不一致或目标不可用时提示先核对;“不再提醒”只移除本机提醒,不取消服务端仍在处理的操作。最新 Yuxi 的退出保护条件和持久恢复由服务端负责,发布需配套 Core/Adapter 与 Yuxi business=13、knowledge=2 数据库版本。渠道页不展示微信聊天或活动,既有历史、费用、内容隔离与应用 API 保持原边界。实际微信身份及重复扫码识别仍由云端负责,不从备注推测。 -- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧工作栏与 Code 共用间距、图标徽章、项目行、操作菜单和底部广场/账户样式,仅工作栏底色采用淡紫色(`#f5f2fa`);顶部返回箭头与 Code 对齐,分组标题为“我的项目”,桌面项目列表仅显示名称,隐藏名称下的设计版本与更新时间;新建、切换和项目操作菜单集中管理 Workspace。工作区统一项目标题、设计版本与制作状态,创作对话和作品区支持拖动分栏、键盘调宽及单栏展开;对话以 Markdown 呈现,底部输入器保留草稿、发送状态与 Enter / Shift+Enter 快捷键。右侧通过“当前作品 / 历史作品”切换,在大图或视频预览旁编辑唯一制作方案、确认费用和制作;历史作品可回到工作区查看,切换面板保留未保存的制作参数。紧凑窗口以“创作对话 / 作品与方案”切换面板,通过项目抽屉管理 Workspace。图片支持适应窗口、原始尺寸及 Esc 关闭,视频支持预览和保存到电脑。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 +- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧工作栏与 Code 共用间距、图标徽章、项目行、操作菜单和底部广场/账户样式,仅工作栏底色采用淡黄色(`#fff9e8`);顶部返回箭头与 Code 对齐,分组标题为“我的项目”,桌面项目列表仅显示名称,隐藏名称下的设计版本与更新时间;新建、切换和项目操作菜单集中管理 Workspace;创建请求失败后保留名称与本次创建标识,原样重试避免重复创建项目。工作区统一项目标题、设计版本与制作状态,创作对话和作品区支持拖动分栏、键盘调宽及单栏展开;对话以 Markdown 呈现,底部输入器保留草稿、发送状态与 Enter / Shift+Enter 快捷键。右侧通过“当前作品 / 历史作品”切换,在大图或视频预览旁编辑唯一制作方案、确认费用和制作;历史作品可回到工作区查看,切换面板保留未保存的制作参数。紧凑窗口以“创作对话 / 作品与方案”切换面板,通过项目抽屉管理 Workspace。图片支持适应窗口、原始尺寸及 Esc 关闭,视频支持预览和保存到电脑。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 diff --git a/src/components/layout/ImageWorkspaceSidebar.tsx b/src/components/layout/ImageWorkspaceSidebar.tsx index 9a9c5a5a..59289779 100644 --- a/src/components/layout/ImageWorkspaceSidebar.tsx +++ b/src/components/layout/ImageWorkspaceSidebar.tsx @@ -20,7 +20,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace'; +import { createImageWorkspaceProjectId, IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import { useAuthStore } from '@/stores/auth'; import type { DesignWorkspaceSummary } from '../../../shared/image-workspace'; @@ -48,6 +48,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba const renameProject = useImageWorkspaceStore((state) => state.renameProject); const selectProject = useImageWorkspaceStore((state) => state.selectProject); const [dialog, setDialog] = useState(null); + const [creationIntentId, setCreationIntentId] = useState(createImageWorkspaceProjectId); const [projectName, setProjectName] = useState(''); const [dialogError, setDialogError] = useState(null); const [saving, setSaving] = useState(false); @@ -63,6 +64,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba const openCreateDialog = () => { // The compact project Sheet owns this event while the desktop sidebar is hidden. if (typeof window.matchMedia === 'function' && !window.matchMedia('(min-width: 1024px)').matches) return; + setCreationIntentId(createImageWorkspaceProjectId()); setProjectName(''); setDialogError(null); setDialog({ mode: 'create', project: null }); @@ -79,6 +81,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba ); const openCreateDialog = () => { + setCreationIntentId(createImageWorkspaceProjectId()); setProjectName(''); setDialogError(null); setDialog({ mode: 'create', project: null }); @@ -103,7 +106,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba await renameProject(dialog.project.workspaceId, title); toast.success('项目名称已更新'); } else { - await createProject(title); + await createProject(title, creationIntentId); navigate('/image-canvas'); } setDialog(null); @@ -282,12 +285,9 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba )} !open && !saving && setDialog(null)}> - + {dialog?.mode === 'rename' ? '重命名设计项目' : '新建设计项目'} - - 创建后会立即生成一份空白设计表单,可通过对话或直接编辑完善。 -
{ export function createImageWorkspaceProject( title: string, - clientWorkspaceId = createClientId('workspace'), + clientWorkspaceId = createImageWorkspaceProjectId(), ): Promise { return requestData(`${IMAGE_WORKSPACE_API_PATH}/workspaces`, { method: 'POST', diff --git a/src/pages/Chat/classroom-workspace.css b/src/pages/Chat/classroom-workspace.css index b03e6de0..bbb29dbd 100644 --- a/src/pages/Chat/classroom-workspace.css +++ b/src/pages/Chat/classroom-workspace.css @@ -45,7 +45,7 @@ [data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row > button:first-child { min-height: 35px; gap: 7px; } [data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row > button:first-child > svg { color: #7891af; } [data-testid="sidebar"]:is([data-classroom="true"], [data-workspace-sidebar="canvas"]) .classroom-account-footer { border-top: 1px solid #dde5ee; background: var(--workspace-sidebar-background, #edf3f9); } -[data-testid="sidebar"][data-workspace-sidebar="canvas"] { --workspace-sidebar-background: #f5f2fa; } +[data-testid="sidebar"][data-workspace-sidebar="canvas"] { --workspace-sidebar-background: #fff9e8; } [data-testid="sidebar"][data-workspace-sidebar="canvas"] .classroom-sidebar-header { padding: 0; } [data-testid="sidebar"][data-workspace-sidebar="canvas"] .classroom-sidebar-content { display: flex; flex-direction: column; overflow: hidden; } .classroom-workspace [data-testid="coding-conversation-header"] { border-bottom-color: #e8edf3; background: #fff; color: #304968; } diff --git a/src/pages/ImageCanvas/DesignWorksRail.tsx b/src/pages/ImageCanvas/DesignWorksRail.tsx index 56fb929a..4dcddb58 100644 --- a/src/pages/ImageCanvas/DesignWorksRail.tsx +++ b/src/pages/ImageCanvas/DesignWorksRail.tsx @@ -16,7 +16,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace'; +import { createImageWorkspaceProjectId, IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace'; import { cn } from '@/lib/utils'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import type { DesignWorkspace, DesignWorkspaceSummary } from '../../../shared/image-workspace'; @@ -70,6 +70,7 @@ export function DesignWorksRail({ const deleteProject = useImageWorkspaceStore((state) => state.deleteProject); const selectProject = useImageWorkspaceStore((state) => state.selectProject); const [dialog, setDialog] = useState(null); + const [creationIntentId, setCreationIntentId] = useState(createImageWorkspaceProjectId); const [projectName, setProjectName] = useState(''); const [dialogError, setDialogError] = useState(null); const [saving, setSaving] = useState(false); @@ -92,6 +93,7 @@ export function DesignWorksRail({ const currentStatus = activeProjectStatus(workspace); const openCreate = () => { + setCreationIntentId(createImageWorkspaceProjectId()); setProjectName(''); setDialogError(null); setDialog({ mode: 'create', project: null }); @@ -99,6 +101,7 @@ export function DesignWorksRail({ useEffect(() => { const handleCreateRequest = () => { + setCreationIntentId(createImageWorkspaceProjectId()); setProjectName(''); setDialogError(null); setDialog({ mode: 'create', project: null }); @@ -126,7 +129,7 @@ export function DesignWorksRail({ await renameProject(dialog.project.workspaceId, title); toast.success('项目名称已更新'); } else { - await createProject(title); + await createProject(title, creationIntentId); toast.success('新项目已创建'); } setDialog(null); @@ -286,6 +289,7 @@ export function DesignWorksRail({ Promise; - createProject: (title: string) => Promise; + createProject: (title: string, creationIntentId?: string) => Promise; renameProject: (workspaceId: string, title: string) => Promise; deleteProject: (workspaceId: string) => Promise; selectProject: (workspaceId: string) => Promise; @@ -117,6 +118,32 @@ let activeEventWorkspaceId: string | null = null; let eventConnectionGeneration = 0; let selectionGeneration = 0; let workspaceSelectionGeneration = 0; +let creationGeneration = 0; +type ProjectCreation = { + accountKey: string; + generation: number; + title: string; + clientWorkspaceId: string; + request: Promise | null; +}; +const projectCreations = new Map(); + +function creationAccountKey(): string | null { + const auth = useAuthStore.getState(); + if (!auth.isAuthenticated()) return null; + return JSON.stringify([ + auth.authBase, + auth.user?.tenantId, + auth.user?.userId, + auth.user?.username, + ]); +} + +function clearProjectCreations(): void { + creationGeneration += 1; + projectCreations.clear(); +} + const assistantStreamChunkIndexes = new Map(); const observedCommandTerminalEvents = new Map(); const ASSISTANT_PROGRESS_STAGES: DesignAssistantProgressStage[] = [ @@ -789,23 +816,68 @@ export const useImageWorkspaceStore = create((set, get) => return inFlightLoad; }, - createProject: async (title) => { - const workspace = await createImageWorkspaceProject(title); - selectionGeneration += 1; - workspaceSelectionGeneration += 1; - closeEventSource(); - assistantStreamChunkIndexes.clear(); - set((state) => ({ - ...withWorkspace(state, workspace), - fieldDrafts: {}, - chatDraft: '', - assistantActivities: {}, - assistantStreams: {}, - quoteBlockers: [], - lastEventId: null, - })); - get().connectEvents(); - return workspace; + createProject: (title, creationIntentId = createImageWorkspaceProjectId()) => { + const accountKey = creationAccountKey(); + if (!accountKey) { + return Promise.reject(new ImageWorkspaceApiError( + 401, 'design_auth_required', '请登录后再创建项目', + )); + } + const normalizedTitle = title.trim(); + const candidate = projectCreations.get(creationIntentId); + const previous = candidate?.accountKey === accountKey + && candidate.generation === creationGeneration ? candidate : undefined; + if (previous?.request) { + if (previous.title === normalizedTitle) return previous.request; + return Promise.reject(new Error('项目正在创建,请等待完成后再修改名称')); + } + const creation: ProjectCreation = previous?.title === normalizedTitle + ? previous + : { + accountKey, + generation: creationGeneration, + title: normalizedTitle, + clientWorkspaceId: createImageWorkspaceProjectId(), + request: null, + }; + projectCreations.set(creationIntentId, creation); + const request = (async () => { + try { + const workspace = await createImageWorkspaceProject( + creation.title, creation.clientWorkspaceId, + ); + if (creation.generation !== creationGeneration + || creation.accountKey !== creationAccountKey()) { + throw new ImageWorkspaceApiError( + 401, 'design_creation_session_changed', '登录状态已变化,请重新打开新建项目', + ); + } + if (projectCreations.get(creationIntentId) === creation) { + projectCreations.delete(creationIntentId); + } + selectionGeneration += 1; + workspaceSelectionGeneration += 1; + closeEventSource(); + assistantStreamChunkIndexes.clear(); + set((state) => ({ + ...withWorkspace(state, workspace), + fieldDrafts: {}, + chatDraft: '', + assistantActivities: {}, + assistantStreams: {}, + quoteBlockers: [], + lastEventId: null, + })); + get().connectEvents(); + return workspace; + } finally { + // A failed snapshot read may follow a committed create, even for 4xx errors. + // Keep the original identity for an explicit same-payload retry. + creation.request = null; + } + })(); + creation.request = request; + return request; }, renameProject: async (workspaceId, title) => { @@ -1253,6 +1325,7 @@ export const useImageWorkspaceStore = create((set, get) => }, reset: () => { + clearProjectCreations(); selectionGeneration += 1; workspaceSelectionGeneration += 1; closeEventSource(); @@ -1279,6 +1352,14 @@ export const useImageWorkspaceStore = create((set, get) => }; }); +let currentCreationAccountKey = creationAccountKey(); +useAuthStore.subscribe(() => { + const accountKey = creationAccountKey(); + if (accountKey === currentCreationAccountKey) return; + currentCreationAccountKey = accountKey; + clearProjectCreations(); +}); + export function recentDesignChanges(workspace: DesignWorkspace | null): DesignFieldChange[] { return workspace?.form.recentChangeSet.changes ?? []; } diff --git a/tests/e2e/image-workspace-v2.spec.ts b/tests/e2e/image-workspace-v2.spec.ts index 64b5e2a3..330c8895 100644 --- a/tests/e2e/image-workspace-v2.spec.ts +++ b/tests/e2e/image-workspace-v2.spec.ts @@ -473,7 +473,7 @@ test.describe('AI Design V2 workspace', () => { await expect(page.getByText('目标与用途')).toHaveCount(0); await expect(page.locator('[data-testid^="sidebar-image-conversation-"]')).toHaveCount(0); - // Canvas shares Code's navigation recipe, with a lavender surface and its own top return row. + // Canvas shares Code's navigation recipe, with a pale-yellow surface and its own top return row. const sidebar = page.getByTestId('sidebar'); const createProject = sidebar.getByTestId('sidebar-create-project'); const project = sidebar.getByTestId('sidebar-image-project-workspace-1'); @@ -482,7 +482,7 @@ test.describe('AI Design V2 workspace', () => { const community = sidebar.getByRole('button', { name: '作品广场', exact: true }); const account = sidebar.getByTestId('sidebar-member-menu-trigger'); await expect(sidebar).toHaveAttribute('aria-hidden', 'false'); - await expect(sidebar).toHaveCSS('background-color', 'rgb(245, 242, 250)'); + await expect(sidebar).toHaveCSS('background-color', 'rgb(255, 249, 232)'); await expect(createProject).toHaveText('新建项目'); await expect(projectsHeading).toHaveText('我的项目'); await expect(project).toHaveText('咖啡机新品主视觉'); @@ -526,6 +526,15 @@ test.describe('AI Design V2 workspace', () => { }); expect(navigationStyles.community).toEqual(navigationStyles.create); + await createProject.click(); + const creationDialog = page.getByRole('dialog', { name: '新建设计项目', exact: true }); + await expect(creationDialog).toBeVisible(); + await expect(creationDialog).not.toContainText('创建后会立即生成一份空白设计表单'); + await expect(creationDialog.getByRole('textbox', { name: '项目名称' })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('canvas-project-create-dialog.png'), animations: 'disabled' }); + await creationDialog.getByRole('button', { name: '取消', exact: true }).click(); + await expect(creationDialog).toHaveCount(0); + const splitBounds = (await split.boundingBox())!; const conversationBounds = (await conversationPane.boundingBox())!; const worksBounds = (await worksPane.boundingBox())!; diff --git a/tests/unit/image-workspace-sidebar.test.tsx b/tests/unit/image-workspace-sidebar.test.tsx index 5f29ba5a..a38a1eab 100644 --- a/tests/unit/image-workspace-sidebar.test.tsx +++ b/tests/unit/image-workspace-sidebar.test.tsx @@ -98,12 +98,13 @@ describe('ImageWorkspaceSidebar', () => { fireEvent.click(screen.getByRole('button', { name: '新建设计项目' })); const dialog = screen.getByRole('dialog', { name: '新建设计项目' }); expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(dialog).not.toHaveTextContent('创建后会立即生成一份空白设计表单'); fireEvent.change(within(dialog).getByRole('textbox', { name: '项目名称' }), { target: { value: ' 小狗的快乐短片 ' }, }); fireEvent.click(within(dialog).getByRole('button', { name: '创建项目' })); - await waitFor(() => expect(actions.createProject).toHaveBeenCalledExactlyOnceWith('小狗的快乐短片')); + await waitFor(() => expect(actions.createProject).toHaveBeenCalledExactlyOnceWith('小狗的快乐短片', expect.stringMatching(/^workspace-/))); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(screen.getByLabelText('当前页面')).toHaveTextContent('/image-canvas'); expect(actions.selectProject).not.toHaveBeenCalled(); @@ -111,6 +112,34 @@ describe('ImageWorkspaceSidebar', () => { expect(actions.deleteProject).not.toHaveBeenCalled(); }); + it('keeps one creation intent across a failed submission and uses a new intent after reopening', async () => { + const { actions } = prepareProjects(); + actions.createProject.mockRejectedValueOnce(new Error('AI 设计服务响应超时,请重试')); + renderSidebar(); + + fireEvent.click(screen.getByRole('button', { name: '新建设计项目' })); + let dialog = screen.getByRole('dialog', { name: '新建设计项目' }); + fireEvent.change(within(dialog).getByRole('textbox', { name: '项目名称' }), { target: { value: '秋天' } }); + fireEvent.click(within(dialog).getByRole('button', { name: '创建项目' })); + await within(dialog).findByText('AI 设计服务响应超时,请重试'); + const initialIntent = actions.createProject.mock.calls[0][1]; + expect(initialIntent).toMatch(/^workspace-/); + expect(within(dialog).getByRole('textbox', { name: '项目名称' })).toHaveValue('秋天'); + + fireEvent.click(within(dialog).getByRole('button', { name: '创建项目' })); + await waitFor(() => expect(actions.createProject).toHaveBeenCalledTimes(2)); + expect(actions.createProject.mock.calls[1]).toEqual(['秋天', initialIntent]); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: '新建设计项目' })); + dialog = screen.getByRole('dialog', { name: '新建设计项目' }); + fireEvent.change(within(dialog).getByRole('textbox', { name: '项目名称' }), { target: { value: '秋天' } }); + fireEvent.click(within(dialog).getByRole('button', { name: '创建项目' })); + await waitFor(() => expect(actions.createProject).toHaveBeenCalledTimes(3)); + expect(actions.createProject.mock.calls[2][1]).not.toBe(initialIntent); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + it('ignores compact creation events while the desktop sidebar is hidden', () => { const { actions } = prepareProjects(); setViewport(false); diff --git a/tests/unit/image-workspace-store.test.ts b/tests/unit/image-workspace-store.test.ts index f3eec307..206caeaf 100644 --- a/tests/unit/image-workspace-store.test.ts +++ b/tests/unit/image-workspace-store.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createImageWorkspaceOperationId, createImageWorkspaceProject, + createImageWorkspaceProjectId, deleteImageWorkspaceProject, fetchImageWorkspace, fetchImageWorkspaceProject, @@ -25,6 +26,7 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => { ...original, createImageWorkspaceOperationId: vi.fn(), createImageWorkspaceProject: vi.fn(), + createImageWorkspaceProjectId: vi.fn(), deleteImageWorkspaceProject: vi.fn(), fetchImageWorkspace: vi.fn(), fetchImageWorkspaceProject: vi.fn(), @@ -39,13 +41,28 @@ vi.mock('@/lib/host-api', async (importOriginal) => { return { ...original, setDesktopBackgroundLease: vi.fn() }; }); +const authMock = vi.hoisted(() => ({ + authenticated: true, + userId: 'account-1', + listeners: new Set<() => void>(), +})); + vi.mock('@/stores/auth', () => ({ useAuthStore: { - getState: () => ({ isAuthenticated: () => true }), + getState: () => ({ + isAuthenticated: () => authMock.authenticated, + authBase: 'https://auth.example.test', + user: { userId: authMock.userId, username: authMock.userId, tenantId: 'tenant-1' }, + }), + subscribe: (listener: () => void) => { + authMock.listeners.add(listener); + return () => authMock.listeners.delete(listener); + }, }, })); const operationIdMock = vi.mocked(createImageWorkspaceOperationId); +const projectIdMock = vi.mocked(createImageWorkspaceProjectId); const createProjectMock = vi.mocked(createImageWorkspaceProject); const deleteProjectMock = vi.mocked(deleteImageWorkspaceProject); const fetchBootstrapMock = vi.mocked(fetchImageWorkspace); @@ -95,7 +112,12 @@ async function loadedStore(eventSource = new FakeEventSource()) { describe('V2 Living Form store', () => { beforeEach(() => { vi.clearAllMocks(); + authMock.authenticated = true; + authMock.userId = 'account-1'; + authMock.listeners.forEach((listener) => listener()); useImageWorkspaceStore.getState().reset(); + let creationSequence = 0; + projectIdMock.mockImplementation(() => `client-workspace-${++creationSequence}`); const workspace = designWorkspaceFixture(); fetchBootstrapMock.mockResolvedValue(designBootstrapFixture()); fetchProjectMock.mockResolvedValue(workspace); @@ -1659,12 +1681,127 @@ describe('V2 Living Form store', () => { await useImageWorkspaceStore.getState().renameProject('workspace-1', '新名称'); await useImageWorkspaceStore.getState().deleteProject('workspace-1'); - expect(createProjectMock).toHaveBeenCalledWith('新项目'); + expect(createProjectMock).toHaveBeenCalledWith('新项目', expect.any(String)); expect(renameProjectMock).toHaveBeenCalledWith('workspace-1', '新名称'); expect(deleteProjectMock).toHaveBeenCalledWith('workspace-1'); expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBeNull(); }); + it.each([ + new ImageWorkspaceApiError(504, 'DESIGN_WORKSPACE_REQUEST_TIMEOUT', '响应超时'), + new Error('连接中断'), + new ImageWorkspaceApiError(404, 'SNAPSHOT_NOT_FOUND', '暂未读取到创建结果'), + new ImageWorkspaceApiError(404, 'SNAPSHOT_NOT_FOUND', '暂未读取到创建结果', 'unknown'), + ])('retries an unknown project creation with its original title and Workspace identity: %s', async (error) => { + createProjectMock.mockRejectedValueOnce(error); + + await expect(useImageWorkspaceStore.getState().createProject(' 秋天 ', 'intent-1')) + .rejects.toBe(error); + expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBeNull(); + await useImageWorkspaceStore.getState().createProject('秋天', 'intent-1'); + + expect(createProjectMock).toHaveBeenNthCalledWith(1, '秋天', 'client-workspace-1'); + expect(createProjectMock).toHaveBeenNthCalledWith(2, '秋天', 'client-workspace-1'); + expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBe('workspace-1'); + }); + + it('does not bind a new same-name creation intent to an unknown earlier request', async () => { + createProjectMock.mockRejectedValueOnce(new ImageWorkspaceApiError(502, 'NETWORK_ERROR', '连接中断')); + + await expect(useImageWorkspaceStore.getState().createProject('秋天', 'intent-1')).rejects.toThrow(); + await useImageWorkspaceStore.getState().createProject('秋天', 'intent-2'); + + expect(createProjectMock).toHaveBeenNthCalledWith(1, '秋天', 'client-workspace-1'); + expect(createProjectMock).toHaveBeenNthCalledWith(2, '秋天', 'client-workspace-2'); + }); + + it('uses a new Workspace identity when the user changes the submitted project name', async () => { + createProjectMock.mockRejectedValueOnce(new ImageWorkspaceApiError(504, 'TIMEOUT', '响应超时')); + + await expect(useImageWorkspaceStore.getState().createProject('秋天', 'intent-1')).rejects.toThrow(); + await useImageWorkspaceStore.getState().createProject('冬天', 'intent-1'); + + expect(createProjectMock).toHaveBeenNthCalledWith(1, '秋天', 'client-workspace-1'); + expect(createProjectMock).toHaveBeenNthCalledWith(2, '冬天', 'client-workspace-2'); + }); + + it('preserves the creation identity for a same-payload retry after a definitive failure', async () => { + createProjectMock.mockRejectedValueOnce(new ImageWorkspaceApiError( + 400, 'INVALID_NAME', '名称无效', 'definitive_failure', + )); + + await expect(useImageWorkspaceStore.getState().createProject('秋天', 'intent-1')).rejects.toThrow(); + await useImageWorkspaceStore.getState().createProject('秋天', 'intent-1'); + + expect(createProjectMock).toHaveBeenNthCalledWith(2, '秋天', 'client-workspace-1'); + }); + + it('requires an authenticated account before creating a project', async () => { + authMock.authenticated = false; + authMock.listeners.forEach((listener) => listener()); + + await expect(useImageWorkspaceStore.getState().createProject('秋天', 'intent-1')) + .rejects.toMatchObject({ code: 'design_auth_required' }); + expect(createProjectMock).not.toHaveBeenCalled(); + }); + + it('shares one in-flight request for repeated submits without creating two projects', async () => { + const pending = deferred>(); + createProjectMock.mockReturnValueOnce(pending.promise); + const first = useImageWorkspaceStore.getState().createProject('秋天', 'intent-1'); + const repeated = useImageWorkspaceStore.getState().createProject(' 秋天 ', 'intent-1'); + + expect(repeated).toBe(first); + expect(createProjectMock).toHaveBeenCalledTimes(1); + await expect(useImageWorkspaceStore.getState().createProject('冬天', 'intent-1')) + .rejects.toThrow('项目正在创建'); + pending.resolve(designWorkspaceFixture()); + await Promise.all([first, repeated]); + expect(openEventsMock).toHaveBeenCalledTimes(1); + }); + + it.each(['account-change', 'logout-login', 'reset'] as const)( + 'does not reuse an unknown creation after %s', async (change) => { + createProjectMock.mockRejectedValueOnce(new ImageWorkspaceApiError(504, 'TIMEOUT', '响应超时')); + await expect(useImageWorkspaceStore.getState().createProject('秋天', 'intent-1')).rejects.toThrow(); + + if (change === 'account-change') authMock.userId = 'account-2'; + if (change === 'logout-login') { + authMock.authenticated = false; + authMock.listeners.forEach((listener) => listener()); + authMock.authenticated = true; + } + if (change === 'reset') useImageWorkspaceStore.getState().reset(); + authMock.listeners.forEach((listener) => listener()); + await useImageWorkspaceStore.getState().createProject('秋天', 'intent-1'); + + expect(createProjectMock).toHaveBeenNthCalledWith(2, '秋天', 'client-workspace-2'); + }, + ); + + it.each(['account-change', 'logout-login', 'reset'] as const)( + 'does not install an old creation response after %s', async (change) => { + const pending = deferred>(); + createProjectMock.mockReturnValueOnce(pending.promise); + const first = useImageWorkspaceStore.getState().createProject('秋天', 'intent-1'); + const rejected = expect(first).rejects.toMatchObject({ code: 'design_creation_session_changed' }); + + if (change === 'account-change') authMock.userId = 'account-2'; + if (change === 'logout-login') { + authMock.authenticated = false; + authMock.listeners.forEach((listener) => listener()); + authMock.authenticated = true; + } + if (change === 'reset') useImageWorkspaceStore.getState().reset(); + authMock.listeners.forEach((listener) => listener()); + pending.resolve(designWorkspaceFixture()); + await rejected; + + expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBeNull(); + expect(openEventsMock).not.toHaveBeenCalled(); + }, + ); + it('renames a background Workspace without changing the active Workspace', async () => { await loadedStore(); const backgroundSummary = designSummaryFixture({ From b5f31c125949ea32f28c2ebac314fa039e7aa82e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 14:46:31 +0800 Subject: [PATCH 07/18] fix: improve Coding sidebar text and remove gallery footer --- .../20261014-coding-sidebar-text-9fe4278d.md | 22 ++++++++++--- README.md | 6 ++-- src/components/layout/Sidebar.tsx | 2 +- src/pages/Chat/CodingProjectConversations.tsx | 4 +-- src/pages/Chat/classroom-workspace.css | 11 ++++--- tests/e2e/main-navigation.spec.ts | 32 +++++++------------ tests/e2e/pi-coding-first-chat.spec.ts | 4 +-- tests/unit/sidebar-project-actions.test.tsx | 31 +++++++++++++++--- 8 files changed, 72 insertions(+), 40 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261014-coding-sidebar-text-9fe4278d.md b/.project-docs/30-worklog/tasks/20261014-coding-sidebar-text-9fe4278d.md index 6cc31f1a..ee808982 100644 --- a/.project-docs/30-worklog/tasks/20261014-coding-sidebar-text-9fe4278d.md +++ b/.project-docs/30-worklog/tasks/20261014-coding-sidebar-text-9fe4278d.md @@ -12,18 +12,18 @@ ## Scope -- Read-only comparison of Coding sidebar text with Agents and Canvas, then propose a clearer text hierarchy. Implementation requires the user's later approval. +- Implement the approved Coding sidebar text hierarchy and remove only its bottom Works Square button. Preserve Canvas/Agents navigation and the separate Code welcome gallery entry. ## Intent And Constraints -- User explicitly asks for a proposal first and forbids beginning product changes before approval. Preserve desktop focus, running trial, inputs, projects and conversations. No product edits, runtime update, model calls, merge/push or cleanup. +- User explicitly approved the preceding proposal and additionally requested deletion of the Coding sidebar's bottom Works Square button. This supersedes the earlier source-edit hold and the former footer-placement direction only. Preserve desktop focus, running trial, inputs, projects/conversations, background tasks and other modules; no model calls, cloud writes, merge/push or cleanup. ## Outcome - All three modules share the same bundled Inter/Source Han sans font stack. Agents main navigation and Canvas project entries use14px/600 dark text for normal clickable items; selected/hover states are differentiated primarily by background. - Coding project/create/gallery entries already use14px/600 dark navy. Its conversation titles and new-conversation action are12px/400 auxiliary blue-gray#64758a, with icons#7891af. The group header uses the same auxiliary gray. These styles explain the screenshot's weaker conversation-list hierarchy; no font replacement is needed. - Proposed Coding-only scope: conversation titles and new-conversation action14px/500/#26384d; group heading and collapse action#506176 while remaining a secondary hierarchy; conversation icons one shade darker. Retain existing14px/600 project entries, pale-blue surface, icon tiles, selected/hover backgrounds, layout and interactions. Scope any adjustment to Coding rather than changing global/shared Canvas tokens. -- No product code or running application was changed. Proposal is complete; implementation remains awaiting human approval. +- The initial proposal changed no product/application state. User subsequently approved implementation; the completed source-only result is recorded below. Running application remains unchanged. ## Verification @@ -33,7 +33,7 @@ ## Follow-ups -- On approval, resume the same task and recheck the newest actual source/peer sidebar changes before applying the bounded Coding-only adjustment. Verify normal/selected/hover/disabled typography and layout in headless browser checks, then perform only separately authorized trial delivery. +- Source is ready for a requested trial update/integration. Before delivery, recheck the actual runtime/source and peer changes, preserve all intervening features and guard account/input/running work/focus. No live delivery, merge/push or worktree cleanup was performed in this implementation turn. ## Promotion Candidates @@ -46,3 +46,17 @@ - Official registry had78 owners including this task. Independent coordination review read77 peer records/231Scope, Intent and Promotion sections. All readable; Code-model-onboarding01e8cbed and old child-language6bf94d12 retain unknown placeholder scopes without a concrete dependency. No semantic blocker affects this read-only proposal. - Adjacent Code gallery/footer tasks retain palette/layout; wide Code redesigne4132a1e was explicitly withdrawn and is not reused. Canvas's later a72d49e6 task changes its surface to pale yellow, superseding the older lavender state; no Canvas background proposal is made here. Source comparison uses the declared actual trial436f7ba snapshot and adopts no peer uncommitted product. - Likely approved-edit paths would be CodingProjectConversations.tsx and Coding-scoped selectors in classroom-workspace.css. No shared global color/font change or backend/API alteration is justified. Planning Gate: Passed. + +## Approved Implementation Resume + +- Official check/start/status resumed the exact same feature ownership, branch/worktree/base; registry now planning. Current trial source remains436f7ba, identical to the proposal baseline; all product files are clean. Prior project/design/context is reused. The78-owner registry is unchanged;77 peer scope sets were reviewed in the preceding turn, and the relevant sidebar/gallery/Canvas scopes were reread on resume. No semantic conflict: this explicit user instruction supersedes prior Code footer retention. Canvas's separate footer stays. +- Planning Gate: Passed. Plan: isolate Code-only normal/selected/hover/disabled text rules; remove the Code footer via the shared Sidebar module condition; update existing navigation/layout regression assertions and README; run focused tests/typecheck/scoped lint/production build and headless geometry checks. Commit the bounded source and record actual verification. A live delivery, if undertaken as part of this temporary-app workflow, must bind the then-current actual source and preserve account/input/running work/focus without an unsafe refresh. + +## Completed Approved Source Changes + +- CodingProjectConversations uses14px/500 for new-conversation and normal conversation titles. Code-scoped classroom CSS promotes actions/normal titles to#26384d, group headings and conversation icons to#506176. Existing selected text#304f75, background/border treatments, project14px/600, font stack, action handlers and conversation identity remain. Disabled new-conversation action keeps reduced opacity and no enabled hover background. +- Shared Sidebar renders the bottom community footer for Canvas only. Code chat/settings/plugin workbars no longer contain that bottom Works Square button; the separate Code welcome entry and legacy nonworkspace upper entry remain. A Code-only account-footer padding/border rule preserves the former adjacent-footer reset after removal, without changing Canvas/Agents styling or shifting the account button. +- Updated README and existing navigation/first-chat E2E assertions for removed footer, preserved welcome link and approved typography. Focused Sidebar regressions cover Code footer absence/account presence, Canvas footer retention and the legacy upper entry. Independent source review found no runtime defect or cross-module style leakage; a remaining README footer statement was corrected before completion. +-23/23 related tests across Sidebar project actions, Image Workspace Sidebar and Main-layout peek passed; the peer's5Sidebar tests are included in23, not extra distinct coverage. Typecheck, changed TS/TSX/spec ESLint, diff and production Vite/Main/Preload/utility build passed. CSS is outside ESLint's configured matching files; browser checks provide its verification. The intermediate log redirection failed because the ignored stage directory was not yet present; directory creation and the final build completed successfully. +- Headless QA passed viewport640/1366 × sidebar256/320, with six Code normal/selected/hover/disabled states and Canvas/Agents baseline comparisons (12 module layout combinations, each with baseline/current evidence). Real CodingProjectConversations, AccountMenuTrigger and CommunityLink React SSR use synthetic stores; Sidebar project/Agents markup reproduces real classes/data attributes. Source CSS/bundled fonts are used; no actual app/user data/network access. Long titles truncate correctly; no overflow. Code account button geometry is exactly unchanged, Canvas/Agents computed styles and geometry match436f7ba baseline. Source CSS SHA2561580c7062a3434c1805787efa3270dbb82d9be1546cfd8518abe8a9bae878ddd. Root viewed code-1366-256.png; peer reviewed compact Code and Canvas/Agents PNGs. Evidence: /Users/chillishark/Makelore 麦洛/.trial-runtime/coding-sidebar-text-20261014-9fe4278d/qa/geometry.json. +- Native visible Electron E2E was not launched to preserve desktop focus; updated specs remain future full-native coverage, while the focused units/headless geometry verify this bounded UI change. Runtime Main/Renderer, login, real projects/conversations/inputs and services were not touched. Implementation source is complete; no temporary-app update, main merge/push or cleanup is claimed. diff --git a/README.md b/README.md index 02b1e42a..4d740363 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用 Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。各页左下角账户按钮统一沿用 Code 的白色圆角卡片、头像、字号和右箭头,展开时跟随 Code 侧栏宽度偏好,首页和设置页与 Code 保持相同的左侧与底部边距;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。设置使用独立页面,不显示编程项目栏、项目标题或工作栏收起按钮;“返回”回到打开设置前的页面(保留查询参数),直接打开设置时回模块首页,未保存设置先保存或还原。每次从首页进入 Code、Agents 或 Canvas 时,默认展开左侧工作栏;进入后沿用各模块现有的手动收起与紧凑布局规则。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 - 会话工作台采用浅蓝项目栏,侧栏入口与项目列表采用 Agent 式的彩色图标徽章+文字,选中项目保留浅色反馈;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;欢迎区的麦洛标识、星装饰和「作品广场」组成同一个可鼠标或键盘操作的广场入口,保留原有主标题与说明,输入框上方不再显示两句广场邀请。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏的「作品广场」固定在底部、紧邻账户按钮上方,保持与上方入口相同的横向对齐;只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 + 会话工作台采用浅蓝项目栏,侧栏入口与项目列表采用 Agent 式的彩色图标徽章+文字,选中项目保留浅色反馈;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;欢迎区的麦洛标识、星装饰和「作品广场」组成同一个可鼠标或键盘操作的广场入口,保留原有主标题与说明,输入框上方不再显示两句广场邀请。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。Code 侧栏底部的「作品广场」入口已移除,欢迎区的广场入口保留;只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 - `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、TOOL、SKILL、MCP 服务和知识库纵向呈现,资源选择按钮分别显示「选择TOOL」「选择SKILL」;移除子智能体选择模块,保存其他配置时保留历史子智能体绑定,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框沿用 Code 的浅蓝边框、圆形按钮及一至六行输入规则;Enter 发送、Shift+Enter 换行,保留中文输入法确认。麦克风复用应用级语音输入,识别只追加当前草稿,不自动发送;切换账号、对话或隐藏页面后丢弃过期识别。支持拖入文件、粘贴图片和多文件选择,上传与确认仍由 Main 持有认证并转发;失败重试保留同一批文件身份。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考按消息独立于正文展示;正文到来后或完成时折叠,可手动展开;无思考或工具内容时不显示过程区。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要配套 Yuxi API 的思考和工具记录投影;实时过程沿用既有事件流。平台工具和内置做事手册使用 Yuxi 下发的通俗名称与说明;逐条消息的思考、工具输入/结果/失败独立于 Markdown 正文,实时与历史工具卡片共用展示文案,展开可查看原始工具标识。个人上传手册保留作者文案,资源选择仍保存原始标识。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 个人智能体聊天在每条提问与回答下方显示云端记录的消息时间;发送时间位于紫色消息气泡外下方并靠右,助手回复时间保持在回答下方:今天显示时分,跨日增加日期,悬停可查看完整日期与秒数;发送时间与回复时间沿用原消息记录,重开对话不改为当前时间。流式回答尚无可靠时间时显示「回复中…」,历史到达后展示原时间;缺失或无效时间显示「时间未记录」。正式对话、草稿试用和上次试用对比使用相同规则,不新增 Yuxi 接口。 @@ -41,7 +41,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - macOS 窗口按钮:红色关闭当前窗口,全屏时先退出全屏再关闭;黄色最小化,绿色保留原生缩放/全屏行为。关窗后应用和进行中的后台任务继续运行,点击 Dock、托盘或菜单可重新打开窗口;`Cmd+Q` 退出应用。 - 桌面文本右键菜单:主 Renderer 窗口由 Electron Main 提供原生“剪切、复制、粘贴、全选”操作;菜单只在可编辑区域或已有文本选择时出现,普通空白区域不显示,也不向 Renderer 暴露剪贴板 IPC。 - AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。 -- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。左侧不再展示麦洛品牌与说明语,“新建项目”置于原品牌区,“我的项目”与列表上移到原新建项目区域;“作品广场”与“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影,“作品广场”移至底部、紧邻账户按钮上方;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;从项目菜单进入的配置页及插件资源页复用相同的左侧工作栏,返回与收起按钮、项目和会话列表、底部作品广场与账户的位置保持一致,并沿用当前宽度和收起状态;配置正文、顶部项目名称与配置页自身的返回操作保留。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 +- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。左侧不再展示麦洛品牌与说明语,“新建项目”置于原品牌区,“我的项目”与列表上移到原新建项目区域;“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影;Code 工作栏底部不显示“作品广场”,欢迎页的独立入口保留;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;从项目菜单进入的配置页及插件资源页复用相同的左侧工作栏,返回与收起按钮、项目和会话列表、底部账户的位置保持一致,并沿用当前宽度和收起状态;配置正文、顶部项目名称与配置页自身的返回操作保留。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 - AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。 - 账号词元点数:左下角账户菜单显示本人永久余额和预占;“词元点数与充值”入口暂时隐藏,充值、充值订单与分页收支记录的既有实现保留。真正新注册赠送 100 点,1 元充值 50 点,永久有效;旧账户不补送,旧会员/订阅/周额度/重置卡入口已移除。家长为孩子充值时登录孩子的账号,页面显示当前昵称和账号,点数进入该账号自己的钱包;无需将孩子身份改为家长。当前有效账号可给本人充值,青少年可查看自己的精确余额;使用他人共享钱包时仅显示可用状态,不显示付款人的余额或记录。Main 持有凭据并转发充值请求;付款结果未知时沿用原请求并核查原订单,只有服务端确认才显示到账。扫码信息缺失时可到网页账户页继续原订单,付款来源也由网页账户页管理,充值不会自动切换共享/个人来源。需配套部署 Works Square 当前账号充值资格更新(本次无需新迁移,原永久点数基础为 0094);本地模拟验收不代表真实支付通过。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 @@ -161,7 +161,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 项目设置仅提供默认编程配置的模型选择,不展示项目提示词或项目 Skills 编辑入口;知识文件与插件仍在各自入口管理。默认模型只影响新对话,现有对话独立切换模型与思考选项,已有消息记录不会被改写;保存模型配置保留原有 Agent 元数据。 - 项目智能体只保存自身的精确默认模型;未完成模型选择的智能体保持待配置状态,不从项目级 `defaultModel` 或运行时私有配置回退。 - 一个智能体可以拥有多条互相独立的 Pi Session;智能体和对话元数据分别由 `.makelore/project.json` 与 `.makelore/conversations.json` 保存,稳定的智能体与对话 id 保持本地历史连续。 -- 会话列表直接挂在左侧项目下,不展示中间智能体列;同一项目内不同历史智能体的会话统一列出,支持删除、改名、未读与运行状态。快速切换项目和会话时,迟到的请求与回复保持原归属。 +- 会话列表直接挂在左侧项目下,不展示中间智能体列;“新对话”和对话名称使用 14px 中等字重的深蓝灰,分组标题与对话图标使用较深的辅助灰,选中与悬停沿用浅色背景;同一项目内不同历史智能体的会话统一列出,支持删除、改名、未读与运行状态。快速切换项目和会话时,迟到的请求与回复保持原归属。 - Conversation 历史按需从 Main-owned Snapshot 读取。Session hydration 沿完整 active branch 投影可见历史;公开 SSE 交付 Snapshot、会话元数据变更通知,以及按 Conversation、worker generation 分组的 `patch-batch`。Renderer 整批校验连续 seq 后在一次状态事务中顺序应用,缺口或畸形批次只恢复目标 Conversation,隐藏 Conversation 的流式更新不会提交选中时间线。Main 发出 `lifecycle:sleep` 时页面关闭旧事件流;视图挂载、项目上下文变化、窗口重新可见或获得焦点时,已选 Conversation 会静默重读权威 Snapshot,从而收敛后台已结束的任务,但绝不重放已接受或状态不确定的 mutation。时间线默认挂载最近 120 个节点,接近顶部时按 100 个节点自动追加更早内容并补偿新增高度,保持当前阅读锚点。 - 核心时间线按每条用户消息组织为“用户消息、连续处理过程、结论回复”。Pi 的流式 thinking、工具、压缩、重试、通知和 `subagent.v1` 单个/并行/串行子任务共享一条轻量过程轨道;运行时显示“处理中”和活动指示,完成后按耗时(历史缺少计时时按项目数)收纳为可展开的“已处理”摘要。文字层级按结构化事件类型划分,不按文案关键词猜测:助手在工具调用前后直接对用户说的过程正文与最终结论都使用黑色;thinking、工具输入输出、压缩、子任务和运行状态使用浅灰辅助文字,仍在运行的灰色缩略文字带有低对比度 Shimmer 擦拭高光,结束后停止动画;错误状态保持红色。折叠的 thinking、助手过程正文和工具输出固定展示第一个可见块的首个非空行,横向偏移始终为零;尾部流式更新不会替换未变化的首行预览,展开后仍可查看完整内容。两类流式文字均在原位更新且不做位移动画,助手过程正文也不使用 Shimmer。工具调用用一行摘要持续展示工具名称、最新输入或输出和运行状态,并可按流式批次轻量整行轮动。思考、助手过程正文和工具节点均可展开查看完整详情,工具的累计输出和浏览器附件留在对应的二级展开区而不形成独立气泡。结论回复按安全 Markdown 排版标题、列表、引用、代码和表格;可点击的 HTTP/HTTPS 与本地 HTML 链接统一使用无底色的橙色文字。左键通过 Main-owned 系统入口交给默认浏览器打开;右键可打开系统菜单,网页链接可复制地址,本地链接还可在访达或系统文件夹中显示。同一回复已经给出完整 HTML 路径时,对应的相对查看路径也会解析到该文件。内部 `turn-start`/`turn-end` 只维持事件顺序、不呈现在界面。 - Composer 使用贴底的复合输入面板,支持文字、登录后的语音转写,以及粘贴或选择 PNG/JPEG/WebP/GIF 图片;每条消息最多 16 张图片、最多并行上传 4 张。语音录制通过现有 Main-owned Works Speech Host API 转成文字,并在项目或账号切换时立即停止;当前对话的模型与思考强度组合入口、语音及发送/中止入口保留在底部控制行,模型入口默认使用透明底色。模型与思考强度通过向上展开的分级菜单切换,只列出当前 Provider 或 Makelore 已验证模型档案实际支持的选项;Qwen3.6 Plus 使用混合思考开关,Qwen3.8 Max 暴露低/中/高三档并把产品“高”映射到服务端 `xhigh`。模型仅报告 `off` 时明确显示“不可调”,不伪造未知模型的推理档位。切换完成后静默刷新当前 Snapshot,只有真正恢复 worker 时才显示本地 Agent 重连提示。重复读取 Snapshot 不发布无变化的 worker 状态;Snapshot 与 SSE 批次竞态时,Renderer 会丢弃已覆盖事件并只应用连续的新尾部,避免形成伪重连循环。上下文接近模型限制或发生溢出时由 Pi 自动整理,Composer 不提供手动整理入口;自动整理的进度和结果仍显示在对话过程轨道中。图片在发送前只保留本地预览,点击发送时才经 Main-owned 有界二进制接口上传一次;Main 在落盘前核对 MIME 与最小图片签名,状态与事件只保存 attachment id,时间线按需读取二进制并创建临时 object URL,不保存重复 base64。发送后立即展示图片附件;Pi 实时消息与历史会话恢复都将图片映射到同一附件存储,重复加载复用已有文件。智能体报告 `changed-file.v1` 后,输入框上方会展示当前轮次的文件数与增删行统计,并可展开查看 Main-owned 变更快照中的逐文件差异。 diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 39b059fa..4fa99834 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -874,7 +874,7 @@ export function Sidebar({ onOpenChange={setProfileDialogOpen} /> - {((workspaceLayout && isProgrammingModule) || isPaintingModule) && ( + {isPaintingModule && (
diff --git a/src/pages/Chat/CodingProjectConversations.tsx b/src/pages/Chat/CodingProjectConversations.tsx index 38b1a152..d312c2e2 100644 --- a/src/pages/Chat/CodingProjectConversations.tsx +++ b/src/pages/Chat/CodingProjectConversations.tsx @@ -73,7 +73,7 @@ export function CodingProjectConversations({ aria-label="新建对话" title="新建对话" disabled={!!busy} - className={`flex shrink-0 items-center gap-1.5 rounded py-2 pr-2 text-xs hover:bg-surface-tertiary ${compact ? "pl-0" : "pl-2"}`} + className={`flex shrink-0 items-center gap-1.5 rounded py-2 pr-2 text-sm font-medium hover:bg-surface-tertiary ${compact ? "pl-0" : "pl-2"}`} onClick={() => void act('new', async () => { const workspace = codingWorkspaceStore.getState(); @@ -103,7 +103,7 @@ export function CodingProjectConversations({ className={`classroom-conversation-row group/session flex items-center rounded-lg ${selected === item.id ? 'bg-[#e7e9e1]' : 'hover:bg-surface-tertiary'}`} > , +})); vi.mock('@/components/layout/ModuleSwitcher', () => ({ ModuleSwitcher: () => null })); vi.mock('@/components/layout/ImageWorkspaceSidebar', () => ({ ImageWorkspaceSidebar: () => null })); vi.mock('@/components/layout/SidebarUpdateButton', () => ({ SidebarUpdateButton: () => null })); @@ -69,8 +71,8 @@ function LocationProbe() { return
{useLocation().pathname}
; } -async function renderSidebar() { - render(); +async function renderSidebar(path = '/chat', workspaceLayout = false) { + render(); await act(async () => undefined); projectConfigState.load.mockClear(); } @@ -95,6 +97,27 @@ describe('Sidebar project actions', () => { projectConfigState.load.mockResolvedValue({ status: 'valid', config: {} }); }); + it('removes the community footer from the Code workspace while retaining its account entry', async () => { + await renderSidebar('/chat', true); + + expect(screen.queryByRole('button', { name: '作品广场' })).not.toBeInTheDocument(); + expect(screen.getByTestId('sidebar').querySelector('.classroom-community-footer')).toBeNull(); + expect(screen.getByRole('button', { name: '打开账户菜单:测试用户' })).toBeVisible(); + }); + + it('retains the community footer in Canvas', async () => { + await renderSidebar('/image-canvas'); + + expect(screen.getByRole('button', { name: '作品广场' }).closest('.classroom-community-footer')).not.toBeNull(); + expect(screen.getByRole('button', { name: '打开账户菜单:测试用户' })).toBeVisible(); + }); + + it('retains the existing upper community entry outside the Code workspace layout', async () => { + await renderSidebar(); + + expect(screen.getByRole('button', { name: '作品广场' }).closest('.classroom-community-footer')).toBeNull(); + }); + it('settings for the current project supersedes an earlier pending project open', async () => { await renderSidebar(); let resolveProjectB!: (value: unknown) => void; From ea5d47787e6e43c8782ba159798bf05b93a16d27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 14:47:32 +0800 Subject: [PATCH 08/18] Remove Gallery from the Canvas sidebar --- ...0261014-canvas-create-recovery-a72d49e6.md | 15 +++++++++++++-- README.md | 2 +- src/components/layout/Sidebar.tsx | 2 +- tests/e2e/image-workspace-v2.spec.ts | 19 +++---------------- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261014-canvas-create-recovery-a72d49e6.md b/.project-docs/30-worklog/tasks/20261014-canvas-create-recovery-a72d49e6.md index 285709f1..23ed3f2d 100644 --- a/.project-docs/30-worklog/tasks/20261014-canvas-create-recovery-a72d49e6.md +++ b/.project-docs/30-worklog/tasks/20261014-canvas-create-recovery-a72d49e6.md @@ -12,7 +12,7 @@ ## Scope -- Remove the exact create-dialog description, change the Canvas rail from pale lavender to pale yellow, diagnose actual Canvas project creation timeouts, and implement the smallest evidenced safe recovery/fix. +- Remove the exact create-dialog description, change the Canvas rail from pale lavender to pale yellow, remove its Gallery entry while preserving Code Gallery, diagnose actual Canvas project creation timeouts, and implement the smallest evidenced safe recovery/fix. ## Intent And Constraints @@ -20,7 +20,7 @@ ## Outcome -- Removed the exact desktop creation description and disabled its absent aria description; Canvas shared rail surface is pale yellow #fff9e8, with Code recipes preserved. Both creation dialogs hold a distinct creation intent. Any failed request for unchanged intent/name reuses its original server clientWorkspaceId; a new dialog or modified name creates a new intent, and repeated concurrent submissions share one in-flight request. Account/logout/reset generations prevent old responses from changing another account or reset store. Create/rename name input is disabled during submission. README synchronized. +- Removed the exact desktop creation description and disabled its absent aria description; Canvas shared rail surface is pale yellow #fff9e8, with Code recipes preserved. Both creation dialogs hold a distinct creation intent. Any failed request for unchanged intent/name reuses its original server clientWorkspaceId; a new dialog or modified name creates a new intent, and repeated concurrent submissions share one in-flight request. Account/logout/reset generations prevent old responses from changing another account or reset store. Create/rename name input is disabled during submission. Removed only the Canvas Gallery/作品广场 footer entry; Code Gallery and all account/compact controls are preserved. README synchronized. ## Verification @@ -78,3 +78,14 @@ Gate result: - Product commit and final HEAD will be reported to the authorized coordination chat. This clean Ready source contains Renderer-only changes; Main/shared/resources are unchanged against runtime436. Source implementation and live network recovery are separately evidenced. - A precise read-only real-project bootstrap recovered; no real Autumn project was created. Real UI color/copy activation remains for the combined update, because actual WeChat f1dab files are active while current.json still points at436, and another Code Main update is in progress. Preserve actual newer Renderer feature source and active user work rather than blindly replacing the old pointer's tree. - Any different title or explicitly reopened create dialog is a new creation intent; an older transport-unknown project may still exist on the server. Never delete it or infer identity from a matching name. This patch prevents same-intent retries from making extra identities; it does not establish the underlying NetworkService hang trigger or prove real model generation. + +## Same-task Steering Resume + +- Official start/status resumed this exact task/worktree/branch/base; user adds removal of the Canvas rail Gallery/作品广场 entry. All earlier color/copy/retry/recovery scope remains. Prior full context and peer assessment still apply; this intentionally supersedes the earlier Canvas Gallery addition while preserving Code Gallery semantics. No conflicting source or runtime ownership change is inferred. +- Planning Gate Passed for this narrow addition. Change only Sidebar visibility condition, README and existing Canvas E2E expectations; repeat related source/lint/build/UI verification because the user-visible footer changed. Keep previous reviewed retry implementation and real network recovery evidence intact. The previously reported ca546c9 is superseded by the forthcoming final source HEAD. + +## Final Steering Verification + +- Same-task narrow steering completed: Canvas Gallery entry is absent; Code chat/settings Gallery remains. Standard typecheck, scoped Sidebar/E2E lint, default full build and independent diff review pass. +- Final real isolated Electron pair passed: Canvas V2 desktop/compact creation/dialog/draft/plan/history (7.1s) and Code remembered-project/shared-settings chrome (5.1s), total 2 passed/12.7s. Inspected final creation-dialog screenshot with pale-yellow rail, no original description and no Canvas Gallery. Logs/PNG updated in the external task evidence stage. Earlier 81 focused unit tests remain applicable to the unchanged retry/store/dialog implementation. +- The final continuation source HEAD supersedes ca546c9; both are in this branch. No new actual runtime update or Main restart was performed by this continuation. Authorized coordinator will preserve f1dab live WeChat source and active user work during unified activation. diff --git a/README.md b/README.md index 7414f8a1..3ae32b35 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 Agents 侧栏的「渠道」按每个微信一张卡展示备注、当前智能体与使用状态;一个微信同时只绑定一个智能体,多个微信可以使用同一智能体。绑定有效且服务正常时始终开启回复,不提供暂停或恢复开关;更换智能体保留登录与历史,完成重新登录后也开启回复。旧的关闭记录在页面可见时,重新核对登录、在线服务、可用目标、同步状态及新旧渠道操作恢复记录后串行开启;服务离线、退出待确认、目标不可用和结果未知时不自动写入。常用操作是“更换智能体”;“更多操作”内可以解除微信绑定,停止任务、清除目标并退出微信登录,历史保留,再使用需扫码。确认解绑后的卡片可选择“删除微信卡片”,只从这台设备的当前账号列表移除,刷新和重开后仍保留删除结果;其他设备及云端账号和聊天历史不受影响,同一微信重新绑定后再次显示。本机偏好按账号与租户隔离,删除前再次读取云端状态并检查原操作,保存失败时保留卡片并显示原因。添加流程为选择已开始使用的智能体、扫码、自动完成绑定;扫码确认、目标正确、启用成功且最终状态回读可用后才显示完成。微信服务离线时,卡片仅保留右上角“微信服务离线”状态,不显示重复的离线长段说明;绑定或重新连接失败的窗口仍说明原因,不把扫码成功当作回复可用。未知或未同步状态显示“状态待确认”。无可用智能体时说明创建和“开始使用”的前提,加载失败不冒充空态,已有微信仍可解绑。Main 持有凭据与原操作恢复;解除需要配套 Yuxi 用户级 `/channel-accounts/{id}/unbind`,旧后端不会通过改按钮文案或组合未确认操作假装完成。变更以最新读取状态为准,未完成操作保留原身份供显式恢复;失败在当前窗口可见,结果未知不自动重放。微信退出仍在处理中时,禁止新的连接和绑定变更,保留刷新与原操作恢复。旧请求无法安全恢复、账号关系不一致或目标不可用时提示先核对;“不再提醒”只移除本机提醒,不取消服务端仍在处理的操作。最新 Yuxi 的退出保护条件和持久恢复由服务端负责,发布需配套 Core/Adapter 与 Yuxi business=13、knowledge=2 数据库版本。渠道页不展示微信聊天或活动,既有历史、费用、内容隔离与应用 API 保持原边界。实际微信身份及重复扫码识别仍由云端负责,不从备注推测。 -- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧工作栏与 Code 共用间距、图标徽章、项目行、操作菜单和底部广场/账户样式,仅工作栏底色采用淡黄色(`#fff9e8`);顶部返回箭头与 Code 对齐,分组标题为“我的项目”,桌面项目列表仅显示名称,隐藏名称下的设计版本与更新时间;新建、切换和项目操作菜单集中管理 Workspace;创建请求失败后保留名称与本次创建标识,原样重试避免重复创建项目。工作区统一项目标题、设计版本与制作状态,创作对话和作品区支持拖动分栏、键盘调宽及单栏展开;对话以 Markdown 呈现,底部输入器保留草稿、发送状态与 Enter / Shift+Enter 快捷键。右侧通过“当前作品 / 历史作品”切换,在大图或视频预览旁编辑唯一制作方案、确认费用和制作;历史作品可回到工作区查看,切换面板保留未保存的制作参数。紧凑窗口以“创作对话 / 作品与方案”切换面板,通过项目抽屉管理 Workspace。图片支持适应窗口、原始尺寸及 Esc 关闭,视频支持预览和保存到电脑。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 +- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧工作栏与 Code 共用间距、图标徽章、项目行、操作菜单和底部账户样式,仅工作栏底色采用淡黄色(`#fff9e8`);顶部返回箭头与 Code 对齐,Canvas 工作栏不显示“作品广场”,分组标题为“我的项目”,桌面项目列表仅显示名称,隐藏名称下的设计版本与更新时间;新建、切换和项目操作菜单集中管理 Workspace;创建请求失败后保留名称与本次创建标识,原样重试避免重复创建项目。工作区统一项目标题、设计版本与制作状态,创作对话和作品区支持拖动分栏、键盘调宽及单栏展开;对话以 Markdown 呈现,底部输入器保留草稿、发送状态与 Enter / Shift+Enter 快捷键。右侧通过“当前作品 / 历史作品”切换,在大图或视频预览旁编辑唯一制作方案、确认费用和制作;历史作品可回到工作区查看,切换面板保留未保存的制作参数。紧凑窗口以“创作对话 / 作品与方案”切换面板,通过项目抽屉管理 Workspace。图片支持适应窗口、原始尺寸及 Esc 关闭,视频支持预览和保存到电脑。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 39b059fa..15e4cde6 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -874,7 +874,7 @@ export function Sidebar({ onOpenChange={setProfileDialogOpen} /> - {((workspaceLayout && isProgrammingModule) || isPaintingModule) && ( + {workspaceLayout && isProgrammingModule && (
diff --git a/tests/e2e/image-workspace-v2.spec.ts b/tests/e2e/image-workspace-v2.spec.ts index 330c8895..8c12a282 100644 --- a/tests/e2e/image-workspace-v2.spec.ts +++ b/tests/e2e/image-workspace-v2.spec.ts @@ -479,7 +479,7 @@ test.describe('AI Design V2 workspace', () => { const project = sidebar.getByTestId('sidebar-image-project-workspace-1'); const projectsHeading = sidebar.locator('.classroom-projects-heading'); const moduleReturn = sidebar.getByTestId('sidebar-module-switcher-trigger'); - const community = sidebar.getByRole('button', { name: '作品广场', exact: true }); + await expect(sidebar.getByRole('button', { name: '作品广场', exact: true })).toHaveCount(0); const account = sidebar.getByTestId('sidebar-member-menu-trigger'); await expect(sidebar).toHaveAttribute('aria-hidden', 'false'); await expect(sidebar).toHaveCSS('background-color', 'rgb(255, 249, 232)'); @@ -502,8 +502,8 @@ test.describe('AI Design V2 workspace', () => { const actions = sidebar.getByRole('button', { name: '项目操作:咖啡机新品主视觉', exact: true }); await expect(actions).toHaveCSS('width', '32px'); await expect(actions).toHaveCSS('height', '32px'); - const [sidebarBox, createBox, headingBox, projectBox, returnBox, communityBox, accountBox] = await Promise.all( - [sidebar, createProject, projectsHeading, project, moduleReturn, community, account].map((locator) => locator.boundingBox()), + const [sidebarBox, createBox, headingBox, projectBox, returnBox, accountBox] = await Promise.all( + [sidebar, createProject, projectsHeading, project, moduleReturn, account].map((locator) => locator.boundingBox()), ); expect(sidebarBox!.width).toBe(256); expect(createBox!.height).toBe(48); @@ -512,20 +512,7 @@ test.describe('AI Design V2 workspace', () => { expect(headingBox!.y - sidebarBox!.y).toBe(96); expect(projectBox!.y - sidebarBox!.y).toBe(136); expect(returnBox!.y - sidebarBox!.y).toBe(4); - expect(communityBox!.x).toBe(createBox!.x); - expect(communityBox!.height).toBe(createBox!.height); - expect(communityBox!.y).toBeGreaterThan(projectBox!.y + projectBox!.height); - expect(accountBox!.y - communityBox!.y - communityBox!.height).toBeCloseTo(0, 0); expect(sidebarBox!.y + sidebarBox!.height - accountBox!.y - accountBox!.height).toBeLessThanOrEqual(14); - const navigationStyles = await sidebar.evaluate((element) => { - const recipe = (selector: string) => { - const style = getComputedStyle(element.querySelector(selector)!); - return [style.borderRadius, style.borderWidth, style.boxShadow, style.padding, style.fontSize, style.fontWeight, style.lineHeight]; - }; - return { create: recipe('[data-testid="sidebar-create-project"]'), community: recipe('.community-link') }; - }); - expect(navigationStyles.community).toEqual(navigationStyles.create); - await createProject.click(); const creationDialog = page.getByRole('dialog', { name: '新建设计项目', exact: true }); await expect(creationDialog).toBeVisible(); From 5da90e4a06bcabe0d15133e5bde3f914044b58bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 14:50:49 +0800 Subject: [PATCH 09/18] fix(coding): log sanitized Pi initialization failures --- ...261014-diagnose-coding-runtime-8b41c9e2.md | 24 +- .../pi/initialization-diagnostic.ts | 267 ++++++++++++++++++ electron/coding-runtime/pi/runtime.ts | 75 ++++- tests/unit/pi-diagnostic-stack-safety.test.ts | 60 ++++ .../unit/pi-initialization-diagnostic.test.ts | 237 ++++++++++++++++ .../pi-initialization-failure-logging.test.ts | 159 +++++++++++ 6 files changed, 815 insertions(+), 7 deletions(-) create mode 100644 electron/coding-runtime/pi/initialization-diagnostic.ts create mode 100644 tests/unit/pi-diagnostic-stack-safety.test.ts create mode 100644 tests/unit/pi-initialization-diagnostic.test.ts create mode 100644 tests/unit/pi-initialization-failure-logging.test.ts diff --git a/.project-docs/30-worklog/tasks/20261014-diagnose-coding-runtime-8b41c9e2.md b/.project-docs/30-worklog/tasks/20261014-diagnose-coding-runtime-8b41c9e2.md index 55956382..5be7ec71 100644 --- a/.project-docs/30-worklog/tasks/20261014-diagnose-coding-runtime-8b41c9e2.md +++ b/.project-docs/30-worklog/tasks/20261014-diagnose-coding-runtime-8b41c9e2.md @@ -12,23 +12,31 @@ ## Scope -- Diagnose the current trial Code runtime-unavailable notice using source, immutable runtime artifacts and bounded application startup diagnostics; explain what is actually known and the evidence gap. +- Continue the diagnosed Code initialization issue by adding bounded, sanitized local exception logs at the managed-worker preparation/opening boundary. Preserve existing public errors, runtime behavior and cleanup. Validate code and redaction using isolated fixtures; do not assume the original cause is fixed merely by adding logs. ## Intent And Constraints -- This request asks why; no product/source fix, app restart, user worker, paid model call, UI mutation, profile/credential/chat-history read or deployment. Isolated owned checkout based on the just-published f34e651. Read only startup diagnostics and runtime dependency metadata. Preserve current runtime and all peer/primary work. +- User now explicitly requests sanitized exception logging. Same owned Feature task/worktree/basef34 resumed through officialstart/status. Product source change is authorized; do not capture raw arbitrary error text, prompt/title, credential/env/config payload, URLs or absolute private paths. Keep only a fixed technical classification, allowlisted error name/code, safe code-frame locations and bounded correlation/fingerprint. Logger/formatter failures must not replace the product exception. +- Actual temporary app is undergoing parallel Canvas/Main network recovery and Renderer changes in other source chats. This task does not restart, inspect via private protocol, reset NetworkService or activate source files during that work. Preserve current Main/profile/project data and all peers; decide runtime delivery from a fresh coordinated source handoff rather than stale old authorization. ## Outcome -- Direct failure stage identified: the Pi Agent Server starts and reaches server.ready, then the managed session-open path fails and cleans up with open_failure. The original exception is not retained by current diagnostics, so the specific underlying cause cannot honestly be identified from existing evidence. No runtime or product changes made. +- Initial read-only diagnosis: Pi Agent Server reaches server.ready, then managed session opening fails and cleans up with open_failure. Existing logs do not retain the original exception, so they cannot prove the specific underlying cause. +- Logging continuation adds one local WARN tagged PiWorkerInitializationFailed per failed managed-worker opening. Fixed stage markers cover registry/provider/resources/credential/extension/worker startup/initial hydration/session binding; the original failure is logged before existing cleanup and never replaces public errors, rejection identity or existing cleanup propagation. +- Diagnostic output contains only allowlisted exception/code, fixed classification, bounded cryptographic text fingerprint, hashed conversation reference, worker generation and safe relative code locations when structurally available. Arbitrary error messages, function names, user content/titles, credential/env/config values, URLs and absolute private paths are not logged. Cause depth, code frames and serialized UTF-8 diagnostic size are limited to2 levels/6 frames per error/4096 bytes. A controlled realpath alias preserves official Pi package locations behind pnpm dependency links. +- Pi RPC responses currently preserve only remote error text. Fixed operation/signature projection helps recognize known remote failure patterns without copying their property/function/path suffixes; it does not claim the actual remote exception type or restore its lost stack. Native lazy stack accessors and custom formatters fail closed rather than execute formatting hooks. +- No actual application activation/restart, source publication or model/provider call in this logging continuation. Current history still does not prove the original runtime failure is fixed. ## Verification -- No tests or paid/provider invocation required for this read-only diagnosis. Source trace independently reviewed; current native-launch.log has three server.ready→open_failure cycles, no diagnostic field/original stack, and later background_sleep events do not prove recovery. Actual Main/server artifacts match active436 manifest. Pi0.84.2 files/modules/required API exports are present and resolve in the actual retained runtime; Node version meets its engine. E2E=1 is real but does not disable coding composition/Pi. No secrets/profile state read. +- Read-only diagnosis independently reviewed: native-launch.log has three server.ready→open_failure cycles, no diagnostic field/original stack; later background_sleep does not prove recovery. At that observation, actual Main/server matched436. Pi0.84.2 required modules/exports exist and resolve; Node meets its engine. E2E=1 does not disable coding composition/Pi. No secrets/profile state read. +- Final logging validation: nine relevant unit suites/119 tests passed, including19 diagnostic privacy/RPC cases,6 managed-opener failure regressions and2 isolated native-stack safety probes. Standard typecheck and scoped ESLint passed. Default full build includes Renderer/Main/preload/release utility and passed; existing Browserslist/chunk/dynamic-import notices remain unrelated. No paid/provider invocation or actual user runtime session was used. +- Independent final source review PASS. It first proved preinstalled/inherited custom formatter execution in the intermediate implementation; final data-only code and two independently rerun isolated probes now show zero formatting-hook calls, omitted lazy frames and unchanged original stack descriptors. Logger failures, original public error/rejection identity, logging before cleanup, existing cleanup-error propagation and single failure logging are covered. Official package symlink regression uses the actual canonical temp path, matching Node SDK stack behavior. Whitespace and task-aware drift checks passed. ## Follow-ups -- For a repair continuation, capture a bounded sanitized original exception at the managed session-open boundary, reproduce the failing initialization without a paid prompt, then fix its actual cause and verify the real Code session. Avoid guessing a model/network cause, blindly reinstalling dependencies or treating E2E as a disable flag. This diagnostic checkout is retained ready for that same issue; no source fix or publication was requested. +- Activate the validated logging Main artifact during the next coordinated update, then reproduce the failing initialization without a paid prompt and use new sanitized evidence to fix its actual cause. Avoid guessing a model/network cause, blindly reinstalling dependencies or treating E2E as a disable flag. +- Latest parallel Canvas handoff ea5d47787e6e43c8782ba159798bf05b93a16d27 supersedesca546 (adds Canvas-only Gallery removal; preserves yellow sidebar/no old explanation/stable create retry). The source thread also reports an actual WeChat Renderer alias atf1dabdb358ff5f2228fbef014f9e659c156ccc10 while current.json remains436. These are peer-reported delivery facts, not this task's native acceptance. A later update must freeze fresh actual sources/alias/dirty-input state and preserve WeChat changes; do not blindly overwrite using old436 metadata. This task performs no runtime actions while source coordination is incomplete. ## Promotion Candidates @@ -46,3 +54,9 @@ - `electron/coding-runtime/pi/runtime.ts:238` managed opener includes provider projection/resources/extensions and SDK session opening;487–491 cleanup reasonopen_failure rethrows without preserving the raw error in current application logs. Existing server.ready events prove server startup/import but not successful thread/session initialization. - Current stage native-launch.log contains E2E startup atline4; Main575 creates coding composition regardless. E2E alters startup effects/background idle timing, not a blanket Pi disable. Actual runtime/server/Main hashes match the active436 source; development resolves Pi CLI via snapshot node_modules→primary, not packaged resources/pi-runtime. Pi package/version,7server module paths and6required APIs are present. These checks do not prove every generated session/resource/configuration is valid. - Read-only independent source and artifact reviewers agree: log evidence does not distinguish the original provider/configuration/extension/session exception. Specific diagnosis requires new sanitized exception evidence; do not call a hypothesis the proven root cause. + +## Logging Resume Gate And Plan + +- Same task official ownership exactly verified; startup and prior own diagnosis reloaded. Canonical positioning/decision/architecture/evidence/commitment context unchanged from previously loaded350 tree. Fresh74 and then75 peer scopes/intents/promotions independently assessed read-only; new Code gallery/old child-language placeholders remain unknown, without a concrete Pi logging dependency. Canvas is actively working Main shared networking/creation recovery, so this task performs only isolated source/test/build work. Gate Passed. +- Single-opener observation spans registry/provider/resources/credential/extension/process/initial hydration/session bind. Record original exception BEFORE cleanup inside existing catches, with a once-per-open sentinel; outer catch covers earlier failures and preserves the same rejection object and current cleanup propagation. No renderer message change or error-response expansion. +- Implement a fail-closed diagnostic formatter and focused privacy tests; wire fixed stage markers and known sensitive context in runtime.ts; add meaningful managed-opener regressions for failures before process creation, session open and cleanup/logger failure without changing public behavior. Run scoped tests/type/lint/default full build and independent review. diff --git a/electron/coding-runtime/pi/initialization-diagnostic.ts b/electron/coding-runtime/pi/initialization-diagnostic.ts new file mode 100644 index 00000000..2a8c5264 --- /dev/null +++ b/electron/coding-runtime/pi/initialization-diagnostic.ts @@ -0,0 +1,267 @@ +import { createHash } from 'node:crypto'; +import { sanitizePiDiagnostic } from './worker-process'; + +const STAGES = new Set([ + 'registry', 'provider-input', 'provider-catalog', 'provider-credentials', + 'worker-resources', 'device-resources', 'resource-materialization', + 'extension-registration', 'worker-start', 'session-bind', 'initial-hydration', + 'snapshot', 'prepare', +]); +const EXCEPTIONS = new Set([ + 'Error', 'EvalError', 'RangeError', 'ReferenceError', 'SyntaxError', 'TypeError', + 'URIError', 'AggregateError', 'AbortError', 'PiProcessError', + 'CodingRuntimeContractError', 'PiProviderConfigError', 'PiSessionProjectionError', + 'CodingConversationServiceError', 'CodingProjectServiceError', +]); +const CODES = new Set([ + 'ENOENT', 'ENOTDIR', 'EACCES', 'EPERM', 'EINVAL', 'EEXIST', 'EMFILE', 'ENFILE', + 'ENOSPC', 'EROFS', 'EIO', 'ENAMETOOLONG', 'ETIMEDOUT', 'EPIPE', 'ECONNRESET', + 'ECONNREFUSED', 'MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND', 'ERR_REQUIRE_ESM', + 'ERR_PACKAGE_PATH_NOT_EXPORTED', 'ERR_INVALID_ARG_TYPE', 'ERR_INVALID_URL', + 'ERR_IMPORT_ATTRIBUTE_MISSING', 'ERR_UNKNOWN_FILE_EXTENSION', + 'ERR_DLOPEN_FAILED', 'ERR_WORKER_INIT_FAILED', 'ABORT_ERR', + 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_SOCKET', + 'PI_RPC_PROTOCOL_ERROR', 'PI_RPC_TIMEOUT', 'PI_RPC_ABORTED', + 'PI_RPC_RESPONSE_ERROR', 'PI_RPC_WRITE_FAILED', 'PI_RPC_EXITED', + 'PI_WORKER_START_FAILED', 'PI_WORKER_STOPPED', 'PI_WORKER_STOP_FAILED', + 'CODING_RUNTIME_START_FAILED', 'CODING_RUNTIME_READY_TIMEOUT', + 'CODING_RUNTIME_PROTOCOL_ERROR', 'CODING_RUNTIME_UNAVAILABLE', + 'CODING_CONVERSATION_NOT_FOUND', 'CODING_SESSION_UNREADABLE', + 'CODING_STORAGE_WRITE_FAILED', 'CODING_MIGRATION_MODEL_REQUIRED', + 'CODING_MODEL_UNAVAILABLE', 'CODING_PROVIDER_AUTH_REQUIRED', + 'CODING_AGENT_NOT_FOUND', 'CODING_REQUEST_UNCERTAIN', + 'PROVIDER_INVALID', 'PROVIDER_AUTH_REQUIRED', 'MODEL_UNAVAILABLE', +]); +const MAX_BYTES = 4_096; +const MAX_TEXT_BYTES = 32_768; +const MAX_FRAME_BYTES = 160; +const MAX_FRAMES = 6; +const MAX_CAUSE_DEPTH = 2; +const REDACTED_FRAME = '[REDACTED_FRAME]'; +const MAX_RPC_TAIL_BYTES = 2_048; +// Server control commands plus PiRpcClient's read-only initialization commands. +const RPC_OPERATIONS = [ + 'server_initialize', 'thread_open', 'thread_close', 'server_shutdown', + 'get_available_models', 'get_available_thinking_levels', 'get_commands', + 'get_entries', 'get_fork_messages', 'get_last_assistant_text', 'get_messages', + 'get_session_stats', 'get_state', 'get_tree', +] as const; + +export interface PiInitializationDiagnostic { + version: 1; + stage: string; + exception: string; + code?: string; + category: string; + message: string; + fingerprint?: string; + rpcOperation?: (typeof RPC_OPERATIONS)[number]; + rpcFailureSignature?: 'undefined-property' | 'null-property' | 'not-callable' | 'module-not-found' | 'missing-local-resource'; + frames?: string[]; + causes?: PiInitializationDiagnostic[]; + truncated?: true; +} + +export interface PiInitializationDiagnosticOptions { + stage: string; + sensitiveValues?: readonly string[]; + /** Bundled application/package roots only; never user project roots. */ + trustedRoots?: readonly string[]; +} + +function own(value: unknown, key: string): PropertyDescriptor | undefined { + if ((typeof value !== 'object' || value === null) && typeof value !== 'function') return undefined; + try { return Object.getOwnPropertyDescriptor(value, key); } catch { return undefined; } +} +function data(value: unknown, key: string): unknown { + const descriptor = own(value, key); + return descriptor && 'value' in descriptor ? descriptor.value : undefined; +} +function strings(value: unknown): string[] { + try { + if (!Array.isArray(value)) return []; + const length = data(value, 'length'); + if (typeof length !== 'number' || !Number.isSafeInteger(length)) return []; + const result: string[] = []; + for (let index = 0; index < Math.min(length, 128); index++) { + const item = data(value, String(index)); + if (typeof item === 'string' && item && item.length <= MAX_TEXT_BYTES && Buffer.byteLength(item) <= MAX_TEXT_BYTES) result.push(item); + } + return result; + } catch { return []; } +} +function exceptionName(error: unknown): string { + let value = error; + for (let depth = 0; depth < 3; depth++) { + const name = data(value, 'name'); + if (typeof name === 'string') return EXCEPTIONS.has(name) ? name : 'UnknownError'; + if (own(value, 'name')) return 'UnknownError'; + try { + if ((typeof value !== 'object' || value === null) && typeof value !== 'function') break; + value = Object.getPrototypeOf(value); + } catch { break; } + } + return 'UnknownError'; +} +function knownCode(error: unknown): string | undefined { + const direct = data(error, 'code'); + if (typeof direct === 'string' && CODES.has(direct)) return direct; + const publicCode = data(data(error, 'publicError'), 'code'); + return typeof publicCode === 'string' && CODES.has(publicCode) ? publicCode : undefined; +} +function rpcProjection(error: unknown): Pick { + if (data(error, 'code') !== 'PI_RPC_RESPONSE_ERROR') return {}; + const message = data(error, 'message'); + if (typeof message !== 'string') return {}; + // Inspect a bounded prefix first; never scan/copy an unbounded response tail. + const wrapper = message.slice(0, 128).match(/^Pi RPC ([a-z_]{1,48}) failed: /); + const operation = wrapper ? RPC_OPERATIONS.find(candidate => candidate === wrapper[1]) : undefined; + if (!wrapper || !operation) return {}; + const result: Pick = { rpcOperation: operation }; + if (message.length - wrapper[0].length > MAX_RPC_TAIL_BYTES) return result; + const tail = message.slice(wrapper[0].length); + if (Buffer.byteLength(tail) > MAX_RPC_TAIL_BYTES) return result; + // Match known runtime shapes, but never retain their private property names, + // function expressions, package names, paths, URLs or other response suffixes. + const property = tail.match(/^(?:TypeError: )?Cannot read properties of (undefined|null) \(reading (?:'[^'\r\n]{1,256}'|"[^"\r\n]{1,256}")\)$/); + if (property) result.rpcFailureSignature = property[1] === 'undefined' ? 'undefined-property' : 'null-property'; + else if (/^(?:TypeError: )?[^\r\n]{1,256} is not a function$/.test(tail)) result.rpcFailureSignature = 'not-callable'; + else if (/^(?:Error: )?Cannot find module '[^'\r\n]{1,512}'(?:\nRequire stack:(?:\n- [^\r\n]{1,512}){1,8})?$/.test(tail) + || /^(?:Error: )?Cannot find package '[^'\r\n]{1,256}' imported from [^\r\n]{1,1024}$/.test(tail)) result.rpcFailureSignature = 'module-not-found'; + else if (/^(?:Error: )?ENOENT: no such file or directory(?:, (?:open|access|stat|lstat|scandir|readdir|realpath|readlink) '[^'\r\n]{1,1024}')?$/.test(tail)) result.rpcFailureSignature = 'missing-local-resource'; + return result; +} +function classify(exception: string, code: string | undefined): Pick { + if (code && ['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND', 'ERR_REQUIRE_ESM', 'ERR_PACKAGE_PATH_NOT_EXPORTED', 'ERR_UNKNOWN_FILE_EXTENSION', 'ERR_DLOPEN_FAILED'].includes(code)) + return { category: 'module-loading', message: 'A required runtime module could not be loaded.' }; + if (code && ['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM', 'EINVAL', 'EEXIST', 'EMFILE', 'ENFILE', 'ENOSPC', 'EROFS', 'EIO', 'ENAMETOOLONG', 'CODING_STORAGE_WRITE_FAILED'].includes(code)) + return { category: 'local-storage', message: 'A local runtime resource could not be read or written.' }; + if (code && ['PROVIDER_AUTH_REQUIRED', 'CODING_PROVIDER_AUTH_REQUIRED'].includes(code)) + return { category: 'provider-authentication', message: 'Provider authentication preparation failed.' }; + if (code && ['PROVIDER_INVALID', 'MODEL_UNAVAILABLE', 'CODING_MODEL_UNAVAILABLE', 'CODING_MIGRATION_MODEL_REQUIRED'].includes(code)) + return { category: 'provider-configuration', message: 'The runtime model configuration could not be prepared.' }; + if (code && ['PI_RPC_TIMEOUT', 'CODING_RUNTIME_READY_TIMEOUT', 'ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT'].includes(code)) + return { category: 'initialization-timeout', message: 'Runtime preparation or communication timed out.' }; + if (code && ['PI_RPC_PROTOCOL_ERROR', 'PI_RPC_RESPONSE_ERROR', 'PI_RPC_WRITE_FAILED', 'CODING_RUNTIME_PROTOCOL_ERROR'].includes(code)) + return { category: 'worker-protocol', message: 'The runtime worker communication failed.' }; + if (code && ['PI_RPC_EXITED', 'PI_WORKER_START_FAILED', 'PI_WORKER_STOPPED', 'PI_WORKER_STOP_FAILED', 'CODING_RUNTIME_START_FAILED', 'ERR_WORKER_INIT_FAILED'].includes(code)) + return { category: 'worker-lifecycle', message: 'The runtime worker could not start or remain available.' }; + if (code === 'CODING_SESSION_UNREADABLE') + return { category: 'session-projection', message: 'Stored runtime session data could not be projected.' }; + if (exception === 'TypeError' || exception === 'ReferenceError' || code === 'ERR_INVALID_ARG_TYPE') + return { category: 'runtime-contract', message: 'Runtime initialization encountered a type or reference mismatch.' }; + if (exception === 'SyntaxError') + return { category: 'invalid-runtime-data', message: 'Runtime initialization encountered invalid syntax or data.' }; + return { category: 'unclassified-initialization', message: 'Runtime initialization failed without a recognized error classification.' }; +} +function secretAliases(values: string[]): string[] { + const result = new Set(); + for (const value of values) { + result.add(value); + try { + result.add(encodeURIComponent(value)); + result.add(encodeURI(value)); + result.add(encodeURIComponent(value).replace(/%20/g, '+')); + } catch { /* Invalid UTF-16 is still covered by its literal value. */ } + result.add(JSON.stringify(value).slice(1, -1)); + result.add(Buffer.from(value).toString('base64')); + result.add(Buffer.from(value).toString('base64url')); + } + return [...result]; +} +function decodedLocation(value: string): string | null { + if (Buffer.byteLength(value) > 2_048) return null; + let result = value; + try { + for (let depth = 0; depth < 2; depth++) { + const next = decodeURIComponent(result); + if (next === result) break; + result = next; + } + } catch { return null; } + if (result.startsWith('file:///')) result = result.slice(7); + else if (result.startsWith('file://localhost/')) result = result.slice(16); + else if (result.includes('://')) return null; + if (/^\/[A-Za-z]:\//.test(result)) result = result.slice(1); + result = result.replace(/\\/g, '/'); + if (result.split('/').some(segment => segment === '..' || segment === '.')) return null; + return result; +} +function frameLocation(line: string, roots: string[], sensitive: string[]): string { + const match = line.match(/^\s*at\s+(?:.*?\()?([^()\r\n]+):(\d{1,9}):(\d{1,9})\)?\s*$/); + if (!match) return REDACTED_FRAME; + const location = decodedLocation(match[1]); + if (!location) return REDACTED_FRAME; + let relative: string | undefined; + if (/^node:internal\/[A-Za-z0-9_./-]+$/.test(location)) relative = location; + else { + for (let index = 0; index < roots.length; index++) { + const root = decodedLocation(roots[index])?.replace(/\/+$/, ''); + if (!root || !/^(?:\/|[A-Za-z]:\/)/.test(root)) continue; + const windows = /^[A-Za-z]:\//.test(root); + const candidate = windows ? location.toLowerCase() : location; + const prefix = windows ? root.toLowerCase() : root; + if (candidate.startsWith(prefix + '/')) { + const suffix = location.slice(root.length + 1); + // Drop function names and absolute roots; retain only code locations. + if (/^[A-Za-z0-9_@+.,$-]+(?:\/[A-Za-z0-9_@+.,$-]+)*\.(?:[cm]?[jt]sx?|mjs|cjs)$/.test(suffix)) relative = `root${index}/${suffix}`; + break; + } + } + } + if (!relative || /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(relative)) return REDACTED_FRAME; + if (sanitizePiDiagnostic(relative, sensitive) !== relative) return REDACTED_FRAME; + const frame = `${relative}:${match[2]}:${match[3]}`; + return Buffer.byteLength(frame) <= MAX_FRAME_BYTES ? frame : REDACTED_FRAME; +} + +/** Produce bounded technical evidence only; arbitrary exception text is never logged. */ +export function buildPiInitializationDiagnostic( + error: unknown, + options: PiInitializationDiagnosticOptions, +): PiInitializationDiagnostic { + const requestedStage = data(options, 'stage'); + const stage = typeof requestedStage === 'string' && STAGES.has(requestedStage) ? requestedStage : 'unknown'; + const roots = strings(data(options, 'trustedRoots')); + const sensitive = secretAliases(strings(data(options, 'sensitiveValues'))); + const seen = new Set(); + const build = (value: unknown, depth: number): PiInitializationDiagnostic => { + const exception = exceptionName(value); + const code = knownCode(value); + const result: PiInitializationDiagnostic = { + version: 1, stage, exception, ...(code ? { code } : {}), ...classify(exception, code), ...rpcProjection(value), + }; + const message = typeof value === 'string' ? value : data(value, 'message'); + if (typeof message === 'string' && message) { + const bytes = Buffer.from(message.slice(0, MAX_TEXT_BYTES)); + result.fingerprint = createHash('sha256').update(bytes.subarray(0, MAX_TEXT_BYTES)).digest('hex').slice(0, 16); + if (message.length > MAX_TEXT_BYTES || bytes.length > MAX_TEXT_BYTES) result.truncated = true; + } + // Even native lazy stack accessors can call global formatting hooks. Only + // inspect an already available string data property; never invoke a getter. + const stack = data(value, 'stack'); + if (typeof stack === 'string') { + const bytes = Buffer.from(stack.slice(0, MAX_TEXT_BYTES)); + const bounded = bytes.subarray(0, MAX_TEXT_BYTES).toString('utf8'); + const lines = bounded.split(/\r?\n/).filter(line => /^\s*at\s/.test(line)); + result.frames = lines.slice(0, MAX_FRAMES).map(line => frameLocation(line, roots, sensitive)); + if (lines.length > MAX_FRAMES || stack.length > MAX_TEXT_BYTES || bytes.length > MAX_TEXT_BYTES) result.truncated = true; + } + seen.add(value); + const cause = data(value, 'cause'); + if (cause !== undefined) { + if (depth < MAX_CAUSE_DEPTH && !seen.has(cause)) result.causes = [build(cause, depth + 1)]; + else result.truncated = true; + } + return result; + }; + const diagnostic = build(error, 0); + const nodes = [diagnostic, ...(diagnostic.causes ?? []), ...(diagnostic.causes?.[0]?.causes ?? [])]; + while (Buffer.byteLength(JSON.stringify(diagnostic)) > MAX_BYTES) { + diagnostic.truncated = true; + const removable = [...nodes].reverse().find(node => node.frames?.length); + if (removable?.frames) removable.frames.pop(); + else { delete diagnostic.causes; break; } + } + return diagnostic; +} diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts index 2ed7eed2..c363c203 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -1,6 +1,8 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; +import { realpathSync } from 'node:fs'; import path from 'node:path'; import { logger } from '../../utils/logger'; +import { buildPiInitializationDiagnostic } from './initialization-diagnostic'; import type { ModelSummary, ProviderAccount } from '../../shared/providers/types'; import { validateSessionKey } from '../../coding-projects/conversation-store'; import { @@ -233,9 +235,18 @@ export function createPiManagedWorkerOpener( const createProcess = options.createProcess ?? ((processOptions) => new PiWorkerProcess(processOptions)); const createSessionKey = options.createSessionKey ?? randomUUID; const now = options.now ?? Date.now; - return async (input) => { + const openWorker = async ( + input: PiWorkerOpenInput, + observation: { + stage(value: string): void; + sensitive(...values: Array): void; + failed(error: unknown): void; + }, + ): Promise => { const resourcesStartedAt = now(); const registered = await options.registry.prepare(input.conversation); + observation.sensitive(registered.projectPath, registered.agent.prompt, registered.agent.name, + registered.agent.roleName, registered.conversation.title); const existingSession = input.existingSession ?? registered.session ?? undefined; const model = registered.conversation.model; if (!model || registered.conversation.modelResolution !== 'resolved') { @@ -245,7 +256,9 @@ export function createPiManagedWorkerOpener( true, ); } + observation.stage('provider-input'); const providerInput = await options.loadProviderInput(); + observation.stage('provider-catalog'); const catalog = buildPiProviderCatalog(providerInput); const selection = selectPiProviderModel(catalog, model); const account = providerInput.accounts.find((candidate) => candidate.id === selection.accountId); @@ -253,6 +266,7 @@ export function createPiManagedWorkerOpener( if (!account || !descriptor) throw new Error('Selected Provider account is unavailable'); const managedPaths = await ensurePiManagedPaths(options.userDataDir); await writePiProviderCatalog(managedPaths.modelsFile, catalog, model); + observation.stage('worker-resources'); const workerResources = options.capabilityRegistry ? await options.capabilityRegistry.resolveWorkerResources({ projectPath: registered.projectPath, @@ -261,6 +275,7 @@ export function createPiManagedWorkerOpener( role: 'parent', }) : fallbackWorkerResources(registered.agent.skillIds); + observation.stage('device-resources'); const deviceResources = options.devicePackageManager ? await options.devicePackageManager.resolveEnabledResources() : undefined; @@ -268,6 +283,7 @@ export function createPiManagedWorkerOpener( ...workerResources.skillEntries, ...(deviceResources?.skillEntries ?? []), ]; + observation.stage('resource-materialization'); const resources = await materializePiAgentResources({ userDataDir: options.userDataDir, projectId: input.conversation.projectId, @@ -290,6 +306,8 @@ export function createPiManagedWorkerOpener( : {}), revision: input.revision, }); + observation.sensitive(resources.paths.configDir, resources.projectSessionsDir, managedPaths.rootDir); + observation.stage('provider-credentials'); const credential = await buildPiWorkerCredentialProjection({ account, descriptor, @@ -298,6 +316,8 @@ export function createPiManagedWorkerOpener( ? { localProxyCredential: await options.getLocalProxyCredential() } : {}), }); + observation.sensitive(...credential.sensitiveValues); + observation.stage('extension-registration'); const modelTools = options.modelToolRegistry?.registerWorker({ conversationId: input.conversation.conversationId, generation: input.generation, @@ -335,6 +355,7 @@ export function createPiManagedWorkerOpener( extensionsDir: managedPaths.extensionsDir, }); } catch (error) { + observation.failed(error); modelTools?.dispose(); throw error; } @@ -346,12 +367,15 @@ export function createPiManagedWorkerOpener( now(), !existingSession, ); + observation.sensitive(extension.extensionPath, ...extension.sensitiveValues); + observation.stage('session-bind'); const sessionKey = validateSessionKey( existingSession?.sessionKey ?? createSessionKey(), ); if (existingSession && input.fork) { throw new Error('Pi worker cannot reopen and fork a session at the same time'); } + observation.stage('worker-start'); const process = createProcess({ executablePath: options.executablePath, cliPath: options.cliPath, @@ -417,6 +441,7 @@ export function createPiManagedWorkerOpener( if (input.fork?.sourceEntryId) { await process.request({ type: 'fork', entryId: input.fork.sourceEntryId }); } + observation.stage('initial-hydration'); const readyStartedAt = now(); const response = await process.request( { type: 'get_state' }, @@ -451,6 +476,7 @@ export function createPiManagedWorkerOpener( ); } } + observation.stage('session-bind'); const sessionStartedAt = now(); const bound = await options.registry.ensureBinding(input.conversation, async () => ({ piSessionId, @@ -485,12 +511,57 @@ export function createPiManagedWorkerOpener( session: clone(bound.session), }; } catch (error) { + observation.failed(error); unsubscribeExtensionInvalidation(); await process.stop('open_failure').catch(() => undefined); await disposeManagedResources(); throw error; } }; + return async (input) => { + let stage = 'registry'; + let failureObserved = false; + const sensitiveValues = [input.conversation.title, options.userDataDir, + options.executablePath, options.cliPath, options.bundledSkillsDir]; + const observation = { + stage: (value: string) => { stage = value; }, + sensitive: (...values: Array) => { + sensitiveValues.push(...values.filter((value): value is string => typeof value === 'string')); + }, + failed: (error: unknown) => { + if (failureObserved) return; + failureObserved = true; + try { + const piPackageRoot = path.resolve(path.dirname(options.cliPath), '..'); + const trustedRoots = [piPackageRoot, path.resolve(__dirname, '..', '..'), options.bundledSkillsDir]; + try { + // pnpm package stacks use the real path behind the bundled dependency link. + const resolvedPiRoot = realpathSync.native(piPackageRoot); + if (!trustedRoots.includes(resolvedPiRoot)) trustedRoots.push(resolvedPiRoot); + } catch { + // Missing resources must still produce a diagnostic using known roots. + } + logger.warn('[PiWorkerInitializationFailed]', { + conversationRef: createHash('sha256').update(input.conversation.conversationId).digest('hex').slice(0, 16), + workerGeneration: Number.isSafeInteger(input.generation) ? input.generation : 0, + diagnostic: buildPiInitializationDiagnostic(error, { + stage, + sensitiveValues, + trustedRoots, + }), + }); + } catch { + // Diagnostic construction/output must never change initialization or cleanup. + } + }, + }; + try { + return await openWorker(input, observation); + } catch (error) { + observation.failed(error); + throw error; + } + }; } function recordManagedMilestone( diff --git a/tests/unit/pi-diagnostic-stack-safety.test.ts b/tests/unit/pi-diagnostic-stack-safety.test.ts new file mode 100644 index 00000000..9c34a04e --- /dev/null +++ b/tests/unit/pi-diagnostic-stack-safety.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment node + +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// Isolate global Error hooks from Vitest. Load the actual formatter source; +// the sanitizer is unreachable here because lazy stack frames are omitted. +const probe = ` +import { readFileSync } from 'node:fs'; +import { registerHooks, stripTypeScriptTypes } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +const sourceUrl = pathToFileURL(process.argv[1]).href; +registerHooks({ + resolve(specifier, context, next) { + if (specifier === './worker-process' && context.parentURL === sourceUrl) + return { url: 'diagnostic-fixture:sanitizer', shortCircuit: true }; + return next(specifier, context); + }, + load(url, context, next) { + if (url === 'diagnostic-fixture:sanitizer') return { format: 'module', + source: 'export function sanitizePiDiagnostic() { throw new Error("Unexpected frame processing"); }', shortCircuit: true }; + if (url === sourceUrl) return { format: 'module', + source: stripTypeScriptTypes(readFileSync(fileURLToPath(url), 'utf8')), shortCircuit: true }; + return next(url, context); + }, +}); +let calls = 0; +if (process.argv[2] === 'preinstalled') { + Object.defineProperty(Error, 'prepareStackTrace', { configurable: true, + value() { calls++; return 'private custom formatter result'; } }); +} else { + Reflect.deleteProperty(Error, 'prepareStackTrace'); +} +const { buildPiInitializationDiagnostic } = await import(sourceUrl); +if (process.argv[2] === 'inherited') { + Object.defineProperty(Function.prototype, 'prepareStackTrace', { configurable: true, + get() { calls++; return () => { calls++; return 'private inherited formatter result'; }; } }); +} +const error = new Error('private message'); +const prior = Object.getOwnPropertyDescriptor(error, 'stack'); +calls = 0; +const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'worker-start' }); +const after = Object.getOwnPropertyDescriptor(error, 'stack'); +process.stdout.write(JSON.stringify({ calls, diagnostic, + stackUnchanged: prior?.get === after?.get && prior?.value === after?.value })); +`; + +describe('initialization diagnostic isolated native stack safety', () => { + it.each(['preinstalled', 'inherited'])('does not execute %s global formatting hooks or modify the error', mode => { + const result = JSON.parse(execFileSync(process.execPath, ['--input-type=module', '-e', probe, + path.resolve('electron/coding-runtime/pi/initialization-diagnostic.ts'), mode], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5_000 }).trim()); + expect(result.calls).toBe(0); + expect(result.stackUnchanged).toBe(true); + expect(result.diagnostic).toMatchObject({ stage: 'worker-start', exception: 'Error' }); + expect(result.diagnostic.frames).toBeUndefined(); + expect(JSON.stringify(result.diagnostic)).not.toContain('private'); + }); +}); diff --git a/tests/unit/pi-initialization-diagnostic.test.ts b/tests/unit/pi-initialization-diagnostic.test.ts new file mode 100644 index 00000000..4780a033 --- /dev/null +++ b/tests/unit/pi-initialization-diagnostic.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from 'vitest'; +import { buildPiInitializationDiagnostic } from '../../electron/coding-runtime/pi/initialization-diagnostic'; + +const root = '/private/bundled-app/resources'; +const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJwcml2YXRlIn0.private-signature'; +const credential = 'private-key-S3cr3t-12345'; + +function withStack(error: T, stack: string): T { + Object.defineProperty(error, 'stack', { configurable: true, writable: true, value: stack }); + return error; +} + +describe('Pi initialization diagnostic privacy and technical evidence', () => { + it('retains the technical class and trusted source location without the exception text or absolute root', () => { + const error = withStack(Object.assign(new TypeError(`Cannot read ${credential} from https://private.example/api`), + { code: 'ERR_INVALID_ARG_TYPE' }), [ + `TypeError: ${credential}`, + ` at secretFunctionName (${root}/pi/runtime.ts:253:20)`, + ' at processTicksAndRejections (node:internal/process/task_queues:95:5)', + ' at execute (/Users/private-user/project/private-module.ts:8:1)', + ].join('\n')); + const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'worker-start', trustedRoots: [root], sensitiveValues: [credential] }); + expect(diagnostic).toMatchObject({ + version: 1, stage: 'worker-start', exception: 'TypeError', code: 'ERR_INVALID_ARG_TYPE', + category: 'runtime-contract', frames: ['root0/pi/runtime.ts:253:20', 'node:internal/process/task_queues:95:5', '[REDACTED_FRAME]'], + }); + expect(diagnostic.fingerprint).toMatch(/^[a-f0-9]{16}$/); + const serialized = JSON.stringify(diagnostic); + for (const privateValue of [credential, 'Cannot read', 'secretFunctionName', root, 'private-user', 'https://private.example/api']) + expect(serialized).not.toContain(privateValue); + }); + + it('classifies a failed worker with its filesystem cause while ignoring arbitrary custom error fields', () => { + const cause = Object.assign(new Error(`open /Users/alice/${credential}.json`), { code: 'ENOENT', path: `/Users/alice/${credential}.json` }); + const error = Object.assign(new Error(`worker failed: ${jwt}`, { cause }), { + name: 'PiProcessError', code: 'PI_WORKER_START_FAILED', diagnostic: `authorization: Bearer ${credential}`, token: credential, + }); + const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'worker-start' }); + expect(diagnostic).toMatchObject({ + exception: 'PiProcessError', code: 'PI_WORKER_START_FAILED', category: 'worker-lifecycle', + causes: [{ exception: 'Error', code: 'ENOENT', category: 'local-storage' }], + }); + const serialized = JSON.stringify(diagnostic); + for (const value of [credential, jwt, '/Users/alice', 'authorization', 'token']) expect(serialized).not.toContain(value); + }); + + it('never records free-form headers, quoted JSON, JWTs, URLs or private paths', () => { + const input = [ + `Authorization: "Bearer ${credential}"`, `{"api_key":"${credential}","prompt":"用户的私密问题"}`, + jwt, 'https://user:password@example.test/private?q=secret', '/Users/alice/private-folder/a.txt', + 'C:\\Users\\Alice\\private-folder\\a.txt', 'file:///Users/alice/private-folder/a.txt', '../private-folder/a.txt', + ].join('\n'); + const diagnostic = buildPiInitializationDiagnostic(new Error(input), { stage: input }); + const serialized = JSON.stringify(diagnostic); + expect(diagnostic.stage).toBe('unknown'); + expect(diagnostic.fingerprint).toMatch(/^[a-f0-9]{16}$/); + for (const value of [credential, jwt, '用户的私密问题', 'private-folder', 'example.test', 'Authorization', 'api_key', 'password']) + expect(serialized).not.toContain(value); + }); + + it('redacts literal and encoded sensitive values even inside trusted stack locations', () => { + const privateName = '私密配置-Alpha42'; + const encodings = [ + privateName, encodeURIComponent(privateName), encodeURIComponent(encodeURIComponent(privateName)), + Buffer.from(privateName).toString('base64url'), + ]; + const error = withStack(new Error(privateName), encodings.map(value => ` at fn (${root}/${value}.ts:11:2)`).join('\n')); + const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'resource-materialization', trustedRoots: [root], sensitiveValues: [privateName] }); + expect(diagnostic.frames).toEqual(encodings.map(() => '[REDACTED_FRAME]')); + const serialized = JSON.stringify(diagnostic); + for (const value of encodings) expect(serialized).not.toContain(value); + }); + + it('keeps file-URL and Windows bundled code locations relative without logging their parent names', () => { + const error = withStack(new Error('private text'), [ + ' at load (file:///Users/private-owner/My%20App/resources/pi/loader.mjs:42:9)', + ' at load (C:\\Users\\private-owner\\App\\resources\\pi\\loader.cjs:48:2)', + ' at load (file://remote-host/private/a.js:8:1)', + ' at load (./private/a.js:8:1)', + ' at load (/Users/private-owner/My App/resources/pi/../secret.js:8:1)', + ].join('\n')); + const diagnostic = buildPiInitializationDiagnostic(error, { + stage: 'worker-resources', trustedRoots: ['/Users/private-owner/My App/resources', 'C:\\Users\\private-owner\\App\\resources'], + }); + expect(diagnostic.frames).toEqual(['root0/pi/loader.mjs:42:9', 'root1/pi/loader.cjs:48:2', '[REDACTED_FRAME]', '[REDACTED_FRAME]', '[REDACTED_FRAME]']); + const serialized = JSON.stringify(diagnostic); + for (const value of ['private-owner', 'Users', 'file:', 'remote-host', 'My%20App', 'C:']) expect(serialized).not.toContain(value); + }); + + it('rejects forged exception names and non-whitelisted codes without falling back to their raw values', () => { + const error = { name: credential, code: 'ERR_PRIVATE_API_KEY_' + credential, message: credential, publicError: { code: credential, message: jwt } }; + const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'provider-catalog' }); + expect(diagnostic.exception).toBe('UnknownError'); + expect(diagnostic.code).toBeUndefined(); + expect(diagnostic.category).toBe('unclassified-initialization'); + expect(JSON.stringify(diagnostic)).not.toContain(credential); + expect(JSON.stringify(diagnostic)).not.toContain(jwt); + }); + + it('does not execute error getters, coercion hooks or lazy stack formatting through hostile fields', () => { + const accessed: string[] = []; + const error = new Error('ordinary placeholder'); + for (const key of ['name', 'message', 'stack', 'code', 'cause', 'publicError']) { + Object.defineProperty(error, key, { configurable: true, get() { accessed.push(key); throw new Error(credential); } }); + } + Object.assign(error, { + toString() { accessed.push('toString'); return credential; }, + [Symbol.toPrimitive]() { accessed.push('toPrimitive'); return credential; }, + }); + const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'snapshot' }); + expect(accessed).toEqual([]); + expect(diagnostic).toMatchObject({ stage: 'snapshot', exception: 'UnknownError' }); + expect(JSON.stringify(diagnostic)).not.toContain(credential); + }); + + it('survives proxies which throw during structural inspection', () => { + const error = new Proxy({}, { + get() { throw new Error(credential); }, + getOwnPropertyDescriptor() { throw new Error(credential); }, + getPrototypeOf() { throw new Error(credential); }, + }); + expect(() => buildPiInitializationDiagnostic(error, { stage: 'registry' })).not.toThrow(); + expect(JSON.stringify(buildPiInitializationDiagnostic(error, { stage: 'registry' }))).not.toContain(credential); + }); + + it('does not invoke inherited formatting getters or a replaced global stack formatter', () => { + let calls = 0; + const prototype = Object.create(Error.prototype); + Object.defineProperty(prototype, 'name', { get() { calls++; return credential; } }); + const inherited = new Error('placeholder'); + Object.setPrototypeOf(inherited, prototype); + expect(buildPiInitializationDiagnostic(inherited, { stage: 'snapshot' }).frames).toBeUndefined(); + const prior = Object.getOwnPropertyDescriptor(Error, 'prepareStackTrace'); + try { + Object.defineProperty(Error, 'prepareStackTrace', { configurable: true, value() { calls++; return credential; } }); + const diagnostic = buildPiInitializationDiagnostic(new Error('placeholder'), { stage: 'snapshot' }); + expect(diagnostic.frames).toBeUndefined(); + expect(JSON.stringify(diagnostic)).not.toContain(credential); + expect(calls).toBe(0); + } finally { + if (prior) Object.defineProperty(Error, 'prepareStackTrace', prior); + else Reflect.deleteProperty(Error, 'prepareStackTrace'); + } + }); + + it('bounds UTF-8 output, stack frames and cause chains and terminates cause cycles', () => { + const make = () => withStack(new Error('长'.repeat(40_000)), + Array.from({ length: 50 }, (_, index) => ` at initialize (${root}/pi/${'a'.repeat(120)}${index}.ts:999999:123)`).join('\n')); + const error = make(); + const cause = make(); + const third = make(); + Object.assign(error, { cause }); + Object.assign(cause, { cause: third }); + Object.assign(third, { cause: error }); + const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'initial-hydration', trustedRoots: [root] }); + expect(Buffer.byteLength(JSON.stringify(diagnostic))).toBeLessThanOrEqual(4_096); + expect(diagnostic.truncated).toBe(true); + let current = diagnostic; + for (let depth = 0; depth < 3; depth++) { + expect(current.frames?.length ?? 0).toBeLessThanOrEqual(6); + if (!current.causes?.[0]) break; + expect(depth).toBeLessThan(2); + current = current.causes[0]; + } + expect(JSON.stringify(diagnostic)).not.toContain('长'); + }); + + it('projects known public contract codes and primitive failures without error content', () => { + const diagnostic = buildPiInitializationDiagnostic({ publicError: { code: 'CODING_MIGRATION_MODEL_REQUIRED', message: credential } }, { stage: 'provider-input' }); + expect(diagnostic).toMatchObject({ code: 'CODING_MIGRATION_MODEL_REQUIRED', category: 'provider-configuration' }); + expect(JSON.stringify(diagnostic)).not.toContain(credential); + expect(buildPiInitializationDiagnostic(credential, { stage: 'prepare' })).toMatchObject({ exception: 'UnknownError', fingerprint: expect.stringMatching(/^[a-f0-9]{16}$/) }); + }); + + it('locates a wrapped thread_open SDK failure without turning its private property into a logged field', () => { + const error = Object.assign(new Error(`Pi RPC thread_open failed: Cannot read properties of undefined (reading '${credential}')`), { + name: 'PiProcessError', code: 'PI_RPC_RESPONSE_ERROR', + }); + const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'worker-start' }); + expect(diagnostic).toMatchObject({ + exception: 'PiProcessError', code: 'PI_RPC_RESPONSE_ERROR', category: 'worker-protocol', + rpcOperation: 'thread_open', rpcFailureSignature: 'undefined-property', + }); + expect(JSON.stringify(diagnostic)).not.toContain(credential); + expect(JSON.stringify(diagnostic)).not.toContain('Cannot read properties'); + }); + + it.each([ + { tail: `TypeError: Cannot read properties of null (reading '${credential}')`, signature: 'null-property' }, + { tail: `${credential}.request is not a function`, signature: 'not-callable' }, + { tail: `Cannot find module '/Users/private-user/${credential}.js'\nRequire stack:\n- /Users/private-user/${jwt}.js`, signature: 'module-not-found' }, + { tail: `Cannot find package '${credential}' imported from https://private.example/${jwt}`, signature: 'module-not-found' }, + { tail: `ENOENT: no such file or directory, open 'C:\\Users\\private-user\\${credential}.json'`, signature: 'missing-local-resource' }, + ])('projects only a fixed signature from private RPC content: $signature', ({ tail, signature }) => { + const error = Object.assign(new Error(`Pi RPC get_state failed: ${tail}`), { name: 'PiProcessError', code: 'PI_RPC_RESPONSE_ERROR' }); + const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'initial-hydration' }); + expect(diagnostic).toMatchObject({ rpcOperation: 'get_state', rpcFailureSignature: signature, exception: 'PiProcessError' }); + const serialized = JSON.stringify(diagnostic); + for (const value of [credential, jwt, 'private-user', 'private.example', 'C:', 'Users', '.request']) expect(serialized).not.toContain(value); + }); + + it('does not interpret user text, unknown commands, embedded wrappers or unknown response patterns as RPC signatures', () => { + const wrapped = `Pi RPC thread_open failed: Cannot read properties of undefined (reading '${credential}')`; + for (const error of [ + new Error(wrapped), + Object.assign(new Error('private prefix\n' + wrapped), { code: 'PI_RPC_RESPONSE_ERROR' }), + Object.assign(new Error(`Pi RPC ${credential} failed: ${wrapped}`), { code: 'PI_RPC_RESPONSE_ERROR' }), + { publicError: { code: 'PI_RPC_RESPONSE_ERROR' }, message: wrapped }, + ]) { + const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'worker-start' }); + expect(diagnostic.rpcOperation).toBeUndefined(); + expect(diagnostic.rpcFailureSignature).toBeUndefined(); + expect(JSON.stringify(diagnostic)).not.toContain(credential); + } + const unknown = Object.assign(new Error(`Pi RPC thread_open failed: Authorization: Bearer ${credential} ${jwt}`), { code: 'PI_RPC_RESPONSE_ERROR' }); + const diagnostic = buildPiInitializationDiagnostic(unknown, { stage: 'worker-start' }); + expect(diagnostic.rpcOperation).toBe('thread_open'); + expect(diagnostic.rpcFailureSignature).toBeUndefined(); + expect(JSON.stringify(diagnostic)).not.toContain(credential); + expect(JSON.stringify(diagnostic)).not.toContain(jwt); + }); + + it('rejects trailing additions and overlong UTF-8 response tails while retaining only a recognized operation', () => { + for (const tail of [ + `Cannot read properties of undefined (reading '${credential}')\n${jwt}`, + `${credential}${'a'.repeat(4_096)} is not a function`, + `${'私'.repeat(1_000)} is not a function`, + ]) { + const error = Object.assign(new Error(`Pi RPC thread_open failed: ${tail}`), { code: 'PI_RPC_RESPONSE_ERROR' }); + const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'worker-start' }); + expect(diagnostic.rpcOperation).toBe('thread_open'); + expect(diagnostic.rpcFailureSignature).toBeUndefined(); + expect(Buffer.byteLength(JSON.stringify(diagnostic))).toBeLessThanOrEqual(4_096); + for (const value of [credential, jwt, '私']) expect(JSON.stringify(diagnostic)).not.toContain(value); + } + }); +}); diff --git a/tests/unit/pi-initialization-failure-logging.test.ts b/tests/unit/pi-initialization-failure-logging.test.ts new file mode 100644 index 00000000..abcdb19d --- /dev/null +++ b/tests/unit/pi-initialization-failure-logging.test.ts @@ -0,0 +1,159 @@ +// @vitest-environment node + +import { mkdtemp, mkdir, realpath, rm, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { logger } from '../../electron/utils/logger'; +import { createPiManagedWorkerOpener, type PiWorkerProcessAdapter } from '../../electron/coding-runtime/pi/runtime'; +import type { PiSessionRegistry } from '../../electron/coding-runtime/pi/session-registry'; +import type { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host'; +import type { ModelToolRegistryPort } from '../../electron/coding-runtime/pi/model-tools/model-tool-registry'; +import type { PiWorkerProcessOptions } from '../../electron/coding-runtime/pi/worker-process'; + +const roots: string[] = []; +const TITLE = 'private conversation title'; +const PROMPT = 'private project instructions'; +const SECRET = 'private-provider-credential'; + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); +}); + +async function fixture() { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-init-diagnostic-')); + roots.push(root); + const projectPath = path.join(root, 'private-project'); + await mkdir(projectPath); + const model = { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'off' as const }; + const input = { + conversation: { conversationId: 'conversation-a', projectId: 'project-a', agentId: 'agent-a', title: TITLE, + model: { model, modelResolution: 'resolved' as const } }, + generation: 7, revision: { provider: 1, resources: 1 }, + }; + const registry = { + prepare: vi.fn(async () => ({ projectPath, + conversation: { model, modelResolution: 'resolved', title: TITLE }, + agent: { id: 'agent-a', name: 'private agent name', roleName: 'private role', prompt: PROMPT, skillIds: [] } })), + ensureBinding: vi.fn(async () => ({ session: { piSessionId: 'session-a', sessionKey: 'session-a' } })), + }; + const dispose = vi.fn(async () => undefined); + const extensionHost = { registerWorker: vi.fn(async () => ({ + allowedToolNames: [], extensionPath: path.join(root, 'extension.mjs'), + env: { PRIVATE_EXTENSION_SECRET: SECRET }, sensitiveValues: [SECRET], dispose, + })) }; + const process = { + generation: 7, start: vi.fn(async () => undefined), + request: vi.fn(async () => ({ type: 'response', success: true, data: { sessionId: 'session-a' } })), + send: vi.fn(async () => undefined), subscribe: vi.fn(() => () => undefined), + subscribeInvalidation: vi.fn(() => () => undefined), + stop: vi.fn(async () => ({ mode: 'stdin-close' as const, code: 0, signal: null })), + }; + const loadProviderInput = vi.fn(async () => ({ accounts: [{ + id: 'account-a', vendorId: 'custom', label: 'fixture', authMode: 'api_key' as const, + apiProtocol: 'openai-completions' as const, baseUrl: 'https://provider.example/v1', model: 'model-a', + enabled: true, isDefault: true, createdAt: '2026-10-14T00:00:00Z', updatedAt: '2026-10-14T00:00:00Z', + }], modelSummaries: [] })); + const createProcess = vi.fn((_options: PiWorkerProcessOptions) => process as unknown as PiWorkerProcessAdapter); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + const options = { registry: registry as unknown as PiSessionRegistry, + executablePath: 'electron', cliPath: 'pi-cli.js', userDataDir: path.join(root, 'user-data'), + bundledSkillsDir: path.resolve('resources/coding-skills'), + extensionHost: extensionHost as unknown as PiManagedExtensionHost, + loadProviderInput, resolveCredential: async () => SECRET, + createSessionKey: () => 'session-a', createProcess }; + const failures = () => warn.mock.calls.filter(([tag]) => tag === '[PiWorkerInitializationFailed]'); + return { root, input, registry, extensionHost, dispose, process, loadProviderInput, createProcess, warn, failures, options }; +} + +describe('managed worker initialization failure logs', () => { + it('records an early registry failure once without changing its public error or logging private input', async () => { + const f = await fixture(); + const error = Object.assign(new Error(`prompt=${PROMPT} token=${SECRET} path=${f.root}`), { + publicError: { code: 'CODING_SESSION_UNREADABLE', message: 'Safe public message' }, + }); + f.registry.prepare.mockRejectedValue(error); + await expect(createPiManagedWorkerOpener(f.options)(f.input)).rejects.toBe(error); + expect(error.publicError).toEqual({ code: 'CODING_SESSION_UNREADABLE', message: 'Safe public message' }); + expect(f.loadProviderInput).not.toHaveBeenCalled(); + expect(f.createProcess).not.toHaveBeenCalled(); + expect(f.failures()).toHaveLength(1); + expect(f.failures()[0][1]).toMatchObject({ workerGeneration: 7, diagnostic: { stage: 'registry', exception: 'Error' } }); + const log = JSON.stringify(f.failures()); + for (const privateValue of [TITLE, PROMPT, SECRET, f.root, 'conversation-a']) expect(log).not.toContain(privateValue); + }); + + it('captures provider preparation failure before any worker exists', async () => { + const f = await fixture(); + const error = new TypeError('Cannot read properties of undefined (reading "providers")'); + f.loadProviderInput.mockRejectedValue(error); + await expect(createPiManagedWorkerOpener(f.options)(f.input)).rejects.toBe(error); + expect(f.createProcess).not.toHaveBeenCalled(); + expect(f.failures()).toHaveLength(1); + expect(f.failures()[0][1]).toMatchObject({ diagnostic: { stage: 'provider-input', exception: 'TypeError' } }); + }); + + it('retains code locations behind a dependency symlink without exposing its absolute path', async () => { + const f = await fixture(); + const packageRoot = path.join(f.root, 'private-install', 'pi-package'); + const packageLink = path.join(f.root, 'bundled-pi'); + await mkdir(path.join(packageRoot, 'dist'), { recursive: true }); + await symlink(packageRoot, packageLink, 'dir'); + const actualPackageRoot = await realpath(packageRoot); + const error = new Error(SECRET); + Object.defineProperty(error, 'stack', { + configurable: true, value: `Error: ${SECRET}\n at load (${actualPackageRoot}/dist/session.js:22:4)`, + }); + f.registry.prepare.mockRejectedValue(error); + await expect(createPiManagedWorkerOpener({ ...f.options, + cliPath: path.join(packageLink, 'dist', 'cli.js') })(f.input)).rejects.toBe(error); + expect(f.failures()).toHaveLength(1); + expect(f.failures()[0][1]).toMatchObject({ diagnostic: { frames: ['root3/dist/session.js:22:4'] } }); + const log = JSON.stringify(f.failures()); + for (const privateValue of [SECRET, f.root, packageRoot, actualPackageRoot, packageLink]) expect(log).not.toContain(privateValue); + }); + + it('logs initial state failure before cleanup, preserving the rejection and cleanup calls', async () => { + const f = await fixture(); + const order: string[] = []; + const error = new TypeError(`secret=${SECRET} ${PROMPT} ${f.root}`); + f.warn.mockImplementation(() => { order.push('log'); }); + f.process.request.mockRejectedValue(error); + f.process.stop.mockImplementation(async () => { order.push('stop'); return { mode: 'stdin-close', code: 0, signal: null }; }); + f.dispose.mockImplementation(async () => { order.push('dispose'); }); + await expect(createPiManagedWorkerOpener(f.options)(f.input)).rejects.toBe(error); + expect(f.process.start).toHaveBeenCalledOnce(); + expect(f.process.stop).toHaveBeenCalledWith('open_failure'); + expect(f.dispose).toHaveBeenCalledOnce(); + expect(order).toEqual(['log', 'stop', 'dispose']); + expect(f.failures()).toHaveLength(1); + expect(f.failures()[0][1]).toMatchObject({ diagnostic: { stage: 'initial-hydration', exception: 'TypeError' } }); + const log = JSON.stringify(f.failures()); + for (const privateValue of [TITLE, PROMPT, SECRET, f.root]) expect(log).not.toContain(privateValue); + }); + + it('retains the original diagnostic when extension cleanup masks an initialization error', async () => { + const f = await fixture(); + const original = new TypeError('Required extension function is not a function'); + const cleanup = new RangeError('cleanup failure'); + const modelDispose = vi.fn(() => { throw cleanup; }); + f.extensionHost.registerWorker.mockRejectedValue(original); + const opener = createPiManagedWorkerOpener({ ...f.options, + modelToolRegistry: { registerWorker: () => ({ tools: [], dispose: modelDispose }) } as unknown as ModelToolRegistryPort }); + await expect(opener(f.input)).rejects.toBe(cleanup); + expect(modelDispose).toHaveBeenCalledOnce(); + expect(f.failures()).toHaveLength(1); + expect(f.failures()[0][1]).toMatchObject({ diagnostic: { stage: 'extension-registration', exception: 'TypeError' } }); + expect(f.createProcess).not.toHaveBeenCalled(); + }); + + it('keeps the original initialization error even if diagnostic output throws', async () => { + const f = await fixture(); + const original = new Error('original failure'); + f.registry.prepare.mockRejectedValue(original); + f.warn.mockImplementation(() => { throw new Error('logger failed'); }); + await expect(createPiManagedWorkerOpener(f.options)(f.input)).rejects.toBe(original); + expect(f.failures()).toHaveLength(1); + }); +}); From d3b0d411ca32b7f8ea72c87dcfe37c02e6692b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 14:57:30 +0800 Subject: [PATCH 10/18] Distinguish home and parent navigation icons --- .../20261014-navigation-icon-93c4a6e8.md | 53 +++++++++++++++++++ README.md | 4 +- src/components/layout/ModuleSwitcher.tsx | 6 +-- src/pages/CloudAgents/DraftEditor.tsx | 2 +- src/pages/Plugins/index.tsx | 2 +- src/pages/ProjectConfiguration/index.tsx | 4 +- src/pages/Settings/index.tsx | 2 +- tests/e2e/cloud-agent-home.spec.ts | 4 ++ tests/e2e/main-navigation.spec.ts | 3 ++ tests/unit/module-navigation.test.tsx | 2 +- 10 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261014-navigation-icon-93c4a6e8.md diff --git a/.project-docs/30-worklog/tasks/20261014-navigation-icon-93c4a6e8.md b/.project-docs/30-worklog/tasks/20261014-navigation-icon-93c4a6e8.md new file mode 100644 index 00000000..5068b3d3 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-navigation-icon-93c4a6e8.md @@ -0,0 +1,53 @@ +# Task: Distinguish home and parent navigation in Agents and Code + +## Identity + +- Task ID: 20261014-navigation-icon-93c4a6e8 +- Mode: Feature +- Branch: codex/20261014-navigation-icon-93c4a6e8-navigation-icon +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-navigation-icon-93c4a6e8 +- Base commit: af639267198d908e43bcb0bd243e86be2a533427 +- Owner: codex-root +- Status: In Progress + +## Scope + +- Distinguish global home from contextual back navigation in Agents and Code with the existing Lucide icon set. Global module return uses a house and “返回首页”; local configuration, chat, settings and plugin return arrows use hover text “返回上一级”. +- Update the current temporary Renderer while retaining its previously prepared WeChat card-name edit. + +## Intent And Constraints + +- Explicit user decision: icons only; no visible destination text added. Existing routes, accessible destination labels, dimensions, keyboard handling and unsaved/pending operation guards remain intact. +- Keep Main PID 35142, Host API port 58003, bundle, bootstrap and profile; no cloud mutations or profile/credential reads. No canonical documentation writes, main merge, push or deployment. + +## Project Context Loaded + +- Task ID: 20261014-navigation-icon-93c4a6e8; mode Feature; matching branch and absolute worktree above. Ownership start/status passed; base af639267 is the documentation successor of tested WeChat-name product f1dabdb. +- Read: read-before-planning, memory-index, own task record, project-positioning, current-state, decision-index, system-overview, business-rules, success-criteria, personal-cloud-agents ADR, community-collaboration, commitments and stale-items. Required context was reviewed by root and the read-only coordination agent. +- Project goal: project-centered creation with Main-owned authenticated APIs; Chinese light visual system. Integrated snapshot predates current trial and several accepted UI changes; runtime/source receipts take precedence for actual delivery state. +- Other active local tasks: 79 peer records / 237 scope, intent and promotion sections read. Sidebar/layout/Code configuration peers overlap paths but preserve global return and contextual navigation; no semantic conflict. Child-language-skill placeholder has unknown scope but is not a navigation dependency. No foreign source changes adopted. +- Decisions: retain manual Agent sidebar control and original navigation protection; current user explicitly chooses house/home and arrow/parent icons plus exact hover text. No architecture or identity changes. +- Likely files: ModuleSwitcher, DraftEditor, ProjectConfiguration, Settings, Plugins and existing navigation fixtures. +- Gate result: Passed. Root planning uses isolated owned source, never the product snapshots or peer worktrees. + +## Plan + +- Change house icon and local arrow hover titles, retain handlers and accessible target labels. +- Update focused existing navigation assertions; run relevant checks and default full Vite build. +- Prepare immutable file-only Renderer delivery preserving pending name edit, refresh and inspect actual UI before marking delivery verified. + +## Outcome + +- Not completed. + +## Verification + +- Not run. + +## Follow-ups + +- None recorded. + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index 93ae0a51..050be0e0 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - macOS 窗口按钮:红色关闭当前窗口,全屏时先退出全屏再关闭;黄色最小化,绿色保留原生缩放/全屏行为。关窗后应用和进行中的后台任务继续运行,点击 Dock、托盘或菜单可重新打开窗口;`Cmd+Q` 退出应用。 - 桌面文本右键菜单:主 Renderer 窗口由 Electron Main 提供原生“剪切、复制、粘贴、全选”操作;菜单只在可编辑区域或已有文本选择时出现,普通空白区域不显示,也不向 Renderer 暴露剪贴板 IPC。 - AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。 -- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。左侧不再展示麦洛品牌与说明语,“新建项目”置于原品牌区,“我的项目”与列表上移到原新建项目区域;“作品广场”与“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影,“作品广场”移至底部、紧邻账户按钮上方;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;从项目菜单进入的配置页及插件资源页复用相同的左侧工作栏,返回与收起按钮、项目和会话列表、底部作品广场与账户的位置保持一致,并沿用当前宽度和收起状态;配置正文、顶部项目名称与配置页自身的返回操作保留。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 +- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部房子图标与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。左侧不再展示麦洛品牌与说明语,“新建项目”置于原品牌区,“我的项目”与列表上移到原新建项目区域;“作品广场”与“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影,“作品广场”移至底部、紧邻账户按钮上方;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;从项目菜单进入的配置页及插件资源页复用相同的左侧工作栏,返回与收起按钮、项目和会话列表、底部作品广场与账户的位置保持一致,并沿用当前宽度和收起状态;配置正文、顶部项目名称与配置页自身的返回操作保留。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 - AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。 - 账号词元点数:左下角账户菜单显示本人永久余额和预占;“词元点数与充值”入口暂时隐藏,充值、充值订单与分页收支记录的既有实现保留。真正新注册赠送 100 点,1 元充值 50 点,永久有效;旧账户不补送,旧会员/订阅/周额度/重置卡入口已移除。家长为孩子充值时登录孩子的账号,页面显示当前昵称和账号,点数进入该账号自己的钱包;无需将孩子身份改为家长。当前有效账号可给本人充值,青少年可查看自己的精确余额;使用他人共享钱包时仅显示可用状态,不显示付款人的余额或记录。Main 持有凭据并转发充值请求;付款结果未知时沿用原请求并核查原订单,只有服务端确认才显示到账。扫码信息缺失时可到网页账户页继续原订单,付款来源也由网页账户页管理,充值不会自动切换共享/个人来源。需配套部署 Works Square 当前账号充值资格更新(本次无需新迁移,原永久点数基础为 0094);本地模拟验收不代表真实支付通过。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 @@ -70,6 +70,8 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 微信卡片名称可点击名称旁的铅笔修改,支持保存、取消、Enter 保存与 Escape 取消。备注名按当前账号与微信账号 ID 保存在本机,刷新或重新绑定后保留;不修改真实微信昵称或云端名称,不跨设备同步。保存失败保留原名称与编辑内容,可重试。 +首页入口使用房子图标,悬停提示“返回首页”;智能体聊天/调整及 Code 配置、设置、插件页面的上级返回保留箭头,悬停提示“返回上一级”。既有目的地、键盘操作和未保存修改的离开保护保持。 + ## 安装与开发 项目使用 `package.json` 中固定版本的 pnpm。 diff --git a/src/components/layout/ModuleSwitcher.tsx b/src/components/layout/ModuleSwitcher.tsx index 52113df5..18122be8 100644 --- a/src/components/layout/ModuleSwitcher.tsx +++ b/src/components/layout/ModuleSwitcher.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft } from 'lucide-react'; +import { Home } from 'lucide-react'; import { useLocation, useNavigate } from 'react-router-dom'; import { cn } from '@/lib/utils'; import { AI_MODULE_SELECTION_PATH, aiModules, getAiModuleForPath } from '@/lib/ai-modules'; @@ -34,10 +34,10 @@ export function ModuleSwitcher({ sidebarCollapsed, compact = false, toolbar = fa agentReturn && 'rounded-full bg-background/70 text-brand', )} > -