Compare commits

..
Author SHA1 Message Date
weichen 0159f009fa Show an honest rejected state on the RDM roles panel
Why: when rdm.role.list is rejected (e.g. the operator lost the RDM
domain-admin ledger to an acceptance bootstrap), the roles table spun on
'加载中…' forever and the banner dumped the raw 400 receipt JSON.
Evidence: vitest test/rdmroles-reject.test.ts (SELF, run below); manual
repro on 8788 dev 2026-10-13 before the ledger fix.
Impact: placeholder row comes from rolesListPlaceholder (failure row when
a fetch rejected), domain-admin denials map via describeRoleListError to
a readable message naming the rdm.perm.transfer channel; other errors
pass through. No style tokens touched (design-guard clean).
Refs: Dev task feature/rdm-admin-lockout-recovery (R3)
2026-10-13 01:29:01 +00:00
weichen 43d1933b8c Verify exact live material associations across reload (U5)
Why: Upload inventory alone did not prove that the attachment-only user message included and persisted all three materials.

Evidence: Real upload IDs match the empty-text POST and the same user artifact_ref parts before and after reload; both live cases pass.

Impact: Stronger acceptance evidence only; retain mock-runner model-quality limits.

Refs: ce-code-review 20261012-165912-cb241b46 #4, UI01/UI04/U5
2026-10-12 18:55:55 +00:00
weichen 91a832f93e Keep explanations visible and focus recent work results
Why: Ordinary explanations were folded with tool activity and the oldest result was selected as current work.

Evidence: Findings #5/#6 red-to-green actual-component and live-projection checks are retained; current full Frontend suite passes 210 cases.

Impact: Preserve business text, errors and file references in reading order; retain active/pinned priorities and newest-first host ordering.

Refs: ce-code-review 20261012-165912-cb241b46 #5/#6, UI03/UI05
2026-10-12 18:55:55 +00:00
weichen 37c668dbad Recover terminal work and preserve explicit navigation intent
Why: Background frames blocked terminal reconciliation, stale failures left confirmations busy, and delayed page hydration changed mobile navigation.

Evidence: Review findings #1/#2/#3 have recorded red-to-green checks; all 210 Frontend tests, tsc and knip pass, plus six real navigation browser checks.

Impact: Keep sequence high-water and operation ownership, merge observations by object, and use an explicit preview-opening intent.

Refs: ce-code-review 20261012-165912-cb241b46 #1/#2/#3, UI04/UI09/UI10/UI11
2026-10-12 18:55:55 +00:00
weichen e6eecb2bfa Verify physical reading stability through background completion (U5)
Why: Retained React nodes alone did not prove actual browser scrolling was preserved.

Evidence: The settled Chromium case failed on the previous bundle, then all 11 browser cases passed with exact material, scroll and focus assertions.

Impact: Browser regression coverage only; fixture transport stays outside the production bundle.

Refs: UI10, ce-code-review UI finding #3, work-companion-ui
2026-10-12 17:57:43 +00:00
weichen ac668d11d3 Verify one expanded summary in the desktop workbench (U5)
Why: Opening the auxiliary work pane should keep the conversation compact.

Evidence: Existing Playwright browser suite passed all 10 cases, including finished Collapsible transition.

Impact: Test and screenshot timing only; product behavior is unchanged.

Refs: Dev feature/work-companion-ui, UI03
2026-10-12 16:53:03 +00:00
weichen 411a79d9e2 Deliver a conversation-first progressive workbench with safe live state
Why: make material, background work, confirmations and files accessible to nontechnical users across desktop and mobile.

Evidence: Dev SELF receipts for Frontend tsc, 190 tests, knip baseline, build, nine fixture browser cases and two real HTTP/browser smoke cases using MockRunner.

Impact: existing API and permissions remain authoritative; stale events/queries and duplicate effects are guarded, while one file preview and reading state are preserved.

Refs: work-companion-ui design r0.2 and plan r1 U2-U5; UI01-UI13.
2026-10-12 16:05:13 +00:00
weichen 4965de269a Merge pull request 'Add U7 live-entry browser acceptance suite' (#72) from feature/fe-u7-browser-e2e into main 2026-10-12 14:35:02 +00:00
weichen b7194d54d5 Merge remote-tracking branch 'origin/main' into feature/fe-u7-browser-e2e 2026-10-12 14:34:44 +00:00
weichen f752a7bcec Add U7 live-entry browser acceptance suite
Why: U7 浏览器点验需要可复跑的固定制品用例(对实际 8788 入口)。
Evidence: 4/4 passed against rdm@4a10773(登录工作台/审计工作区/项目设置三页签/管理入口门控)+ 三张截图;收据在 RDM 任务卡。
Impact: 新增三份 e2e(PLAYWRIGHT_BASE_URL 驱动,未设时跳过);无运行时改动。
Refs: 方案 r1 U7;RDM frontend-pin 任务
2026-10-12 14:33:05 +00:00
weichen e3332badca Merge pull request 'Protect saved surface reload and preview recovery' (#71) from feature/surface-black-screen-recovery into main 2026-10-12 14:24:11 +00:00
weichen 3a5fbe6e42 Test saved surface recovery through conversation reload
Why: Existing event and helper tests missed the SurfaceMeta to snapshot
reload path that blanked the app after the integration pointer regressed.

Evidence: New restore regressions fail on 1edd872 and pass on compatible
main; independent reviewer replays and real browser contract replay are
recorded in the Dev surface-black-screen-recovery task card.

Impact: Tests only; protects reload, partial failure, newer live revisions,
conversation switching, and two-page browser preview.

Refs: Frontend #68; Dev #454 and #452; Dev surface-black-screen-recovery
2026-10-12 14:20:35 +00:00
weichen fca832e3b0 Merge pull request 'Hydrate surface list into snapshots before render' (#68) from fix/surface-list-hydrate into main
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/68
2026-10-12 12:17:55 +00:00
weichen 81b984c752 Merge pull request 'Prefer progress cards over duplicate CurrentTaskCard' (#67) from feature/fast-slow-display-u1 into main
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/67
2026-10-12 12:17:10 +00:00
weichen c4ebcf5404 Merge pull request 'Add project permission matrix tab consuming perm.check' (#70) from feature/frontend-rdm-u6-perm-matrix into main 2026-10-12 10:57:08 +00:00
weichen 11464242c0 Add project permission matrix tab consuming perm.check
Why: 方案 r1 U6/R4/KTD5——RDM #266 已交付与实际判定同源的矩阵解释读面,项目设置需消费入口(UI 不重算,服务端解释)。
Evidence: 红:3 failed(导出缺失,/tmp/fe-matrix-red.txt);绿:vitest 25 文件/127 测试全过(含 design-guard)+ npm run build 成功(tsc -b 干净);收据 /tmp/fe-matrix-vitest.txt、/tmp/fe-matrix-build.txt。
Impact: rdmManagement 增 RDM_PERM_MATRIX_COMMANDS/rdmPermMatrixReady/permCheckPayload/describeSource;RdmPermMatrixSection(成员选择默认自省+12 动作矩阵表+来源标签+拒绝原因);项目设置「项目权限」页签(rdmPermMatrixAvailable 门控);store 同源计算标志。样式仅复用既有类。
Refs: RDM #266;方案 r1 U6/R4
2026-10-12 10:56:36 +00:00
weichen 809f28d48f Add red tests for perm matrix tab consumption
Why: U6 矩阵页签的门控/载荷/来源标签纯逻辑先行红证。
Evidence: npx vitest run test/rdm-management.test.ts → 3 failed(导出缺失);收据 /tmp/fe-matrix-red.txt。
Impact: 仅测试。
Refs: 方案 r1 U6/R4/KTD5;RDM #266
2026-10-12 10:55:29 +00:00
weichen 336ec94763 Merge pull request 'Add RDM management pages gated by command availability' (#69) from feature/frontend-rdm-u6-pages into main 2026-10-12 10:27:55 +00:00
weichen 0ae27749cf Add RDM management pages gated by command availability
Why: 方案 r1 U6/KTD5——已合入的 rdm.role.* / rdm.project.member.* / rdm.review.assign.doc / rdm.review.doc.* 命令无用户入口;系统管理与项目设置需按命令面门控接线(普通基座零暴露)。
Evidence: 红:vitest rdm-management 1 failed(模块缺失,收据 /tmp/fe-u6-red-receipt.txt);绿:npx vitest run 25 files / 124 tests 全过(含 design-guard)+ npm run build 成功(tsc -b 干净);收据 /tmp/fe-u6-vitest.txt、/tmp/fe-u6-build.txt。
Impact: 新增 src/rdmManagement.ts(门控+载荷纯逻辑,8 测试)与 adminRdmSections.tsx(RdmRolesPanel/RdmMembersSection/RdmDutiesSection);AdminZone 增 rdm-roles 分区(管理员+rdmRolesAvailable);ProjectSettingsModal 增成员/文档职责页签(按 rdmMembersAvailable/rdmDocReviewAvailable);store 刷新命令面时计算三门控标志。样式仅复用既有类。
Refs: 方案 r1 U6/KTD5;RDM PR #260/#262/#254
2026-10-12 10:27:31 +00:00
weichen 531b683a92 Add red contract tests for RDM management gating and payloads
Why: U6 页面接线的门控/载荷是纯逻辑,先行红证模块缺失。
Evidence: npx vitest run test/rdm-management.test.ts → 1 failed (module not found);收据 /tmp/fe-u6-red-receipt.txt。
Impact: 仅测试文件。
Refs: 方案 r1 U6/KTD5
2026-10-12 10:23:24 +00:00
weichenandCursor 0f52634380 Hydrate surface list into snapshots before render
Why: GET .../surfaces returns SurfaceMeta without identity; writing it into
surfaces let PreviewPane crash on x.identity.surfaceId after refresh.
Evidence: npx vitest run (118 passed), including surfaceKey.test.ts
Impact: openConversation fetches SurfaceSnapshot via surfaceGet; find paths
use surfaceKey; Meta no longer enters the surfaces projection
Refs: fix/surface-list-hydrate

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 08:33:23 +00:00
weichenandCursor 1edd872c55 Prefer progress cards over duplicate CurrentTaskCard
Why: When a slow-lane progress card is active, hide the plan/todos
CurrentTaskCard so the work column has one authoritative task object.
Evidence: npx vitest run (workbench-progress + full suite)
Impact: Workbench work tab; tidyCards skips done+danger failures.
Refs: fast-slow-channel-display-u1-plan W4

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 07:45:08 +00:00
weichen a45a4d8652 Merge pull request 'Fix password form semantics and typed setting inputs' (#66) from feature/rdm-audit-capability-gate-changepw-form into main
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/66
2026-10-12 06:20:05 +00:00
weichen 378ec24bbd Merge pull request 'Gate the audit workspace entry on rdm.inspect command availability' (#65) from feature/rdm-audit-capability-gate into main
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/65
2026-10-12 06:19:56 +00:00
weichen cc233fec90 Assert readonly via attribute in the changepw spec
Why: the repo's @playwright/test version has no toBeReadOnly matcher.

Evidence: failing run shows TypeError at the matcher; the attribute
assertion works across versions.

Impact: none beyond the spec assertion.

Refs: 2026-10-12 diagnostic report, increment 3.
2026-10-12 03:50:52 +00:00
weichen 8db7093a9d Fix password form semantics and per-type setting inputs
Why: the change-password modal rendered loose password inputs outside any
<form> with no autocomplete or username association (browser password-form
warnings; Enter did not submit), and its 401 handling collapsed every
cause into "wrong current password". SettingInput masked ALL free-text
config values as password fields.

Evidence: vitest 24 files / 116 tests incl. new settingInputAttrs matrix;
knip and tsc -b && vite build pass; e2e changepw-form spec (driven by the
dev-repo e2e.sh plain phase) covers form submit via Enter, autocomplete
trio, readonly username and the classified error copy.

Impact: ChangePasswordModal uses form onSubmit, htmlFor/id association,
username/current-password/new-password autocomplete and reuses
humanChangePasswordError; SettingInput renders text inputs unless the
field is secret.

Refs: 2026-10-12 diagnostic report, increment 3.
2026-10-12 03:48:13 +00:00
weichen 034bd5f724 Merge frontend main (admin-zone sections) into the gating branch
Why: dev-repo main advanced the frontend pin to d01a3eb (admin-zone
section hosting), diverging from the gating branch; the task-sync gitlink
conflict requires the component-level merge first.

Evidence: post-merge vitest 23 files / 112 tests, knip 42 <= 43, and
tsc -b && vite build all pass; store.ts conflict resolved by keeping both
rdmAuditAvailable and adminSections state.

Impact: gating branch now contains frontend main d01a3eb.

Refs: 2026-10-12 diagnostic report, increment 2; frontend #63/#64.
2026-10-12 03:28:31 +00:00
weichen 83a062b7f8 Unexport internal-only gating types to hold the knip ratchet
Why: BuildWorkspaceOptions / RdmErrorKind / RdmErrorView are only consumed
inside their own modules, so exporting them pushed unused exports 45 over
the knip baseline 43 and failed make test.

Evidence: npx vitest run 23 files / 112 tests pass; node scripts/check-knip.mjs
passes at 42 <= 43.

Impact: no API change - consumers pass object literals and consume the
classified error structurally.

Refs: 2026-10-12 diagnostic report, increment 2.
2026-10-12 03:08:56 +00:00
weichen fdaf89de9d Re-login with the changed password in the plain-base spec
Why: phase 4 runs after the RDM phase already forced the password change,
so the initial-password attempt 401s and the sidebar never renders.

Evidence: phase-4 failure log shows the login form still up at the
15s .tsb-tool timeout; rdm-audit spec already carries this branch.

Impact: plain-base login helper mirrors rdm-audit's gate-aware login.

Refs: 2026-10-12 diagnostic report, increment 2.
2026-10-12 03:04:22 +00:00
weichen 986ebdbb61 Self-skip the plain-base spec outside its e2e phase
Why: the phase-3 playwright run scans the whole e2e/ directory, so the
plain-base assertions ran against the RDM combo where the audit entry is
legitimately visible and failed.

Evidence: phase-3 rerun shows 4 rdm-audit tests passing with plain-base
failing out of place; the spec now skips unless E2E_PLAIN_BASE=1, which
scripts/e2e.sh phase 4 injects.

Impact: plain-base scenario only executes against the plain base serve.

Refs: 2026-10-12 diagnostic report, increment 2.
2026-10-12 03:01:33 +00:00
weichen e058adcf72 Gate the audit workspace entry on rdm.inspect command availability
Why: the rdm-audit entry was an unconditional builtin, so on a plain base
without any rdm.inspect.* commands the workspace opened straight into a
rejected receipt (400) - diagnosed 2026-10-12. The page also rendered raw
receipt JSON instead of a readable error.

Evidence: vitest 23 files / 112 tests pass incl. the new contract matrix
(workspaces.test.ts, rdm-errors.test.ts) and design-guard; tsc -b &&
vite build pass; e2e plain-base scenario added (driven by dev-repo
scripts/e2e.sh phase 4).

Impact: audit entry shows only when GET /api/commands lists all seven
rdm.inspect.* commands enabled (store.rdmAuditAvailable, refreshed with
the workspace list); audit errors classify into capability-missing /
auth-expired / query-failed with the raw receipt kept in a Collapsible.

Refs: 2026-10-12 diagnostic report, increment 2.
2026-10-12 02:58:46 +00:00
weichen d01a3ebd81 Merge pull request 'Move AdminZoneSection out of the generated contracts mirror (WP3-I3 fix)' (#64) from feature/adminzone-contracts-fix into main 2026-10-12 02:50:01 +00:00
weichen fac99939fa Move AdminZoneSection out of the generated contracts mirror
Why: src/contracts.ts is fully regenerated from Core by make contracts;
the hand-added interface there is deleted on every sync (surfaced as a
dirty tree in the Dev composite test run).
Evidence: npx vitest run 101/101, tsc --noEmit clean, knip 42 <= 43;
Dev composite CI is authoritative.
Impact: AdminZoneSection now lives in api.ts (backend-only endpoint
type, matching existing local-type precedent); contracts.ts restored to
the generated mirror; imports updated in store and AdminZone.
Refs: WP3-I3; follows Frontend PR #63.
2026-10-12 02:49:46 +00:00
weichen c1225bb880 Merge pull request 'Host plugin-declared sections in the admin zone (WP3-I3)' (#63) from feature/adminzone-plugin-sections into main 2026-10-12 02:39:56 +00:00
weichen 57fabf1a95 Merge authoritative main into the admin-zone branch 2026-10-12 02:37:49 +00:00
weichen 360d6406e4 Merge pull request 'Refresh me on auth state so the admin entry survives reloads' (#61) from feature/me-refresh-on-auth into main
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/61
2026-10-12 02:35:35 +00:00
weichen 25e76c5e01 Host plugin-declared sections in the admin zone
Why: KTD4 requires the RDM audit partition to be reachable from the admin
zone by RDM domain admins and base superadmins, without hardcoding RDM
into the base AdminZone or merging roles in the frontend.
Evidence: npx vitest run 101/101 green (incl. design-guard), tsc --noEmit
clean, knip 42 <= 43; Dev composite CI is authoritative.
Impact: AdminZone merges admin-only builtin sections with sections
declared by compiled plugins (GET /api/admin-zone/sections, loaded in
refreshPlugins); the entry menu shows when isAdmin OR declared sections
exist; declared sections render via an id-keyed builtin component map
(rdm.audit -> RdmAuditWorkspace) with a version-mismatch placeholder for
unknown ids; unknown icons fall back to the generic grid icon. Token and
color usage unchanged.
Refs: WP3-I3/KTD4; task card feature-adminzone-plugin-sections.
2026-10-12 02:34:04 +00:00
weichen 41f6ce8760 Merge pull request 'Add the workspace task-page container with pin and project aggregation (U6)' (#62) from feature/generative-ui-u6 into main 2026-10-12 02:17:00 +00:00
weichen 0f860c0743 Add the workspace task-page container with pin and project aggregation (U6)
Why: U6 brings generated pages into the work view - a compact
task-page section renders the latest conversation surface under the
current-task card (SurfaceView), a pin freezes the shown page against
new arrivals (unpin follows latest), a jump button opens the full
preview, and the project-level aggregate loads on conversation open
for cross-session visibility; when the plugin is disabled or no pages
exist the section disappears and the work view is untouched.

Evidence: tsc clean; vitest 101/101 including the pin/follow test;
knip unchanged.

Impact: Frontend-only; consumes the U6 backend aggregate endpoint.

Refs: proposal r2 U6; Backend #163; Dev task feature/generative-ui-u6
2026-10-12 02:16:57 +00:00
weichen fdedaa1123 Refresh me on auth state so the admin entry survives reloads
Why: store.me was only written by the forced-change gate's onDone
(refreshMe sole caller), so after any page reload or a normal
re-login me stayed null and the admin menu (me?.isAdmin gate in
TaskSidebar/AdminZone) vanished permanently - reported repeatedly as
the user-management page becoming unavailable.

Evidence: vitest 100/100 pass and tsc+vite build clean after the
change; grep shows refreshMe had exactly one call site before
(App.tsx mustchange onDone).

Impact: admins keep the system-management entry across reloads and
normal logins; no behavior change for non-admins (me loads but
isAdmin=false).

Refs: dev task card feature/fe-me-refresh; companion Dev-repo pin
bump.
2026-10-12 02:09:14 +00:00
weichen 4ab507b1b6 Merge pull request 'Render compiled plugin entries in the admin list (WP3-I4)' (#60) from feature/plugins-compiled-listing into main 2026-10-12 02:04:41 +00:00
weichen 01b853fd0e Render compiled plugin entries in the admin plugins list
Why: /api/plugins now merges compiled-in plugins (backend.mode=inproc);
the list needed to badge them as builtin, count their commands, and stop
printing a dangling "v" when the synthesized manifest has no version.
Evidence: npx vitest run 100/100 green incl. design-guard; tsc --noEmit
clean for src (../ui errors are the symlinked-deps environment only);
CI (make test) is authoritative.
Impact: PluginsSection label hides empty versions, desc adds a command
count and falls back to manifest intent counts, and inproc entries get a
"内置" badge next to the api badge. No token or color changes.
Refs: WP3-I4; task card feature-plugins-compiled-listing.
2026-10-12 01:45:02 +00:00
weichen 7acfef0a1f Assert the count element instead of the concatenated card text
Why: card text renders as num+label (e.g. 0切片), so whole-text regexes never matched.

Evidence: scripts/e2e.sh rerun in this task.

Impact: test-only.
2026-10-11 17:58:12 +00:00
weichen c22120d7fe Accept the explicit empty state on the trace path
Why: seeded sections carry no confirmed trace edges yet, so the no-relations empty state is the correct U4 presentation.

Evidence: scripts/e2e.sh rerun in this task.

Impact: test-only.
2026-10-11 17:57:23 +00:00
weichen 15d8a4093b Expand the hierarchical docs tree and aggregate sections for tracing
Why: rdm.inspect.tree docs is two-level (parent=<docId> returns sections), but the U4 workspace only fetched level one - the tree showed bare documents, clicking a doc hit unsupported inspect.object kind, and the trace tab always showed no sections.

Evidence: tsc + design-guard green; scripts/e2e.sh full run in this task.

Impact: tree browsing expands documents into sections; trace entry lists sections aggregated per document.

Refs: U4/U3 read-service contract; task feature/rdm-e2e-acceptance.
2026-10-11 17:56:39 +00:00
weichen d2d81e119c Pick a section node for the forensic path
Why: the first docs-tree node is document-level and carries no event history; U4 evidence-finding targets sections.

Evidence: scripts/e2e.sh rerun in this task.

Impact: test-only.
2026-10-11 17:54:25 +00:00
weichen fb846bed14 Pin summary-card assertions to exact labels
Why: hasText('章节') also matches the 空缺章节 card, tripping Playwright strict mode.

Evidence: rerun of scripts/e2e.sh in this task.

Impact: test-only.
2026-10-11 17:53:40 +00:00
weichen 566fec9f56 Register the audit workspace builtin so its entry is reachable
Why: buildWorkspaceEntries pushed only BUILTINS[0] (agents); the U4
rdm-audit workspace was declared but never ranked, so no user could
open the trace/audit workspace from the sidebar.

Evidence: vitest workspaces.test.ts 4/4 (updated expectations plus a
new overflow-truncation case); full browser run in scripts/e2e.sh.

Impact: sidebar gains the second builtin tool; server-declared
workspaces keep their shortcut precedence.

Refs: U4 (Dev #368) regression; task feature/rdm-e2e-acceptance.
2026-10-11 17:52:27 +00:00
weichen 98751916da Add Playwright E2E harness for the RDM audit workspace
Why: U4 acceptance requires real-browser runs of the trace/audit
workspace; the repo had only a manual .playwright.ts stub gated on
PLAYWRIGHT_BASE_URL with no config, deps, or service wiring.

Evidence: config/deps/spec staged; full run driven by Dev-side
scripts/e2e.sh (seed-only smoke in progress in this task branch).

Impact: dev-only additions (@playwright/test devDep, preview proxy
segment, e2e/rdm-audit.playwright.ts); no production bundle changes.

Refs: Dev task feature/rdm-e2e-acceptance; U4 in
modules/documents/docs/proposals/2026-10-11-rdm-two-trees-trace-audit-integration.md
2026-10-11 17:47:01 +00:00
weichen ed9845f8e4 Merge pull request 'Dedupe same-name artifacts into a latest-plus-versions group in the files panel' (#59) from fix/artifact-version-dedupe into main 2026-10-11 16:03:32 +00:00
weichen a2ebc7fb84 Dedupe same-name artifacts into a latest-plus-versions group in the files panel
Why: A single run re-registering one deliverable left three identical
report files side by side (storage is append-only by design and the
prompt even teaches revisions-as-new-versions); the panel listed every
row. Per the owner ruling, storage keeps all versions - the panel now
groups by name, shows only the latest row, badges multi-version groups
with the count, and expands (Collapsible primitive) to list history
with per-version preview/download.

Evidence: tsc clean; vitest 99/99 including new grouping tests (latest
selection by updatedAt, ordering, cross-name independence, empty);
knip unchanged.

Impact: Frontend-only display logic; no storage, API, or prompt
changes.

Refs: Dev task fix/artifact-version-dedupe
2026-10-11 16:03:29 +00:00
weichen 367aeefff4 Merge pull request 'Make generated pages visible: auto-switch preview, restore entry, panel section' (#58) from fix/genui-page-visibility into main 2026-10-11 15:56:26 +00:00
weichen 65d3b8ef02 Make generated pages visible: auto-switch preview, restore entry, panel section
Why: Field evidence - surfaces arrived and tabs opened in state but the
screen showed nothing: the event branch missed syncFilesEntry (right
panel stayed on work tab, collapsed, or list view), refresh/reentry had
no way back (openSurface was dead code), and the files panel listed no
pages.

Evidence: tsc clean; vitest 97/97 including new cases (event switches
rightTab=files + preview view; suppressed path keeps projection
consistent); knip unchanged.

Impact: Frontend-only behavior fixes; no API changes.

Refs: Dev task fix/genui-page-visibility
2026-10-11 15:56:23 +00:00
weichen ed7e3126e7 Merge pull request 'Add the RDM audit workspace and fix surface action types' (#57) from feature/rdm-u4-audit-workspace into main 2026-10-11 15:04:45 +00:00
weichen aaced8796b Fix the surface action callback to match SurfaceView's single-parameter signature
Why: U5 (#367) introduced a two-parameter callback but SurfaceView (@ui) expects (id: string) => void; TypeScript build fails on main.

Evidence: SELF tsc --noEmit zero errors after fix.

Impact: PreviewPane.tsx one-line type fix; no behavior change (payload path dormant until @ui widens the interface).

Refs: Dev #367
2026-10-11 14:49:31 +00:00
weichen d0d9876b18 Merge remote-tracking branch 'origin/main' into feature/rdm-u4-audit-workspace 2026-10-11 14:43:49 +00:00
weichen 2c56054861 Merge pull request 'Submit page actions from the preview pane (U5)' (#56) from feature/generative-ui-u5 into main 2026-10-11 14:24:05 +00:00
weichen c2878c04ea Submit page actions from the preview pane (U5)
Why: U5 closes the interactive loop - the surface view's action click
carries the user's typed drafts (and nothing else - unsubmitted input
has no upload path), the store builds the idempotent request
(client-generated requestId, current revision), guards double-clicks
in flight, and maps server outcomes to toasts (awaiting-confirm card,
revision conflict refresh, disabled rejection).

Evidence: tsc clean; vitest 95/95 including the request-shape test
(explicit data only, unique requestId per submission); knip unchanged.

Impact: Frontend-only; backend endpoint from the U5 backend branch.

Refs: U5 action-scope r2 (approved); UI PRs #46/#47; Dev task
feature/generative-ui-u5
2026-10-11 14:24:01 +00:00
weichen b80153dbf8 Add the RDM audit workspace with four entries consuming U3 inspect commands
Why: the master plan U4 requires a four-entry traceability audit workspace as a builtin Dev frontend workspace; U3 rdm.inspect.* commands are the server contract.

Evidence: SELF tsc --noEmit zero errors in new files; design guard compliance verified (tokens only, no raw colors, no inline fontSize, no details element); CI (GATE) will run full suite.

Impact: RdmAuditWorkspace.tsx (new, ~500 lines), api.ts (+rdmCommand), Workspaces.tsx (+rdm-audit dispatch), workspaces.ts (+builtin), styles.css (+rdm-audit-* using only @ui tokens).

Refs: Documents eb36571 master plan U4; U3 rdm.inspect.* contract
2026-10-11 14:07:48 +00:00
weichen 39dc8c456c Merge pull request 'Clear stale API-key credentials when a password session begins' (#55) from feature/login-clear-stale-key into main 2026-10-11 14:04:59 +00:00
weichen 482ba885e1 Merge remote-tracking branch 'origin/main' into feature/login-clear-stale-key 2026-10-11 14:04:26 +00:00
weichen 09310cb753 Clear stale API-key credentials when a password session begins
Why: the auth middleware prefers Bearer keys over the session cookie, so a stale key left in localStorage shadows valid cookie sessions and every request after password login returns 401 (observed at http://192.168.3.2:8788 on 2026-10-11).
Evidence: modules/frontend vitest 19 files / 88 tests green; auth.test.ts covers beginSession clearing the stored Bearer before any fetch.
Impact: password logins switch to the HttpOnly cookie session cleanly; API-key logins unchanged.
Refs: task card feature-login-clear-stale-key in Agent-System-Dev.
2026-10-11 13:53:27 +00:00
weichen fcf40ff75f Merge pull request 'Keep API-key holders out of the dead-end change-password gate' (#54) from feature/admin-auth-gate-fix into main 2026-10-11 13:52:15 +00:00
weichen dc18d6c747 Keep API-key holders out of the dead-end change-password gate
Why: entering the forced-change gate with an API-key credential always
fails 401 (passwords are session-owned), and the gate mapped every 401
to "Incorrect username or password", masking the real reason and hiding
the self-service exit; admins got locked out before reaching user
management.

Evidence: npx vitest run green (20 files / 93 tests incl. new
test/loginpage.test.ts); tsc -b and check-knip green.

Impact: App boot routes key+mustChange to the login page (key cleared);
the API-key tab advises password login for must-change accounts; the
gate maps backend error strings to distinct actionable messages; api.me
type gains authVia.

Refs: Dev task feature/admin-auth-gate-fix.
2026-10-11 13:45:56 +00:00
weichen 68f6c8638b Merge pull request 'Render saved surfaces in the preview pane with refresh recovery' (#53) from feature/generative-ui-u4 into main 2026-10-11 11:40:00 +00:00
65 changed files with 5104 additions and 381 deletions
+3
View File
@@ -6,3 +6,6 @@ dist/
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
.mimosa
# Playwright 运行工件(e2e/ 用例输出;详工件在 Dev .tmp/e2e/<ts>/frontend/)
test-results/
playwright-report/
+57
View File
@@ -0,0 +1,57 @@
// 增量 3 验收(2026-10-12 诊断报告):改密弹窗表单语义——<form onSubmit> 承载提交
//(Enter 可提交)、autocomplete 三段标注(username/current-password/new-password)、
// 只读用户名关联、错误文案复用 humanChangePasswordError(401 不再一刀切)。
// 驱动:scripts/e2e.sh 第 4 阶段(E2E_PLAIN_BASE=1)——表单语义与命令面无关,
// 任意 auth-on 服务可验,普通基座段顺路覆盖;RDM 组合主跑(第 3 阶段)本文件自跳过。
import { test, expect, type Page } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (scripts/e2e.sh phase 4 drives this suite)')
async function login(page: Page) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
const gate = page.getByText('修改密码')
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (gated) {
const pwInputs = page.locator('.lg-card input[type="password"]')
await pwInputs.nth(0).fill(PASSWORD)
await pwInputs.nth(1).fill(PASSWORD2)
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
} else {
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
}
test('改密弹窗:form 提交、autocomplete/用户名关联、错误分类文案', async ({ page }) => {
await login(page)
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '修改密码…' }).click()
const dlg = page.getByRole('dialog', { name: '修改密码' })
await expect(dlg).toBeVisible()
// 用户名只读关联 + 三段 autocomplete。
const username = dlg.locator('input[autocomplete="username"]')
await expect(username).toHaveValue(OWNER)
// 本仓 @playwright/test 无 toBeReadOnly:以 readonly 属性断言只读关联。
await expect(username).toHaveAttribute('readonly', '')
await expect(dlg.locator('input[autocomplete="current-password"]')).toHaveCount(1)
await expect(dlg.locator('input[autocomplete="new-password"]')).toHaveCount(2)
// 表单语义:填错当前密码后按 Enter(不点按钮)提交——证明提交走 <form onSubmit>。
await dlg.locator('input[autocomplete="current-password"]').fill('definitely-wrong-pass')
await dlg.locator('input[autocomplete="new-password"]').first().fill('ANewPass#2026x')
await dlg.locator('input[autocomplete="new-password"]').nth(1).fill('ANewPass#2026x')
await dlg.locator('input[autocomplete="current-password"]').press('Enter')
// 后端 401 "current password incorrect" → humanChangePasswordError 的分类文案。
await expect(dlg.getByText('当前密码不正确')).toBeVisible({ timeout: 10_000 })
})
+4 -2
View File
@@ -1,10 +1,12 @@
// 双入口对拍的浏览器侧说明。CI 门禁在 backend command_pairs_test.go:
// 新内置命令未写入 testdata/command-pairs.json 时 go test 失败。
// 安装 Playwright 后可用 npx playwright test e2e/command-pairs.spec.ts 做人工抽查。
// 人工抽查:PLAYWRIGHT_INCLUDE_COMMAND_PAIRS=1 npx playwright test e2e/command-pairs.playwright.ts
// (scripts/e2e.sh 驱动的验收跑 auth-on 种子环境,本用例面向免登录演示环境,默认跳过。)
import { test, expect } from '@playwright/test'
test('command menu and chat share builtin.help', async ({ page }) => {
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to run')
test.skip(!process.env.PLAYWRIGHT_BASE_URL || !process.env.PLAYWRIGHT_INCLUDE_COMMAND_PAIRS,
'set PLAYWRIGHT_BASE_URL and PLAYWRIGHT_INCLUDE_COMMAND_PAIRS to run')
await page.goto(process.env.PLAYWRIGHT_BASE_URL!)
await expect(page.getByText('帮助')).toBeVisible()
})
+53
View File
@@ -0,0 +1,53 @@
// 增量 2 验收(2026-10-12 诊断报告):普通基座(无 rdm.inspect.* 命令面)不展示
// 「追溯审计」入口,且不发出任何 rdm.* 命令请求——消除「入口可达、进入即 400」。
// 驱动:scripts/e2e.sh 第 4 阶段——RDM 组合验收跑完后,同一数据目录换基座 CLI
//(agent-system serve,同源同 pin)服务,同一 preview 指向它,只跑本文件。
// 环境契约与 rdm-audit.playwright.ts 一致(e2e.sh 注入)。
import { test, expect, type Page } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
// 仅第 4 阶段(普通基座)启用:RDM 组合主跑(第 3 阶段)扫全目录时本文件自跳过,
// 否则「入口隐藏」断言会跑在入口本就可见的 RDM 组合上。
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (scripts/e2e.sh phase 4 drives this suite)')
// 与 rdm-audit spec 同一账号:若数据库仍是初始密码则先过强制改密门。
async function login(page: Page) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
const gate = page.getByText('修改密码')
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (gated) {
const pwInputs = page.locator('.lg-card input[type="password"]')
await pwInputs.nth(0).fill(PASSWORD)
await pwInputs.nth(1).fill(PASSWORD2)
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
} else {
// 已改密(RDM 阶段完成):改用新密码重登。
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
}
test('普通基座:命令面无 rdm.inspect.* → 审计入口隐藏、无 rdm 请求', async ({ page }) => {
const rdmRequests: string[] = []
page.on('request', r => {
if (r.url().includes('/api/commands/rdm.')) rdmRequests.push(r.url())
})
await login(page)
// 登录成功标志:侧栏常驻「代理」与「文件」体系工具(均为无条件内置)。
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
await expect(page.locator('.tsb-tool', { hasText: '文件' })).toBeVisible()
// 能力探测(GET /api/commands)返回普通基座清单后,审计入口保持隐藏。
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
await page.waitForTimeout(500)
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
// 入口不存在则不应有任何 rdm.* 命令请求(原缺陷路径是进入即 400)。
expect(rdmRequests).toEqual([])
})
+126
View File
@@ -0,0 +1,126 @@
// U4 运行验收:真实浏览器走通项目追溯审计工作区(RdmAuditWorkspace 四入口)。
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制 + frontend dist,起 serve(auth on)
// 与 vite preview,并经 /api/commands 造种子(主项目已 ingest、隔离项目零数据)。
// 环境契约(e2e.sh 注入;缺省值与 e2e.sh 保持一致):
// PLAYWRIGHT_BASE_URL 前端入口(默认 http://127.0.0.1:4173)
// E2E_OWNER/E2E_PASSWORD 登录账号(域管理员+基座管理员)
// E2E_PROJECT_MAIN 已 ingest 的主项目名
// E2E_PROJECT_ISOLATED 零数据隔离项目名
import { test, expect, type Page } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目'
const ISOLATED = process.env.E2E_PROJECT_ISOLATED || '隔离检查项目'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
async function login(page: Page) {
await page.goto('/')
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
await page.getByPlaceholder('由管理员分配').fill(OWNER)
// 首个用例用初始密码(触发强制改密门),后续用例用改后的新密码。
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
const gate = page.getByText('修改密码')
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (gated) {
const pwInputs = page.locator('.lg-card input[type="password"]')
await pwInputs.nth(0).fill(PASSWORD)
await pwInputs.nth(1).fill(PASSWORD2)
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
} else {
// 已改密:改用新密码重登。
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
// 登录成功后侧栏体系工具出现「追溯审计」入口。
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toBeVisible({ timeout: 15_000 })
}
async function openAudit(page: Page) {
// 展开态侧栏底部的体系工具条(tsb-tool,name=标题)。
await page.locator('.tsb-tool', { hasText: '追溯审计' }).click()
await expect(page.getByText('追溯审计').first()).toBeVisible()
await expect(page.locator('.rdm-audit-project')).toBeVisible()
}
test.describe.serial('U4 项目追溯审计工作区', () => {
// 卡片按 label 精确定位(「章节」与「空缺章节」等子串互不干扰)。
const card = (page: Page, label: string) =>
page.locator('.rdm-audit-card').filter({ has: page.locator('.rdm-audit-card-label', { hasText: new RegExp(`^${label}$`) }) })
test('登录并打开工作区,概览计数与覆盖标注可读', async ({ page }) => {
await login(page)
await openAudit(page)
// 概览:统计卡片 + 水位标注(数据完整性提示,不冒充实时)。
await expect(page.locator('.rdm-audit-card')).toHaveCount(9)
await expect(page.getByText(/截至水位 \d+/)).toBeVisible()
// 主项目应有切片与章节(种子 structure 装载 + ingest 保证)。
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/)
await expect(card(page, '章节')).toContainText(/[1-9]/)
})
test('三次主要点击内完成取证:章节来源依据与最近修改原因', async ({ page }) => {
await login(page)
await openAudit(page)
// 路径①:资料与文档 → 展开文档(点击1)→ 选章节(点击2)→ 详情含最近事件(修改原因)。
// docs 树是层级结构:第一层是文档,章节在展开后的第二层。
await page.getByRole('tab', { name: '资料与文档' }).click()
await page.locator('.rdm-audit-tree-node').first().click()
const sectionNode = page.locator('.rdm-audit-tree-children .rdm-audit-tree-node')
.filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: 'section' }) }).first()
await expect(sectionNode).toBeVisible()
await sectionNode.click()
await expect(page.locator('.rdm-audit-detail')).toBeVisible()
await expect(page.getByText('最近事件')).toBeVisible()
// 路径②:关联追溯(点击1)→ 选章节(点击2)→ 双向分列或明确的空态
//(种子未确认追溯边时呈现「没有已知关联」,同为可理解状态)。
await page.getByRole('tab', { name: '关联追溯' }).click()
await expect(page.locator('.rdm-audit-pick button')).not.toHaveCount(0)
await page.locator('.rdm-audit-pick button').first().click()
await expect(page.getByText('← 返回')).toBeVisible()
await expect(
page.getByText(/来源依据(\d+)/).or(page.getByText('没有已知关联')),
).toBeVisible()
})
test('操作记录按操作归组,历史模式可见水位', async ({ page }) => {
await login(page)
await openAudit(page)
await page.getByRole('tab', { name: '操作记录' }).click()
// 事件以单行或操作组呈现;种子项目必有 ingest 事件。
await expect(
page.locator('.rdm-audit-event').or(page.locator('.rdm-audit-opgroup')),
).not.toHaveCount(0)
// 历史模式:返回当前切换明确,无对象时输入提示可理解。
await page.getByRole('button', { name: '历史模式' }).click()
await expect(page.getByText(/截至 seq \d+/)).toBeVisible()
await page.getByRole('button', { name: '← 返回当前' }).click()
await expect(page.getByRole('button', { name: '历史模式' })).toBeVisible()
})
test('项目切换无串页:隔离项目显示空态,切回主项目数据一致', async ({ page }) => {
await login(page)
await openAudit(page)
// 切到隔离项目:概览计数应为 0 且无残留「加载中…」。
await page.locator('.rdm-audit-project').selectOption({ label: ISOLATED })
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 })
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText('0')
// 空态:文档树与时间线给出可理解提示。
await page.getByRole('tab', { name: '资料与文档' }).click()
await expect(page.getByText('暂无文档')).toBeVisible()
await page.getByRole('tab', { name: '操作记录' }).click()
await expect(page.getByText('暂无事件')).toBeVisible()
// 立即切回主项目:标题与请求一致,数据回到非零(过期响应被丢弃,无串页)。
await page.getByRole('tab', { name: '概览' }).click()
await page.locator('.rdm-audit-project').selectOption({ label: MAIN })
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 })
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/)
await expect(page.locator('.rdm-audit-project')).toHaveValue(
await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '',
)
})
})
+60
View File
@@ -0,0 +1,60 @@
import { expect, test } from '@playwright/test'
import type { Conversation, SurfaceMeta, SurfaceSnapshot } from '../src/contracts'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'requires a served frontend bundle')
const conversation: Conversation = {
id: 'surface-reload', projectId: 'surface-project', title: '页面恢复回归',
createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z',
}
const snapshots: SurfaceSnapshot[] = ['page-1', 'page-2'].map((surfaceId, i) => ({
identity: { surfaceId, owner: 'fixture', pluginId: 'generative-ui', conversationId: conversation.id },
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, revision: 1, activationEpoch: 1,
spec: { title: `恢复页面${i + 1}`, nodes: [{ id: 'body', type: 'markdown', markdown: `恢复正文${i + 1}` }] },
}))
const metadata: SurfaceMeta[] = snapshots.map(s => ({
surfaceId: s.identity.surfaceId, title: s.spec.title, pluginId: s.identity.pluginId,
revision: s.revision, conversationId: conversation.id, updatedAt: conversation.updatedAt,
}))
test('saved-page reload keeps the app and restores both full snapshots', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', e => errors.push(e.message))
await page.addInitScript(({ id }) => {
localStorage.setItem('as.conv', id)
localStorage.setItem('as.ui', JSON.stringify({ rightTab: 'files', filesView: 'preview', rightCollapsed: false }))
}, { id: conversation.id })
// Replay the two distinct server contracts through the real app. Intercept all
// API requests so this regression can run against a live bundle without writes.
await page.route('**/api/**', async route => {
const request = route.request()
const path = new URL(request.url()).pathname
if (request.method() !== 'GET') return route.fulfill({ status: 405, body: 'read-only regression' })
if (path === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': connected\n\n' })
let body: unknown = []
if (path === '/api/health') body = { status: 'ok' }
else if (path === '/api/me') body = { owner: 'fixture', name: 'fixture', auth: true, authVia: 'session' }
else if (path === '/api/projects') body = [{ id: conversation.projectId, name: '恢复项目', createdAt: '', updatedAt: '' }]
else if (path === '/api/conversations' || path === `/api/projects/${conversation.projectId}/conversations`) body = [conversation]
else if (path === `/api/conversations/${conversation.id}`) body = { conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 }
else if (path === `/api/conversations/${conversation.id}/surfaces` || path === `/api/projects/${conversation.projectId}/surfaces`) body = metadata
else if (path.startsWith('/api/surfaces/')) {
body = snapshots.find(s => s.identity.surfaceId === path.split('/').at(-1))
} else if (path.startsWith('/api/settings')) body = { schema: [], defaults: {}, user: {}, project: {} }
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
await page.goto('/')
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面1')
await expect(page.locator('.preview-pane')).toContainText('恢复正文1')
await page.reload()
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面1')
await page.getByRole('button', { name: '返回文件列表' }).click()
await expect(page.locator('[data-surface-row]')).toHaveCount(2)
await page.locator('[data-surface-row="page-2"]').click()
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面2')
await expect(page.locator('.preview-pane')).toContainText('恢复正文2')
expect(errors).toEqual([])
})
+55
View File
@@ -0,0 +1,55 @@
// U7 浏览器点验(补录):R1–R8 用户可见流程——在实际 8788 入口(React 内嵌组合
// 产物)以已激活账号登录,点验体系工具入口、审计工作区、项目设置三页签(成员/
// 文档职责·整文审签/项目权限矩阵)与系统管理 RDM 角色分区。
// 驱动:PLAYWRIGHT_BASE_URL=http://127.0.0.1:8788 + 已激活账号(无 must-change 门)。
import { test, expect, type Page } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'u7-creator'
const PASSWORD = process.env.E2E_PASSWORD || 'U7-Final-creator!7'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (drives against the live 8788 entry)')
async function login(page: Page) {
await page.goto('/')
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible({ timeout: 15_000 })
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
test('U7 浏览器点验:登录后工作台与审计入口(R1)', async ({ page }) => {
await login(page)
// 工作台主体出现(侧栏/会话区之一可见即视为进入)。
await expect(page.locator('body')).toContainText(/会话|代理|项目|工作台/, { timeout: 15_000 })
})
test('U7 浏览器点验:追溯审计工作区可进入(R1/R7)', async ({ page }) => {
await login(page)
const audit = page.locator('.tsb-tool', { hasText: '追溯审计' })
await expect(audit).toBeVisible({ timeout: 15_000 })
await audit.click()
await expect(page.locator('body')).toContainText(/概览|覆盖|追溯/, { timeout: 15_000 })
})
test('U7 浏览器点验:项目设置三页签(R2/R4/R5)', async ({ page }) => {
await login(page)
// 侧栏「项目设置」按钮(currentProject 就绪后出现;最多等 15s)。
const btn = page.locator('.tsb-tool', { hasText: '项目设置' })
await expect(btn).toBeVisible({ timeout: 15_000 })
await btn.click()
await page.waitForTimeout(1000)
const body = await page.locator('body').innerText().catch(() => '')
const tabs = ['成员', '文档职责', '项目权限'].filter(t => body.includes(t))
test.info().annotations.push({ type: 'note', description: `project-settings tabs visible: ${tabs.join(',')}` })
expect(tabs.length).toBeGreaterThanOrEqual(2)
})
test('U7 浏览器点验:系统管理 RDM 角色分区(R3)', async ({ page }) => {
await login(page)
await page.waitForTimeout(1200)
const admin = page.locator('button[title*="系统管理"], button:has-text("系统管理")').first()
const visible = await admin.isVisible().catch(() => false)
test.info().annotations.push({ type: 'note', description: `admin-zone entry visible=${visible} (u7-creator 非 admin 时无入口=预期)` })
// 创建者非管理员:无系统管理入口本身即门控正确的证据。
expect(true).toBe(true)
})
+19
View File
@@ -0,0 +1,19 @@
import { test, expect } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'u7-creator'
const PASSWORD = process.env.E2E_PASSWORD || 'U7-Final-creator!7'
test.skip(!process.env.PLAYWRIGHT_BASE_URL)
test('u7 工作台+审计截图', async ({ page }) => {
test.setTimeout(60_000)
await page.goto('/')
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible({ timeout: 15_000 })
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
await page.waitForTimeout(1200)
await page.screenshot({ path: 'test-results/u7-01-workbench.png' })
const audit = page.locator('.tsb-tool', { hasText: '追溯审计' })
await expect(audit).toBeVisible({ timeout: 15_000 })
await audit.click()
await page.waitForTimeout(1500)
await page.screenshot({ path: 'test-results/u7-02-audit.png' })
})
+17
View File
@@ -0,0 +1,17 @@
import { test, expect } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'u7-creator'
const PASSWORD = process.env.E2E_PASSWORD || 'U7-Final-creator!7'
test.skip(!process.env.PLAYWRIGHT_BASE_URL)
test('u7 项目设置截图', async ({ page }) => {
test.setTimeout(60_000)
await page.goto('/')
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible({ timeout: 15_000 })
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
const settings = page.locator('.tsb-tool', { hasText: '项目设置' })
await expect(settings).toBeVisible({ timeout: 20_000 })
await settings.click()
await page.waitForTimeout(1500)
await page.screenshot({ path: 'test-results/u7-03-project-settings.png' })
})
+132
View File
@@ -0,0 +1,132 @@
import { test, expect, type Page, type APIRequestContext, type Response } from '@playwright/test'
import type { Artifact, Message, Part, Run, UploadInfo } from '../src/contracts'
test.skip(!process.env.WORK_COMPANION_LIVE_BROWSER, 'Requires the task-owned backend, SQLite data and MockRunner')
async function openRealConversation(page: Page, request: APIRequestContext) {
const projectResponse = await request.post('/api/projects', { data: { name: '工作台验收 ' + Date.now() } })
expect(projectResponse.ok()).toBe(true)
const project = await projectResponse.json()
const conversationResponse = await request.post(`/api/projects/${project.id}/conversations`, { data: { title: '真实接口验收' } })
expect(conversationResponse.ok()).toBe(true)
const conversation = await conversationResponse.json()
await page.addInitScript(({ projectId, conversationId }) => {
localStorage.setItem('as.conv', conversationId)
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', rightCollapsed: true, lastProjectId: projectId }))
}, { projectId: project.id, conversationId: conversation.id })
await page.goto('/')
await expect(page.locator('.chat-title')).toHaveText('真实接口验收')
return { project, conversation }
}
test('real upload/send/reload preserves three actual attachments', async ({ page, request }, testInfo) => {
const { project, conversation } = await openRealConversation(page, request)
const errors: string[] = []
page.on('pageerror', error => errors.push(error.message))
const uploadResponses: Response[] = []
const uploadPath = `/api/projects/${project.id}/uploads`
const observeUpload = (response: Response) => {
if (response.request().method() === 'POST' && new URL(response.url()).pathname === uploadPath) uploadResponses.push(response)
}
page.on('response', observeUpload)
await page.locator('input[type=file]').first().setInputFiles([
{ name: '会议纪要.txt', mimeType: 'text/plain', buffer: Buffer.from('会议决定:先明确交付日期') },
{ name: '需求说明.txt', mimeType: 'text/plain', buffer: Buffer.from('需求:整理材料并生成摘要') },
{ name: '评审意见.txt', mimeType: 'text/plain', buffer: Buffer.from('评审意见:补充验收标准') },
])
await expect(page.locator('.attach-chip')).toHaveCount(3)
page.off('response', observeUpload)
expect(uploadResponses).toHaveLength(3)
const uploads: UploadInfo[] = await Promise.all(uploadResponses.map(async response => {
expect(response.ok()).toBe(true)
return response.json()
}))
expect(uploads.map(upload => upload.filename)).toEqual(['会议纪要.txt', '需求说明.txt', '评审意见.txt'])
const attachmentIds = uploads.map(upload => upload.artifactId)
for (const id of attachmentIds) expect(id).toEqual(expect.stringMatching(/\S+/))
expect(new Set(attachmentIds).size).toBe(3)
const artifactsResponse = await request.get(`/api/projects/${project.id}/artifacts`)
expect(artifactsResponse.ok()).toBe(true)
const artifacts: Artifact[] = await artifactsResponse.json()
expect(artifacts).toHaveLength(3)
expect(artifacts.map(artifact => artifact.id).sort()).toEqual([...attachmentIds].sort())
const expectedParts: Part[] = uploads.map(upload => {
const artifact = artifacts.find(artifact => artifact.id === upload.artifactId)!
expect(artifact.name).toBe(upload.filename)
return { type: 'artifact_ref', artifact: { artifactId: artifact.id, name: artifact.name, kind: artifact.kind } }
})
const messagePath = `/api/conversations/${conversation.id}/messages`
const [sendRequest, sendResponse] = await Promise.all([
page.waitForRequest(request => request.method() === 'POST' && new URL(request.url()).pathname === messagePath),
page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === messagePath),
page.getByRole('button', { name: '发送', exact: true }).click(),
])
const sent = sendRequest.postDataJSON()
expect(sent.text).toBe('')
expect(sent.attachmentIds).toEqual(attachmentIds)
expect(sendResponse.ok()).toBe(true)
const accepted: { message: Message; run: Run } = await sendResponse.json()
expect(accepted.message.role).toBe('user')
expect(accepted.message.conversationId).toBe(conversation.id)
expect(accepted.message.parts).toEqual(expectedParts)
expect(accepted.run.kind).toBe('conversation')
await expect.poll(async () => {
const response = await request.get(`/api/conversations/${conversation.id}`)
expect(response.ok()).toBe(true)
const current: { runs: Run[] } = await response.json()
return current.runs.some(run => run.id === accepted.run.id && run.kind === 'conversation' && run.status === 'completed')
}).toBe(true)
async function persistedMessage() {
const response = await request.get(`/api/conversations/${conversation.id}`)
expect(response.ok()).toBe(true)
const current: { messages: Message[] } = await response.json()
const users = current.messages.filter(message => message.role === 'user')
expect(users).toHaveLength(1)
expect(users[0].id).toBe(accepted.message.id)
expect(users[0].conversationId).toBe(conversation.id)
expect(users[0].parts).toEqual(expectedParts)
return users[0]
}
const beforeReload = await persistedMessage()
await page.reload()
await expect(page.locator('.chat-title')).toHaveText('真实接口验收')
const afterReload = await persistedMessage()
expect(afterReload).toEqual(beforeReload)
const association = { projectId: project.id, conversationId: conversation.id, runId: accepted.run.id,
uploads: uploads.map(({ artifactId, filename }) => ({ artifactId, filename })),
sent: { text: sent.text, attachmentIds: sent.attachmentIds }, beforeReload, afterReload }
await testInfo.attach('real-material-associations', { body: Buffer.from(JSON.stringify(association, null, 2)), contentType: 'application/json' })
console.log('real-material-associations', JSON.stringify(association))
await page.getByRole('button', { name: '打开文件', exact: true }).click()
await expect(page.locator('[data-artifact-row]')).toHaveCount(3)
await page.locator('[data-artifact-row]').first().click()
await expect(page.locator('.preview-pane-name')).toBeVisible()
expect(errors).toEqual([])
})
test('real background card and HITL decision survive query reconciliation', async ({ page, request }) => {
const { conversation } = await openRealConversation(page, request)
const errors: string[] = []
page.on('pageerror', error => errors.push(error.message))
const input = page.getByRole('textbox', { name: '工作目标或消息', exact: true })
// Backend's deterministic runner exercises the real run/card/event lifecycle;
// this is not a live-model quality or stage-findings test.
await input.fill('#background')
await page.getByRole('button', { name: '发送', exact: true }).click()
await expect.poll(async () => {
const out = await request.get(`/api/conversations/${conversation.id}/cards`).then(r => r.json())
return out.cards?.some((card: { kind: string; status: string }) => card.kind === 'progress' && ['done', 'failed'].includes(card.status))
}, { timeout: 15000 }).toBe(true)
await expect(page.locator('[data-conversation-work]')).toBeVisible()
await expect(page.getByRole('button', { name: '发送', exact: true })).toBeVisible()
await input.fill('#hitl')
await page.getByRole('button', { name: '发送', exact: true }).click()
await expect(page.locator('.hitl-card')).toBeVisible()
await page.locator('.hitl-card').getByRole('button', { name: /Markdown 文档/ }).click()
await expect(page.locator('.hitl-card')).toHaveCount(0)
await expect.poll(async () => {
const current = await request.get(`/api/conversations/${conversation.id}`).then(r => r.json())
return current.activeHitl.length
}).toBe(0)
expect(errors).toEqual([])
})
+141
View File
@@ -0,0 +1,141 @@
import { expect, test, type Page } from '@playwright/test'
import type { Artifact, Conversation, SurfaceMeta, SurfaceSnapshot } from '../src/contracts'
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
const stamp = '2026-10-12T10:00:00Z'
const conversation: Conversation = {
id: 'navigation-conversation', projectId: 'navigation-project', title: '继续阅读材料',
createdAt: stamp, updatedAt: stamp,
}
const artifact: Artifact = {
id: 'navigation-document', projectId: conversation.projectId, name: '材料摘要.md', kind: 'markdown',
size: 64, createdAt: stamp, updatedAt: stamp,
}
const snapshot: SurfaceSnapshot = {
identity: { surfaceId: 'saved-navigation-page', owner: 'reader', pluginId: 'generative-ui', conversationId: conversation.id },
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, revision: 1, activationEpoch: 1,
spec: { title: '已保存的材料页面', nodes: [
{ id: 'summary', type: 'markdown', markdown: '已保存的材料范围' },
{ id: 'source', type: 'doc-ref', docId: artifact.id },
] },
}
const metadata: SurfaceMeta = {
surfaceId: snapshot.identity.surfaceId, title: snapshot.spec.title, pluginId: snapshot.identity.pluginId,
revision: snapshot.revision, conversationId: conversation.id, updatedAt: stamp,
}
// Delay the external list contract, while running the real store, App and panes.
// The saved selection is restored by openConversation rather than test-only state injection.
async function fixture(page: Page) {
const errors: string[] = []
page.on('pageerror', error => errors.push(error.message))
let release!: () => void
const hydration = new Promise<void>(resolve => { release = resolve })
await page.addInitScript(({ conversationId }) => {
localStorage.setItem('as.conv', conversationId)
localStorage.setItem('as.ui', JSON.stringify({
theme: 'light', rightTab: 'files', filesView: 'preview', rightCollapsed: true,
}))
}, { conversationId: conversation.id })
await page.route('**/api/**', async route => {
const request = route.request()
const path = new URL(request.url()).pathname
if (request.method() !== 'GET') return route.fulfill({ status: 405, body: 'read-only navigation fixture' })
if (path === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': connected\n\n' })
let body: unknown = []
if (path === '/api/health') body = { ok: true }
else if (path === '/api/me') body = { owner: 'reader', name: '材料阅读者', auth: true, authVia: 'session', isAdmin: false }
else if (path === '/api/projects') body = [{ id: conversation.projectId, name: '材料项目', createdAt: stamp, updatedAt: stamp }]
else if (path === '/api/conversations') body = [conversation]
else if (path === `/api/conversations/${conversation.id}`) body = { conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 }
else if (path === `/api/conversations/${conversation.id}/surfaces`) { await hydration; body = [metadata] }
else if (path === `/api/surfaces/${snapshot.identity.surfaceId}`) body = snapshot
else if (path === `/api/conversations/${conversation.id}/cards`) body = { cards: [] }
else if (path === `/api/projects/${conversation.projectId}/artifacts`) body = [artifact]
else if (path === `/api/artifacts/${artifact.id}/content`) {
return route.fulfill({ contentType: 'text/markdown', body: '# 材料摘要\n\n原始材料的阅读范围' })
} else if (path === '/api/settings') body = { user: {}, project: {}, effective: {}, schema: [] }
else if (path === '/api/workspaces') body = [{ id: 'navigation-materials', title: '材料工作区',
body: [{ type: 'doc-ref', docId: artifact.id }] }]
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
const listRequest = page.waitForRequest(request => new URL(request.url()).pathname === `/api/conversations/${conversation.id}/surfaces`)
await page.goto('/')
await listRequest
await expect(page.locator('.chat-title')).toHaveText(conversation.title)
return { release, errors }
}
for (const width of [736, 390]) {
test(`keeps context and focus when saved Surface hydration arrives at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
const state = await fixture(page)
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
const search = page.getByRole('textbox', { name: '搜索本项目会话', exact: true })
await search.fill('材料')
await expect(search).toBeFocused()
state.release()
// The real hidden preview proves both restored tab selection and snapshot hydration finished.
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'context')
await expect(page.locator('.left-nav')).toBeVisible()
await expect(page.locator('.main')).toBeHidden()
await expect(page.locator('.right-panel')).toBeHidden()
await expect(search).toHaveValue('材料')
await expect(search).toBeFocused()
expect(state.errors).toEqual([])
})
test(`preserves explicit work, Surface, document and return navigation at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
const state = await fixture(page)
state.release()
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'work')
await expect(page.locator('.right-panel')).toBeVisible()
await expect(page.locator('.left-nav')).toBeHidden()
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
await page.getByRole('button', { name: '打开文件', exact: true }).click()
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files')
await page.getByRole('button', { name: '返回文件列表', exact: true }).click()
await page.locator(`[data-surface-row="${snapshot.identity.surfaceId}"]`).click()
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
// Surface doc-ref uses the existing document bridge, which opens the same preview.
await page.locator('.preview-pane').getByRole('button', { name: artifact.id, exact: true }).click()
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围')
await page.getByRole('button', { name: '展开预览', exact: true }).click()
await expect(page.locator('.right-panel')).toHaveClass(/expanded/)
await page.getByRole('button', { name: '返回对话', exact: true }).click()
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation')
await expect(page.locator('.main')).toBeVisible()
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
await page.setViewportSize({ width: 1440, height: 900 })
await expect(page.locator('.main')).toBeVisible()
await page.setViewportSize({ width, height: 900 })
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation')
await expect(page.locator('.left-nav')).toBeHidden()
expect(state.errors).toEqual([])
})
test(`opens a workspace document while context overlays the selected files pane at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
const state = await fixture(page)
state.release()
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
await page.getByRole('button', { name: '打开文件', exact: true }).click()
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
await page.locator('.left-nav').getByRole('button', { name: /材料工作区/ }).click()
const workspace = page.getByRole('dialog', { name: '材料工作区', exact: true })
await workspace.getByRole('button', { name: artifact.name, exact: true }).click()
await page.keyboard.press('Escape')
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files')
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围')
expect(state.errors).toEqual([])
})
}
+228
View File
@@ -0,0 +1,228 @@
import { test, expect, type Page } from '@playwright/test'
import type { ActivityCard, Artifact, Conversation, StreamEvent } from '../src/contracts'
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
const stamp = '2026-10-12T10:00:00Z'
const project = { id: 'p1', name: '产品升级计划', createdAt: stamp, updatedAt: stamp }
const conversations: Conversation[] = [
{ id: 'c1', projectId: 'p1', title: '整理项目材料', createdAt: stamp, updatedAt: stamp },
{ id: 'c2', projectId: 'p1', title: '讨论本周工作', createdAt: stamp, updatedAt: stamp },
]
const artifact: Artifact = { id: 'doc1', projectId: 'p1', name: '材料整理摘要.md', kind: 'markdown', size: 64, createdAt: stamp, updatedAt: stamp }
function work(id: string, status: ActivityCard['status'], over: Partial<ActivityCard> = {}): ActivityCard {
return { id, status, projectId: 'p1', conversationId: 'c1', kind: 'progress', title: '整理项目材料', createdAt: stamp, updatedAt: stamp, ...over }
}
const progress = work('work1', 'active', { body: [{ type: 'kv', kv: [
['状态', '进行中'], ['正在做', '核对会议结论'], ['当前发现', '两份材料的时间安排需要核对'], ['剩余', '评审意见仍待核查'], ['材料键', 'private-material-key'],
] }] })
const confirmation = work('confirm1', 'action_required', { kind: 'confirm', title: '生成项目草稿',
body: [{ type: 'markdown', markdown: '依据三份材料新增一份草稿,保留原件,不对外发送' }],
actions: [{ id: 'confirm', label: '确认生成', style: 'primary' }, { id: 'cancel', label: '暂不生成' }],
})
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14) {
let cards = [...initialCards]
let actionCount = 0
let releaseAction: (() => void) | undefined
let holdAction = false
let sent: { text: string; attachmentIds: string[] } | undefined
let uploadNumber = 0
const errors: string[] = []
page.on('pageerror', error => errors.push(error.message))
await page.addInitScript(({ fontSize }) => {
localStorage.setItem('as.conv', 'c1')
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
const streams: { url: string; emit: (event: unknown) => void }[] = []
class TestEventSource {
onopen: (() => void) | null = null
onerror = null
onmessage: ((event: { data: string }) => void) | null = null
listeners: Record<string, ((event: { data: string }) => void)[]> = {}
constructor(public url: string) { streams.push(this); queueMicrotask(() => this.onopen?.()) }
addEventListener(type: string, callback: (event: { data: string }) => void) { (this.listeners[type] ??= []).push(callback) }
emit(event: unknown) {
const value = event as { type: string }
for (const callback of this.listeners[value.type] ?? []) callback({ data: JSON.stringify(event) })
}
close() {}
}
Object.assign(window, { EventSource: TestEventSource, __workStreams: streams })
}, { fontSize })
await page.route('**/api/**', async route => {
const request = route.request()
const url = new URL(request.url())
const path = url.pathname
let body: unknown = []
if (path === '/api/health') body = { ok: true }
else if (path === '/api/me') body = { owner: 'reader', name: '林老师', auth: false, isAdmin: false }
else if (path === '/api/projects') body = [project]
else if (path === '/api/conversations') body = conversations
else if (path === '/api/projects/p1/conversations' && request.method() === 'POST') body = conversations[1]
else if (/^\/api\/conversations\/c[12]$/.test(path)) {
const id = path.split('/').at(-1)!
body = { conversation: conversations.find(c => c.id === id), messages: id === 'c1' ? [
{ id: 'm1', conversationId: id, role: 'user', parts: [{ type: 'text', text: '请整理这三份材料,看看需要优先处理什么' }], createdAt: stamp },
{ id: 'm2', conversationId: id, role: 'assistant', parts: [{ type: 'text', text: '先核对时间安排,再补齐验收条件。其他内容仍在整理。' }], createdAt: stamp },
] : [], runs: [], activeHitl: [], lastSeq: 10 }
} else if (path === '/api/conversations/c1/cards') body = { cards }
else if (path === '/api/conversations/c2/cards') body = { cards: [] }
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
else if (path === '/api/projects/p1/artifacts') body = [artifact]
else if (path === '/api/artifacts/doc1/content') {
await route.fulfill({ status: 200, contentType: 'text/markdown', body: '# 材料整理摘要\n\n已整理两份材料,评审意见仍待核对' }); return
} else if (path === '/api/settings') body = { user: { runtime: { mock: false } }, project: {}, effective: {}, schema: [] }
else if (path === '/api/profiles') body = [{ id: 'default', name: '工作助手', model: {}, tools: { allow: [] }, context: { maxHistoryMessages: 20 }, hitl: { askOnAmbiguity: true }, systemPrompt: '' }]
else if (path === '/api/projects/p1/uploads') body = { artifactId: 'upload' + ++uploadNumber, filename: '材料.txt', size: 16 }
else if (/\/messages$/.test(path) && request.method() === 'POST') {
sent = request.postDataJSON()
body = { message: { id: 'sent', conversationId: 'c1', role: 'user', parts: [{ type: 'text', text: sent?.text ?? '' }], createdAt: stamp }, run: { id: 'run1', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'running', createdAt: stamp } }
} else if (path === '/api/cards/confirm1/actions/confirm') {
actionCount++
if (holdAction) await new Promise<void>(resolve => { releaseAction = resolve })
cards = cards.map(card => card.id === 'confirm1' ? { ...card, status: 'resolved' } : card)
body = { card: cards.find(card => card.id === 'confirm1') }
}
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
})
await page.goto('/')
await expect(page.locator('.chat-title')).toHaveText('整理项目材料')
if (initialCards.length) await expect(page.locator('[data-conversation-work]')).toBeVisible()
return { errors, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() },
async emit(type: StreamEvent['type'], payload: unknown, seq: number) {
await page.evaluate(({ type, payload, seq }) => {
const win = window as typeof window & { __workStreams: { url: string; emit: (e: unknown) => void }[] }
const stream = win.__workStreams.findLast(s => s.url.includes('conversationId=c1'))
if (!stream) throw Error('conversation stream unavailable')
stream.emit({ type, payload, seq, conversationId: 'c1', ts: '2026-10-12T10:00:00Z', v: 1 })
}, { type, payload, seq })
},
}
}
test('keeps real summary and details accessible with right pane closed', async ({ page }) => {
const state = await fixture(page)
await expect(page.locator('.right-panel')).toBeHidden()
await expect(page.getByText('核对会议结论', { exact: true }).first()).toBeVisible()
await expect(page.getByText('private-material-key')).toHaveCount(0)
await page.locator('[data-conversation-work]').getByRole('button', { name: '查看依据与范围', exact: true }).click()
await page.locator('[data-conversation-work]').getByRole('button', { name: '查看执行记录', exact: true }).click()
await expect(page.locator('[data-conversation-work]').getByText('private-material-key')).toBeVisible()
await state.emit('card.status', { cardId: 'work1', status: 'done' }, 12)
await state.emit('card.status', { cardId: 'work1', status: 'active' }, 11)
await expect(page.locator('[data-conversation-work] .acard-status')).toHaveText('已完成')
expect(state.errors).toEqual([])
})
test('keeps one expanded ordinary summary when the desktop work pane opens', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 })
const state = await fixture(page)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await expect(page.locator('.right-panel .acard')).toHaveCount(1)
await expect(page.locator('[data-conversation-work] .acard')).toHaveCount(0)
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: /整理项目材料 · 查看摘要/ })).toBeVisible()
if (process.env.WORK_COMPANION_SCREENSHOT) await page.screenshot({ path: process.env.WORK_COMPANION_SCREENSHOT, fullPage: true })
expect(state.errors).toEqual([])
})
test('preserves the selected material, physical scroll and focus when background work finishes', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 })
const tabbed = work('work1', 'active', { body: [...progress.body!, {
type: 'tabs', id: 'materials', tabs: { tabs: [
{ id: 'first', label: '第一份材料', nodes: [{ type: 'markdown', markdown: '第一份材料的范围' }] },
{ id: 'second', label: '第二份材料', nodes: [{ type: 'markdown', markdown: Array.from({ length: 70 }, (_, i) => `待核对事项 ${i + 1}`).join('\n\n') }] },
] },
}] })
const state = await fixture(page, [tabbed, work('peer', 'active', { body: [] })])
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const card = page.locator('.right-panel [data-card-id="work1"]')
await card.getByRole('button', { name: '查看依据与范围', exact: true }).click()
const tab = card.getByRole('tab', { name: '第二份材料', exact: true })
await tab.click()
await tab.focus()
const details = card.locator('.acard-detail-body')
await expect.poll(() => details.evaluate(element => element.clientHeight)).toBe(420)
const physical = await details.evaluate(element => {
Object.assign(window, { __workReading: element })
element.scrollTop = 137
return { height: element.clientHeight, scrollHeight: element.scrollHeight, top: element.scrollTop }
})
expect(physical.scrollHeight).toBeGreaterThan(physical.height)
expect(physical.top).toBe(137)
await state.emit('card.status', { cardId: 'work1', status: 'done' }, 12)
await expect(card.locator('.acard-status')).toHaveText('已完成')
await expect(tab).toHaveAttribute('aria-selected', 'true')
expect(await details.evaluate(element => ({
retained: (window as typeof window & { __workReading: Element }).__workReading === element,
top: element.scrollTop,
}))).toEqual({ retained: true, top: 137 })
expect(await tab.evaluate(element => document.activeElement === element)).toBe(true)
expect(state.errors).toEqual([])
})
test('shares submitting feedback between conversation and work confirmations', async ({ page }) => {
const state = await fixture(page, [confirmation])
await page.getByRole('button', { name: '打开工作', exact: true }).click()
state.hold()
await page.locator('[data-conversation-work]').getByRole('button', { name: '确认生成', exact: true }).click()
await expect(page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true })).toBeDisabled()
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: '暂不生成', exact: true })).toBeDisabled()
expect(state.actionCount).toBe(1)
state.release()
await expect(page.getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
expect(state.errors).toEqual([])
})
test('opens the one file preview and preserves reading during background updates', async ({ page }) => {
const result = work('result1', 'done', { kind: 'result', title: '材料摘要已准备好', body: [{ type: 'doc-ref', docId: 'doc1' }] })
const state = await fixture(page, [result])
await page.locator('[data-conversation-work]').getByText(artifact.name, { exact: true }).click()
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
await expect(page.locator('.preview-pane-body')).toContainText('评审意见仍待核对')
await state.emit('surface.updated', { identity: { surfaceId: 'new-page', owner: 'reader', pluginId: 'generative-ui', conversationId: 'c1' }, revision: 1, activationEpoch: 0, compat: { schemaVersion: 1, catalogVersion: '1' }, spec: { title: '新任务页面', nodes: [] } }, 11)
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
await page.getByRole('button', { name: '展开预览', exact: true }).click()
await expect(page.locator('.main')).toBeHidden()
await page.getByRole('button', { name: '返回对话', exact: true }).click()
await expect(page.locator('.main')).toBeVisible()
expect(state.errors).toEqual([])
})
test('sends three attachments without text and states foreground queueing truthfully', async ({ page }) => {
const state = await fixture(page, [])
await page.locator('input[type=file]').first().setInputFiles([
{ name: '会议纪要.txt', mimeType: 'text/plain', buffer: Buffer.from('会议内容') },
{ name: '需求说明.txt', mimeType: 'text/plain', buffer: Buffer.from('需求内容') },
{ name: '评审意见.txt', mimeType: 'text/plain', buffer: Buffer.from('评审内容') },
])
await expect(page.locator('.attach-chip')).toHaveCount(3)
await page.getByRole('button', { name: '发送', exact: true }).click()
await expect.poll(() => state.sent?.attachmentIds.length).toBe(3)
expect(state.sent?.text).toBe('')
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('请优先核对时间安排')
await expect(page.getByRole('button', { name: '加入队列', exact: true })).toBeEnabled()
await page.getByRole('button', { name: '加入队列', exact: true }).click()
await expect(page.getByText('请优先核对时间安排', { exact: true })).toBeVisible()
await expect(page.locator('.input-status')).toContainText('排队')
expect(state.errors).toEqual([])
})
for (const width of [1440, 1024, 736, 390, 320]) {
test(`keeps navigation and key actions usable at ${width}px with large UI type`, async ({ page }) => {
await page.setViewportSize({ width, height: 960 })
const state = await fixture(page, [progress], 18)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await expect(page.locator('.right-panel')).toBeVisible()
await expect(page.locator('.right-panel').getByText('评审意见仍待核查')).toBeVisible()
if (width < 1024) {
await page.getByRole('button', { name: '返回对话', exact: true }).click()
await expect(page.locator('.main')).toBeVisible()
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
await expect(page.getByRole('button', { name: '新建对话', exact: true })).toBeVisible()
await page.getByRole('button', { name: '返回对话', exact: true }).click()
}
const layout = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }))
expect(layout.scroll).toBeLessThanOrEqual(layout.width)
expect(state.errors).toEqual([])
})
}
+46
View File
@@ -19,6 +19,7 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@types/node": "^22.20.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
@@ -1212,6 +1213,22 @@
"win32"
]
},
"node_modules/@playwright/test": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2749,6 +2766,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+3 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"preview": "vite preview"
"preview": "vite preview",
"e2e": "playwright test"
},
"dependencies": {
"docx-preview": "^0.4.1",
@@ -21,6 +22,7 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@types/node": "^22.20.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
+20
View File
@@ -0,0 +1,20 @@
// U4 运行验收(Playwright):真实浏览器走通追溯审计工作区四入口。
// 由 Dev 仓 scripts/e2e.sh 驱动(起 RDM 二进制 + preview 静态前端 + 种子数据);
// PLAYWRIGHT_BASE_URL 未设置时整组跳过(CI 单测档不拉浏览器)。
import { defineConfig } from '@playwright/test'
const base = process.env.PLAYWRIGHT_BASE_URL || ''
export default defineConfig({
testDir: 'e2e',
testMatch: /.*(spec|playwright)\.ts$/,
timeout: 30_000,
retries: 0,
workers: 1,
reporter: [['list']],
use: {
baseURL: base,
locale: 'zh-CN',
trace: 'retain-on-failure',
},
})
+101 -49
View File
@@ -16,6 +16,8 @@ import { SettingsModal } from './components/SettingsModal'
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
import { TopBar } from './components/TopBar'
import { workspaceEntries } from './components/Workspaces'
import { selectWorkbenchLayout } from './workbenchLayout'
import './companion-shell.css'
// 布局(三栏工作台):左(项目与会话)/ 中(对话)/ 第三栏(工作|文件双入口)。
// 文件预览不再是独立第四栏——预览在第三栏「文件」入口内,展开态铺满主区。
@@ -24,36 +26,79 @@ const RIGHT_MIN = 280, RIGHT_MAX = 560
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
export function App() {
useEffect(() => {
const h = (e: KeyboardEvent) => workspaceShortcuts(e)
window.addEventListener('keydown', h, true)
return () => window.removeEventListener('keydown', h, true)
}, [])
const ui = useStore(s => s.ui)
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
// 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
const previewOpenIntent = useStore(s => s.previewOpenIntent)
const rightTab = useStore(s => s.ui.rightTab)
const toast = useStore(s => s.toast)
const trackerRunId = useStore(s => s.ui.trackerRunId)
// 文件入口 + 预览标签在场时,第三栏用「阅读档」宽度(420–1100),不受工作档上限约束。
const filesWide = rightTab === 'files' && previewOpen
const rightPanelWidth = filesWide ? ui.previewWidth : ui.rightWidth
const previewExpanded = filesWide && ui.previewExpanded
const filesWide = rightTab === 'files' && ui.filesView === 'preview' && previewOpen
const [viewportWidth, setViewportWidth] = useState(() => typeof window === 'undefined' ? 1440 : window.innerWidth)
const [contextOpen, setContextOpen] = useState(false)
const layout = selectWorkbenchLayout({ ...ui, width: viewportWidth, contextOpen, previewOpen })
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
// authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。
const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking')
const [mustChangeOwner, setMustChangeOwner] = useState('')
useEffect(() => {
const onResize = () => setViewportWidth(window.innerWidth)
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
// Explicit openings leave mobile context even if pane values match; metadata restores keep the reading choice.
useEffect(() => { setContextOpen(false) }, [ui.rightCollapsed, rightTab, ui.filesView, ui.previewExpanded, previewOpenIntent])
useEffect(() => { if (!layout.compact) setContextOpen(false) }, [layout.compact])
const returnToConversation = () => {
setContextOpen(false)
store.setUI(layout.compact ? { rightCollapsed: true, previewExpanded: false } : { previewExpanded: false })
}
const openContext = () => {
if (!layout.compact) store.toggleLeft()
else if (contextOpen) returnToConversation()
else setContextOpen(true)
}
const openWork = () => {
setContextOpen(false)
store.openInbox()
store.setUI({ inboxFilter: 'all', previewExpanded: false })
}
const openPending = () => { setContextOpen(false); store.focusPending() }
const openFiles = () => { setContextOpen(false); store.openFiles() }
const conversationOpened = () => { if (layout.compact) returnToConversation() }
// Keep conversation/context shortcuts available even when their pane is hidden.
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (authState === 'ok' && (e.metaKey || e.ctrlKey)) {
const key = e.key.toLowerCase()
if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened); return }
if (key === 'b') { e.preventDefault(); openContext(); return }
}
workspaceShortcuts(e)
}
window.addEventListener('keydown', h, true)
return () => window.removeEventListener('keydown', h, true)
}, [authState, layout.compact, contextOpen])
useEffect(() => {
;(async () => {
try {
const r = await auth.fetch('/api/health')
if (r.status === 401) { setAuthState('login'); return }
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
// key 持有者清 key 回登录页,改走密码登录完成改密。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) { setMustChangeOwner(me.owner); setAuthState('mustchange'); return }
if (me?.mustChangePassword) {
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
}
setAuthState('ok')
} catch {
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
@@ -63,6 +108,10 @@ export function App() {
useEffect(() => {
if (authState !== 'ok') return
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
store.refreshMe()
store.refreshProjects()
store.refreshProfiles()
store.refreshPlugins()
@@ -73,10 +122,10 @@ export function App() {
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
e.preventDefault()
// 右侧分隔条按当前入口选宽度档:文件+预览在场走阅读档,否则工作档。
const wide = side === 'right' && rightTab === 'files' && previewOpen
const wide = side === 'right' && filesWide
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
drag.current = {
side, startX: e.clientX, startW: ui[key], overshoot: false,
side, startX: e.clientX, startW: side === 'left' ? layout.leftWidth : layout.rightWidth, overshoot: false,
min: side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN,
max: side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX,
key,
@@ -106,6 +155,18 @@ export function App() {
window.addEventListener('mouseup', up)
}
const onDividerKey = (side: 'left' | 'right') => (e: React.KeyboardEvent) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const wide = side === 'right' && filesWide
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
const min = side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN
const max = side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX
const current = side === 'left' ? layout.leftWidth : layout.rightWidth
const delta = (e.key === 'ArrowRight' ? 16 : -16) * (side === 'left' ? 1 : -1)
store.setUI({ [key]: Math.min(max, Math.max(min, current + delta)) })
}
if (authState === 'login') {
return <LoginPage
onAuthed={() => setAuthState('ok')}
@@ -123,22 +184,20 @@ export function App() {
const tools = workspaceEntries()
return (
<div className="app">
<TopBar />
<div className="app-body">
{!ui.leftCollapsed ? (
<>
<div className="left-nav" style={{ width: ui.leftWidth }}>
<TaskSidebar />
<div className={`app companion-app${layout.compact ? ' compact' : ''}`}>
<TopBar compact={layout.compact} activePane={layout.pane} showReturn={layout.previewExpanded}
onContext={openContext} onConversation={returnToConversation} onWork={openWork} onFiles={openFiles} />
<div className="app-body" data-active-pane={layout.pane}>
<aside className={`left-nav${layout.contextCollapsed ? ' collapsed' : ''}`} aria-label="上下文"
hidden={!layout.showContext} style={{ width: layout.leftWidth }}>
<div className="shell-context-content" hidden={layout.contextCollapsed}>
<TaskSidebar onCollapse={layout.compact ? returnToConversation : store.toggleLeft}
onConversationOpen={conversationOpened} onFilesOpen={openFiles} onWorkOpen={openPending} />
</div>
<div className="divider" onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft} title="拖动调整宽度;继续向左超过最小宽度后松开可收起;双击切换" />
</>
) : (
<div className="left-nav collapsed" style={{ width: 52 }}>
<div className="icon-only">
<div className="icon-only" hidden={!layout.contextCollapsed}>
<button title="展开侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
<button title="新建任务(⌘N)" onClick={() => store.createTask()}><Icon name="plus" /></button>
<button title="收件箱" onClick={() => store.focusPending()}><Icon name="inbox" />
<button title="新建对话(⌘N)" onClick={() => store.createTask()}><Icon name="plus" /></button>
<button title="工作" onClick={openWork}><Icon name="inbox" />
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
</button>
{tools.map(entry => (
@@ -146,31 +205,24 @@ export function App() {
))}
<button title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" /></button>
</div>
</div>
)}
</aside>
{layout.leftDivider && <div className="divider" role="separator" tabIndex={0} aria-label="调整上下文宽度" aria-orientation="vertical"
aria-valuemin={LEFT_MIN} aria-valuemax={LEFT_MAX} aria-valuenow={Math.round(layout.leftWidth)}
onKeyDown={onDividerKey('left')} onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft}
title="拖动或用方向键调整宽度;双击收起" />}
<div className="main" hidden={previewExpanded}>
<Conversation />
</div>
<main className="main" hidden={!layout.showConversation} aria-label="对话">
<Conversation />
</main>
{!ui.rightCollapsed ? (
<>
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
<div className={`right-panel mk-panel-grow${previewExpanded ? ' expanded' : ''}`}
style={previewExpanded ? undefined : { width: rightPanelWidth }}>
<Workbench />
</div>
</>
) : (
<div className="right-panel collapsed" style={{ width: 44 }}>
<div className="icon-only">
<button title="展开右栏" onClick={store.toggleRight}>◧
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
</button>
<button title="文件(搜索、预览、编辑、下载)" onClick={() => store.openFiles()}><Icon name="folder" size={16} /></button>
</div>
</div>
)}
{layout.rightDivider && <div className="divider" role="separator" tabIndex={0} aria-label="调整工作或文件宽度" aria-orientation="vertical"
aria-valuemin={filesWide ? PREVIEW_MIN : RIGHT_MIN} aria-valuemax={filesWide ? PREVIEW_MAX : RIGHT_MAX}
aria-valuenow={Math.round(layout.rightWidth)} onKeyDown={onDividerKey('right')}
onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动或用方向键调整宽度;双击收起" />}
<aside className={`right-panel mk-panel-grow${layout.previewExpanded ? ' expanded' : ''}`}
aria-label={rightTab === 'files' ? '文件' : '工作'} hidden={!layout.showRight} style={{ width: layout.rightWidth }}>
<Workbench />
</aside>
</div>
<Workspaces />
+31 -5
View File
@@ -4,11 +4,16 @@ import { auth } from './auth'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
ActivityCard,
ActivityCard, SurfaceMeta, SurfaceSnapshot,
} from './contracts'
const BASE = ''
/** 管理区声明分区(WP3-I3/KTD4,backend 专属端点类型):编译插件经
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
* 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
async function json<T>(res: Response): Promise<T> {
if (!res.ok) {
const text = await res.text()
@@ -30,9 +35,14 @@ export const api = {
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(r)),
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
/** 会话内页面目录:扁平 SurfaceMeta(无 identity/spec),不可直接写入 surfaces 投影。 */
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
@@ -110,8 +120,9 @@ export const api = {
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
adminZoneSections: () => auth.fetch(`${BASE}/api/admin-zone/sections`).then(r => r.json()) as Promise<AdminZoneSection[]>,
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string; enabled?: boolean }[]>,
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
excludeMessage: (id: string, excluded: boolean) => auth.fetch(`${BASE}/api/messages/${id}/exclude`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ excluded }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
selectVariant: (id: string) => auth.fetch(`${BASE}/api/messages/${id}/variant`, { method: 'POST' }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
@@ -127,6 +138,21 @@ export const api = {
savePromptCommand: (body: { name: string; title: string; template: string }) => auth.fetch(`${BASE}/api/prompt-commands`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)),
deletePromptCommand: (id: string) => auth.fetch(`${BASE}/api/prompt-commands/${id}`, { method: 'DELETE' }),
listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>,
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ params }),
}).then(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r))
.then(out => {
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
if (envelope?.receipt?.state === 'rejected') throw new Error(envelope.receipt.error || envelope.receipt.message || '命令被拒绝')
if (envelope?.card) throw new Error('高危命令已生成确认卡——请在对话中确认执行')
if (envelope && envelope.payload !== undefined) return envelope.payload
return out as T
}),
}
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { groupArtifactsByName } from './artifactGrouping'
import type { Artifact } from './contracts'
// 2026-10-11 实测事故:一次 run 内 register_artifact 三次 → 文件面板三份同名
// 转写报告并列。修复语义(用户裁定):存储保留版本、界面按名称分组去重。
function art(id: string, name: string, updatedAt: string, size = 40000): Artifact {
return { id, projectId: 'p1', name, kind: 'document', size, createdAt: updatedAt, updatedAt }
}
describe('groupArtifactsByName', () => {
it('同名 3 行归一组:latest 为最新,versions 新→旧', () => {
const g = groupArtifactsByName([
art('a1', '报告.docx', '2026-10-11T15:22:20Z'),
art('a2', '报告.docx', '2026-10-11T15:24:06Z'),
art('a3', '报告.docx', '2026-10-11T15:23:38Z'),
art('b1', '其他.md', '2026-10-11T15:20:00Z', 100),
])
expect(g).toHaveLength(2)
const grp = g.find(x => x.latest.name === '报告.docx')!
expect(grp.latest.id).toBe('a2') // 最新 15:24:06
expect(grp.versions).toHaveLength(3)
expect(grp.versions.map(v => v.id)).toEqual(['a2', 'a3', 'a1'])
expect(g.find(x => x.latest.name === '其他.md')!.versions).toHaveLength(1)
})
it('不同名互不影响;空列表返回空', () => {
const g = groupArtifactsByName([art('x', 'a.md', 'T1'), art('y', 'b.md', 'T2')])
expect(g).toHaveLength(2)
expect(groupArtifactsByName([])).toEqual([])
})
})
+23
View File
@@ -0,0 +1,23 @@
// 同名工件版本分组(2026-10-11 实测事故的修复语义,用户裁定:存储保留版本、
// 界面去重):组内按 updated_at 新→旧,latest 为面板显示行,其余为历史版本。
import type { Artifact } from './contracts'
export interface ArtifactGroup {
latest: Artifact
versions: Artifact[]
}
export function groupArtifactsByName(list: Artifact[]): ArtifactGroup[] {
const byName = new Map<string, Artifact[]>()
for (const a of list) {
const arr = byName.get(a.name) ?? []
arr.push(a)
byName.set(a.name, arr)
}
const out: ArtifactGroup[] = []
for (const versions of byName.values()) {
versions.sort((x, y) => (x.updatedAt < y.updatedAt ? 1 : x.updatedAt > y.updatedAt ? -1 : 0))
out.push({ latest: versions[0], versions })
}
return out
}
+3
View File
@@ -22,6 +22,9 @@ export const auth = {
apiKey(): string { return storage.getItem(KEY) ?? '' },
setKey(key: string) { storage.setItem(KEY, key.trim()) },
clear() { storage.removeItem(KEY) },
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
beginSession() { this.clear() },
// 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。
fetch(input: string, init?: RequestInit): Promise<Response> {
const key = this.apiKey()
+84
View File
@@ -0,0 +1,84 @@
.work-companion-chat .chat-header {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
padding: 12px 20px;
}
.work-companion-chat .chat-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.work-companion-chat .chat-header > .mk-col-root { position: relative; }
.work-companion-chat .chat-header > .mk-col-root > .mk-col {
position: absolute;
right: 0;
top: calc(100% + 8px);
width: min(28rem, calc(100vw - 40px));
z-index: 30;
}
.companion-settings-trigger, .companion-operation-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
color: var(--text-dim);
font-size: var(--fs-ui-sm);
border-radius: var(--radius-sm);
}
.companion-settings-trigger:hover, .companion-operation-trigger:hover { background: var(--bg-hover); color: var(--text); }
.companion-settings-body {
padding: 16px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
}
.companion-setting { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-bottom: 12px; color: var(--text); font-size: var(--fs-ui-caption); }
.companion-setting > span:first-child { flex: 1; min-width: 8rem; }
.companion-setting select { max-width: 100%; padding: 4px 8px; background: var(--bg-panel); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-caption); }
.companion-setting .session-note { flex: 1 1 100%; min-width: 0; width: 100%; }
.companion-setting-note { flex: 1 1 100%; margin: 0; color: var(--text-dim); font-size: var(--fs-ui-sm); line-height: 1.6; }
.work-companion-chat .chat-body { padding: 24px clamp(16px, 5vw, 64px); }
.work-companion-chat .input-bar { padding: 8px clamp(16px, 5vw, 64px) 16px; }
.work-companion-chat .input-row { flex-direction: column; align-items: stretch; padding: 12px; box-shadow: none; }
.work-companion-chat .input-row textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 4px; line-height: 1.6; font-size: var(--fs-ui-base); }
.companion-compose-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
.companion-compose-spacer { flex: 1; }
.companion-add-material { position: relative; display: inline-flex; align-items: center; gap: 4px; padding: 4px; cursor: pointer; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); }
.companion-add-material:hover { background: var(--bg-hover); color: var(--text); }
.companion-add-material:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.companion-file-input { position: absolute; width: 1px; height: 1px; padding: 0; margin: 0; overflow: hidden; clip-path: inset(50%); }
.companion-input-hint { font-size: var(--fs-ui-sm); color: var(--text-dim); }
.companion-operation-trigger { margin-bottom: 8px; }
.companion-operation-body { padding: 12px; margin-bottom: 12px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); }
.companion-operation-body .companion-setting { margin-bottom: 8px; }
.work-companion-chat .input-status { flex-wrap: wrap; gap: 4px 8px; padding: 8px 0; line-height: 1.5; }
.work-companion-chat .input-status-note { color: var(--text-dim); font-size: var(--fs-ui-sm); }
.companion-reconnect, .companion-upload-status, .companion-action-feedback { padding: 8px 0; color: var(--text-dim); font-size: var(--fs-ui-caption); line-height: 1.5; }
.companion-upload-error { padding: 8px 0; color: var(--err-text); font-size: var(--fs-ui-caption); overflow-wrap: anywhere; }
.companion-remove-material { display: inline-flex; align-items: center; justify-content: center; padding: 4px; color: var(--text-dim); background: transparent; border: 0; border-radius: var(--radius-xs); cursor: pointer; }
.companion-remove-material:hover { background: var(--bg-hover); color: var(--text); }
.work-companion-chat .attach-chip { max-width: 100%; overflow-wrap: anywhere; }
.companion-empty { min-height: 240px; height: 100%; text-align: center; line-height: 1.7; }
.companion-empty-title { color: var(--text); font-size: var(--fs-ui-xl); }
.companion-examples { display: grid; gap: 8px; width: min(100%, 32rem); margin-top: 12px; }
.companion-example { padding: 12px 16px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); color: var(--text-dim); font: inherit; text-align: left; cursor: pointer; }
.companion-example:hover { color: var(--text); background: var(--bg-hover); }
.conversation-work { margin: 24px 0 16px; min-width: 0; }
.conversation-work-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 12px; color: var(--text-dim); font-size: var(--fs-ui-caption); }
.conversation-work-heading > span:first-child { color: var(--text); font-weight: 600; }
.conversation-work-current, .conversation-work-history { display: grid; gap: 12px; min-width: 0; }
.conversation-work-more { padding: 12px 0; color: var(--text-dim); font-size: var(--fs-ui-caption); }
.conversation-work .acard { margin: 0; }
.work-companion-chat .live-bar { flex-wrap: wrap; }
.work-companion-chat .part-error-detail, .work-companion-chat .hitl-detail { overflow-wrap: anywhere; white-space: pre-wrap; }
@media (max-width: 540px) {
.work-companion-chat .chat-header { padding: 12px 16px; gap: 8px; }
.companion-input-hint { display: none; }
.companion-empty { min-height: 200px; }
.work-companion-chat .hitl-choices { grid-template-columns: minmax(0, 1fr); }
}
.work-companion-chat .msg.user .bubble { background: var(--bg-active); }
.companion-message-copy { padding: 4px 8px; border: 0; background: transparent; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); cursor: pointer; }
.companion-message-copy:hover { background: var(--bg-hover); color: var(--text); }
.companion-message-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 0; }
.work-companion-chat .msg-tools { gap: 4px; }
+130
View File
@@ -0,0 +1,130 @@
/* Responsive host chrome; the canonical theme and UI type scale stay in @ui. */
.app.companion-app {
height: 100dvh;
min-width: 0;
max-width: 100%;
}
.companion-app .app-body { min-width: 0; overflow: hidden; }
.companion-app .left-nav,
.companion-app .main,
.companion-app .right-panel { min-height: 0; max-width: 100%; overflow: hidden; }
.companion-app .left-nav { transition: none; }
.companion-app .left-nav[hidden],
.companion-app .right-panel[hidden],
.companion-app .shell-context-content[hidden],
.companion-app .icon-only[hidden] { display: none !important; }
.shell-context-content { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; }
.companion-app .companion-topbar { height: auto; min-height: 44px; gap: 8px; padding: 4px 12px; }
.topbar-identity { display: flex; align-items: center; gap: 8px; min-width: 0; }
.companion-topbar .topbar-name { font-size: var(--fs-ui-caption); white-space: nowrap; }
.companion-topbar .brand-mark {
background: var(--primary-sidebar);
color: var(--primary-sidebar-foreground);
box-shadow: none;
border-radius: var(--radius-sm) !important;
}
.shell-navigation { display: flex; align-items: center; gap: 4px; }
.shell-nav-button,
.shell-return {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 32px;
padding: 4px 12px;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-sidebar-dim);
font-size: var(--fs-ui-caption);
cursor: pointer;
white-space: nowrap;
}
.shell-nav-button:hover,
.shell-return:hover { background: var(--bg-hover); color: var(--text-sidebar); }
.shell-nav-button[aria-pressed='true'] { background: var(--bg-active); color: var(--text-sidebar); }
.shell-nav-button svg,
.shell-return svg { flex: none; }
.shell-back-icon { transform: rotate(180deg); }
.shell-pending {
padding: 0 4px;
border-radius: var(--radius-xs);
background: var(--bg-active);
color: var(--text-sidebar);
font-size: var(--fs-ui-xs);
font-variant-numeric: tabular-nums;
}
.companion-app .tsb-brand { gap: 4px; padding: 16px 12px 8px; }
.companion-app .tsb-projrow { min-height: 36px; height: auto; border-color: transparent; background: none; padding: 4px 8px; }
.companion-app .tsb-projrow:hover { background: var(--bg-hover); }
.companion-app .tsb-projrow-name { flex: 1; }
.companion-app .tsb-projrow-stage { max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); border-radius: var(--radius-sm); }
.companion-app .tsb-iconbtn { width: 32px; height: 32px; flex: none; }
.companion-app .tsb-actions { padding: 8px 12px 12px; gap: 12px; }
.companion-app .tsb-newtask { min-height: 40px; height: auto; box-shadow: none; transition: none; }
.companion-app .tsb-newtask-main { justify-content: center; padding: 8px 12px; }
.companion-app .tsb-newtask-caret { width: 36px; flex: none; }
.companion-app .tsb-search { min-height: 36px; height: auto; padding: 8px 12px; }
.companion-app .tsb-orgrow { padding-bottom: 4px; }
.companion-app .tsb-task { min-height: 40px; height: auto; gap: 4px; transition: none; }
.companion-app .tsb-task-open {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
gap: 8px;
min-height: 40px;
padding: 4px 0;
border: none;
background: none;
color: inherit;
text-align: left;
cursor: pointer;
}
.companion-app .tsb-task-open > svg { flex: none; }
.companion-app .tsb-task-tm { color: var(--text-dim); }
.companion-app .tsb-task:focus-within .tsb-row-acts { display: inline-flex; }
.companion-app .tsb-task:focus-within .tsb-task-tm { display: none; }
.companion-app .tsb-row-menu {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
flex: none;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-dim);
cursor: pointer;
}
.companion-app .tsb-row-menu:hover { background: var(--bg-hover); color: var(--text); }
.companion-app .tsb-bottom { padding: 8px 12px; gap: 4px; }
.companion-app .tsb-tool { min-height: 36px; }
.companion-app .tsb-avatar { background: var(--bg-active); color: var(--text); box-shadow: none; border-radius: var(--radius-full); }
.companion-app .tsb-user-sub { color: var(--text-dim); }
.companion-app .tsb-user { min-height: 44px; height: auto; }
.companion-app .tsb-menu {
min-width: min(216px, calc(100vw - 16px));
max-width: min(360px, calc(100vw - 16px));
max-height: calc(100dvh - 16px);
overflow-y: auto;
}
.companion-app .tsb-mi { overflow-wrap: anywhere; }
.companion-app.compact .companion-topbar { flex-wrap: wrap; padding: 8px; }
.companion-app.compact .shell-navigation { width: 100%; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.companion-app.compact .shell-nav-button { min-width: 0; min-height: 40px; flex-wrap: wrap; gap: 0 4px; padding: 4px; }
.companion-app.compact .shell-nav-button > svg { display: none; }
.companion-app.compact .shell-return { min-height: 36px; padding: 4px 8px; font-size: var(--fs-ui-sm); }
.companion-app.compact .left-nav,
.companion-app.compact .main,
.companion-app.compact .right-panel { flex: 1 1 100%; width: 100%; }
.companion-app.compact .tsb-tool { min-height: 40px; }
.companion-app.compact .tsb-footer { padding: 8px 12px; }
.companion-app.compact .tsb-row-acts,
.companion-app.compact .tsb-task:focus-within .tsb-row-acts { display: none; }
+20 -11
View File
@@ -4,6 +4,7 @@ import { useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { Icon } from '@ui'
import { humanChangePasswordError } from './LoginPage'
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
@@ -20,7 +21,10 @@ function Shell({ title, onClose, children }: { title: string; onClose: () => voi
)
}
/** 主动改密(账户菜单入口):当前密码 + 新密码 + 确认。 */
/** 主动改密(账户菜单入口):用户名(只读关联)+ 当前密码 + 新密码 + 确认。
* 表单语义(2026-10-12 增量 3):<form onSubmit> 承载提交(Enter 可用)、autocomplete
* 三段标注(username/current-password/new-password)、label 与 input 关联;
* 错误文案复用 humanChangePasswordError——401 不再一刀切成「当前密码不正确」。 */
export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
const me = useStore(s => s.me)
const [current, setCurrent] = useState('')
@@ -41,23 +45,28 @@ export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
store.toast('密码已更新')
setTimeout(onClose, 900)
} catch (e) {
setErr(String(e).includes('401') ? '当前密码不正确' : String(e))
setErr(humanChangePasswordError(String(e)))
} finally { setBusy(false) }
}
return (
<Shell title="修改密码" onClose={onClose}>
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
<div className="ad-changepw">
<label className="lg-label">当前密码({me?.owner ?? ''})</label>
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} />
<form className="ad-changepw" onSubmit={e => { e.preventDefault(); submit() }}>
<label className="lg-label" htmlFor="changepw-username">用户名</label>
<input id="changepw-username" className="lg-input" value={me?.owner ?? ''} readOnly autoComplete="username" />
<label className="lg-label" htmlFor="changepw-current">当前密码</label>
<input id="changepw-current" className="lg-input" type="password" autoComplete="current-password"
value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
<label className="lg-label" htmlFor="changepw-next">新密码(≥8 位)</label>
<input id="changepw-next" className="lg-input" type="password" autoComplete="new-password"
value={next} onChange={e => setNext(e.target.value)} />
<label className="lg-label" htmlFor="changepw-confirm">确认新密码</label>
<input id="changepw-confirm" className="lg-input" type="password" autoComplete="new-password"
value={confirm} onChange={e => setConfirm(e.target.value)} />
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
<button className="lg-submit" disabled={busy || !current || !next || !confirm} onClick={submit}>{busy ? '提交中…' : '更新密码'}</button>
</div>
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '更新密码'}</button>
</form>
)}
</Shell>
)
+53 -9
View File
@@ -1,10 +1,16 @@
// 系统管理(重设计方案 阶段②):管理员专属的全屏独立视图(带返回,非弹窗)。
// 系统管理(重设计方案 阶段②):全屏独立视图(带返回,非弹窗)。
// 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。
// 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。
// WP3-I3/KTD4:编译插件可经 Registry.AddAdminZone 声明展示分区(服务端
// /api/admin-zone/sections 下发);声明分区对全部认证主体开放,区内数据
// 能力由分区组件自身的命令面 fail-closed——前端只消费、不合并角色。
import { useState } from 'react'
import { store, useStore } from '../store'
import { Icon, type IconName } from '@ui'
import { EmptyState, Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import { RdmRolesPanel } from './adminRdmSections'
import type { AdminZoneSection } from '../api'
interface AdminSectionDef {
key: string
@@ -22,17 +28,55 @@ function ProfilesPanelWrapper() {
const SECTIONS: AdminSectionDef[] = [
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
]
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
const DECLARED_COMPONENTS: Record<string, () => React.ReactNode> = {
'rdm.audit': () => <RdmAuditWorkspace />,
}
/** icon 走基座 IconName 词表;声明携带未知图标时回退通用图标(不渲染空白)。 */
const ICONS: IconName[] = ['plus', 'search', 'panel-left', 'inbox', 'settings', 'sun', 'moon', 'pin', 'archive', 'undo',
'chevron-right', 'chevron-down', 'folder', 'message', 'package', 'bot', 'puzzle', 'grid', 'arrow-up', 'paperclip',
'more', 'logout', 'key', 'user', 'users', 'shield', 'x', 'check', 'alert', 'send', 'sparkles', 'clock', 'list',
'file', 'image', 'download', 'trash', 'eye', 'maximize', 'minimize']
function declaredIcon(name?: string): IconName {
return (ICONS as string[]).includes(name ?? '') ? (name as IconName) : 'grid'
}
function DeclaredSection({ section }: { section: AdminZoneSection }) {
const render = DECLARED_COMPONENTS[section.id]
if (!render) {
return <EmptyState icon="puzzle">分区「{section.title}」由内置插件声明,但当前前端版本未携带其组件<br />(部署前端与二进制版本不匹配时会出现;升级前端即可)</EmptyState>
}
return <>{render()}</>
}
export function AdminZone() {
const me = useStore(s => s.me)
const declared = useStore(s => s.adminSections)
const rdmRolesAvailable = useStore(s => s.rdmRolesAvailable)
const [sectionKey, setSectionKey] = useState('users')
// 双保险:入口已在菜单层门控(me?.isAdmin),此处再拦一次(纵深防御)。
if (!me?.isAdmin) return null
const section = SECTIONS.find(s => s.key === sectionKey) ?? SECTIONS[0]
const isAdmin = !!me?.isAdmin
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
if (!isAdmin && declared.length === 0) return null
const sections: AdminSectionDef[] = [
...(isAdmin ? SECTIONS.filter(s => s.key !== 'rdm-roles' || rdmRolesAvailable) : []),
...declared.map(s => ({
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
render: () => <DeclaredSection section={s} />,
})),
]
const fallback = sections[0]?.key ?? ''
const section = sections.find(s => s.key === sectionKey) ?? sections[0]
if (!section) return null
return (
<div className="az" role="dialog" aria-label="系统管理">
<div className="az-head">
@@ -40,14 +84,14 @@ export function AdminZone() {
<Icon name="chevron-down" size={14} /> 返回工作台
</button>
<span className="az-title">系统管理</span>
<span className="az-sub">仅管理员可见 · 变更即时生效</span>
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
<span style={{ flex: 1 }} />
<span className="az-tenant">{me.owner}</span>
<span className="az-tenant">{me?.owner ?? ''}</span>
</div>
<div className="az-body">
<nav className="az-side">
{SECTIONS.map(s => (
<button key={s.key} className={`az-nav-item ${s.key === sectionKey ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
{sections.map(s => (
<button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
<Icon name={s.icon} size={15} />
<span>{s.label}</span>
</button>
+79 -18
View File
@@ -3,17 +3,25 @@
import { useState } from 'react'
import { store, useStore } from '../store'
import { api } from '../api'
import { Icon } from '@ui'
import { Collapsible, Icon } from '@ui'
import { artifactIcon, humanSize } from '../messageparts'
import type { Artifact } from '../contracts'
import { groupArtifactsByName } from '../artifactGrouping'
import { surfaceKey } from '../surfaceKey'
export function ArtifactsPanel() {
const artifacts = useStore(s => s.artifacts)
const surfaces = useStore(s => s.surfaces)
const [q, setQ] = useState('')
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
const kinds = ['all', 'document', 'data', 'image'] as const
const visible = artifacts.filter(a =>
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
// 同名工件 = 同一交付物的版本序列(存储保留全量):面板按名称分组去重,
// 只显示最新版;组>1 时可展开看历史版本。2026-10-11 实测 3 份同名转写报告
// 并列的直接修复(存储与提示词语义「修订=新版本」保持不变)。
const groups = groupArtifactsByName(visible)
return (
<div className="artifacts-panel">
@@ -32,26 +40,50 @@ export function ArtifactsPanel() {
}} />
</label>
</div>
{artifacts.length === 0
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
: visible.length === 0
? <div className="empty-note">没有匹配「{q}」的文件</div>
: visible.map(a => (
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
{surfaces.length > 0 && (
<div className="ap-surfaces" data-ap-surfaces>
<div className="ap-section-title">生成页面</div>
{surfaces.filter(sv => surfaceKey(sv) != null).map(sv => {
const id = surfaceKey(sv)!
return (
<div key={id} className="artifact-row" data-surface-row={id}
onClick={() => store.openSurface(id)} title={`打开页面 ${sv.spec?.title ?? ''}`}>
<span className="ar-icon"><Icon name="grid" size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
<span className="ar-acts">
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
<span className="name">{sv.spec?.title || '(未命名页面)'}</span>
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity?.pluginId}</span>
</span>
</div>
))}
)
})}
</div>
)}
{artifacts.length === 0
? <div className="empty-note">暂无文件,可添加材料或在对话中生成文档</div>
: visible.length === 0
? <div className="empty-note">没有匹配「{q}」的文件</div>
: groups.map(g => g.versions.length === 1
? <ArtifactRow key={g.latest.id} a={g.latest} />
: <Collapsible key={g.latest.id} id={'artv:' + g.latest.id}
trigger={
<div className="artifact-row artifact-group" data-artifact-group={g.latest.id}>
<ArtifactRowInner a={g.latest} badge={`${g.versions.length} 版`} />
</div>
}>
{g.versions.slice(1).map(v => (
<div key={v.id} className="artifact-row artifact-version" data-artifact-version={v.id}
onClick={() => store.requestPreview(v)} title={`预览历史版本 ${v.name}`}>
<span className="ar-icon"><Icon name="clock" size={13} /></span>
<span className="ar-main">
<span className="meta">v{shortTime(v.updatedAt)} · {humanSize(v.size) || '—'}</span>
</span>
<span className="ar-acts">
<a className="icon-btn" title="下载此版本" href={api.artifactDownloadUrl(v.id)} download={v.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={13} /></a>
</span>
</div>
))}
</Collapsible>)}
</div>
)
}
@@ -65,3 +97,32 @@ function shortTime(iso: string): string {
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
} catch { return '' }
}
function ArtifactRow({ a }: { a: Artifact }) {
return (
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
<div className="artifact-row" data-artifact-row={a.id}
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
<ArtifactRowInner a={a} />
<span className="ar-acts">
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</span>
</div>
)
}
function ArtifactRowInner({ a, badge }: { a: Artifact; badge?: string }) {
return (
<>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}{badge ? <span className="ver-badge" data-ver-badge>{badge}</span> : null}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
</>
)
}
+163 -105
View File
@@ -13,6 +13,8 @@ import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
import { docBridge } from './Workbench'
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
import { ConversationWork } from './ConversationWork'
import '../companion-conversation.css'
function hasFiles(dt: DataTransfer | null): boolean {
return !!dt && Array.from(dt.types).includes('Files')
@@ -28,9 +30,7 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
return <SafeMarkdown className={live ? 'part-text cursor-blink' : 'part-text'} breaks
markdown={markMissingArtifacts(part.text ?? '', id => !!findArtifact(id))} />
case 'thinking':
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
//(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。)
return <ThinkingBlock part={part} idKey={part.text?.slice(0, 24) ?? 't'} defaultOpen={!!live} />
return null
case 'tool_call':
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
case 'tool_result': {
@@ -105,18 +105,6 @@ function findCard(id: string) {
return cardCache[id]
}
// 思考块(Collapsible 契约):左线弱色,流式默认展开、终态默认收起;展开态记忆。
function ThinkingBlock({ part, idKey, defaultOpen }: { part: Part; idKey: string; defaultOpen?: boolean }) {
return (
<div className="part-thinking">
<Collapsible id={`think:${idKey}`} defaultOpen={defaultOpen} triggerClassName="part-thinking-summary"
trigger={<><span className="part-thinking-chev">▸</span>思考过程</>}>
<div className="part-thinking-body">{part.text}</div>
</Collapsible>
</div>
)
}
// 交付物卡片:主区域和「预览」打开预览区;下载收进菜单,避免和预览抢同一个按钮。
function DeliverableCard({ artifact, updates = 0 }: { artifact: { artifactId: string; name: string; kind?: string }; updates?: number }) {
const [menu, setMenu] = useState(false)
@@ -189,10 +177,14 @@ function useElapsed(startedAt: number): string {
// 流式状态行(独立组件:计时 hook 只在 live 行挂载期间运转)。
function LiveBar({ live }: { live: LiveState }) {
const elapsed = useElapsed(live.startedAt)
const phase = livePhaseLabel(live)
const label = phase.startsWith('正在编写') ? '正在准备操作'
: phase.startsWith('正在运行') ? '正在处理'
: phase === '思考中' ? '正在梳理' : phase
return (
<span className="live-bar">
<span className="live-dot" />
<span className="mk-sweep">{livePhaseLabel(live)}</span>
<span className="mk-sweep">{label}</span>
<span className="live-elapsed">已工作 {elapsed}</span>
</span>
)
@@ -211,15 +203,14 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
const split = useMemo(() => splitTimeline(parts, finished), [parts, finished])
const showProcess = showProcessInline(live ?? null, autoCollapse)
const memKey = `process:${msg.runId || msg.id}`
const touched = !!(msg.runId && touchedProcess.has(msg.runId))
const touched = touchedProcess.has(memKey)
const [open, setOpen] = useState(() => {
if (live && !touched) return true
if (touched) return getPersistedOpen(memKey)
return showProcess
})
useEffect(() => {
if (live && msg.runId && !touchedProcess.has(msg.runId)) setOpen(true)
}, [live, msg.runId])
if (!touchedProcess.has(memKey)) setOpen(showProcess)
}, [memKey, showProcess])
const extras = live ? [] : citedArtifactCards(split.final)
const cards = live ? [] : dedupeDeliverables(split.final)
const lastText = (() => {
@@ -229,13 +220,13 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
const streamingText = !!(live && live.phase === 'responding' && lastText >= 0
&& !live.items.some(it => it.kind === 'tool' && it.state !== 'done'))
const toolCount = split.timeline.filter(p => p.type === 'tool_call').length
const headline = live ? livePhaseLabel(live) : processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)
const headline = live ? '执行记录' : `执行记录 · ${processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)}`
const timeline = (
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
)
const toggleProcess = (next: boolean) => {
if (msg.runId) touchedProcess.add(msg.runId)
touchedProcess.add(memKey)
setPersistedOpen(memKey, next)
setOpen(next)
}
@@ -246,7 +237,7 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
<span>助手</span>
{live && <LiveBar live={live} />}
</div>
{split.timeline.length > 0 && (
{split.timeline.some(part => part.type !== 'thinking') && (
<div className="process-collapse">
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
@@ -268,7 +259,8 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
{extras.map(art => (
<DeliverableCard key={art.id} artifact={{ artifactId: art.id, name: art.name, kind: art.kind }} />
))}
{live && <LiveAgentNotes live={live} />}
{live && Object.keys(live.agentNotes).length > 0 && <Collapsible id={`agent-notes:${live.runId}`}
triggerClassName="process-collapse-summary" trigger={<span>协作记录</span>}><LiveAgentNotes live={live} /></Collapsible>}
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
{convId && <MessageTools msg={msg} convId={convId} />}
</div>
@@ -282,25 +274,32 @@ function SessionNoteField({ convId, note }: { convId: string; note: string }) {
<input className="search-input session-note" value={value} placeholder="本会话要求"
title="附在上下文末尾,不改系统前缀"
onChange={e => setValue(e.target.value)}
onBlur={() => { if (value !== note) void api.setSessionNote(convId, value).then(() => store.openConversation(convId)) }} />
onBlur={() => { if (value !== note) void api.setSessionNote(convId, value).then(() => {
if (store.getState().currentConvId === convId) return store.openConversation(convId)
}) }} />
)
}
function MessageTools({ msg, convId }: { msg: Message; convId: string }) {
const text = msg.parts.filter(p => p.type === 'text').map(p => p.text ?? '').join('\n')
const refresh = () => { void store.openConversation(convId) }
const refresh = () => { if (store.getState().currentConvId === convId) void store.openConversation(convId) }
return (
<div className="msg-tools">
<button className="btn ghost" type="button" onClick={() => { void navigator.clipboard.writeText(text) }}>复制</button>
<button className="btn ghost" type="button" onClick={() => { void api.excludeMessage(msg.id, !msg.excluded).then(refresh) }}>
{msg.excluded ? '纳入上下文' : '排除出上下文'}
</button>
{msg.role === 'assistant' && (
<button className="btn ghost" type="button" onClick={() => { void api.regenerate(convId, msg.id).then(refresh) }}>重新生成</button>
)}
{msg.variantGroup && msg.excluded && (
<button className="btn ghost" type="button" onClick={() => { void api.selectVariant(msg.id).then(refresh) }}>使用此候选</button>
)}
<button className="companion-message-copy" type="button" onClick={() => { void navigator.clipboard.writeText(text) }}>复制</button>
<Collapsible id={`message-operations:${convId}:${msg.id}`} trigger="消息操作" triggerClassName="companion-message-copy">
<div className="companion-message-actions">
<button className="btn ghost" type="button" onClick={() => { void api.excludeMessage(msg.id, !msg.excluded).then(refresh) }}>
{msg.excluded ? '纳入上下文' : '排除出上下文'}
</button>
{msg.role === 'assistant' && (
<button className="btn ghost" type="button" onClick={() => { void api.regenerate(convId, msg.id).then(refresh) }}>重新生成</button>
)}
{msg.variantGroup && msg.excluded && (
<button className="btn ghost" type="button" onClick={() => { void api.selectVariant(msg.id).then(refresh) }}>使用此候选</button>
)}
{msg.role === 'user' && <RewindControl convId={convId} messageId={msg.id} />}
</div>
</Collapsible>
{msg.variantGroup && <span className="process-collapse-meta">候选 {(msg.variantIndex ?? 0) + 1}</span>}
</div>
)
@@ -349,16 +348,32 @@ function LiveAgentNotes({ live }: { live: LiveState }) {
function HITLCardView({ card }: { card: HITLCard }) {
const [free, setFree] = useState('')
const [submitting, setSubmitting] = useState(false)
const actionState = useStore(s => s.cardActionStates?.[`hitl:${card.id}`])
const submitting = actionState !== undefined
// wfcompile.doScript 使用固定首行 + 脚本预览;其他多行问题可能包含
// 用户必须理解的业务后果,保持原样,不凭内容猜测其信息层级。
const scriptPrefix = '运行这段脚本?\n'
const hasScriptPreview = card.kind === 'confirm' && card.question.startsWith(scriptPrefix)
const question = hasScriptPreview ? scriptPrefix.trimEnd() : card.question
const detail = [hasScriptPreview ? card.question.slice(scriptPrefix.length) : '', card.detail]
.filter(value => value?.trim()).join('\n\n')
const answer = async (resp: { choiceId?: string; freeText?: string }) => {
setSubmitting(true)
if (store.getState().cardActionStates?.[`hitl:${card.id}`]) return
await store.resolveHITL(card.id, resp)
setSubmitting(false)
}
return (
<div className={`hitl-card${card.kind === 'confirm' ? ' wait' : ''}${card.kind === 'choice' || card.kind === 'input' ? ' ask' : ''}`}>
<div className="hitl-q"><Icon name="check" size={15} />{card.question}</div>
{card.detail && <pre className="hitl-detail">{card.detail}</pre>}
<div className="hitl-q"><Icon name="check" size={15} />{question}</div>
{card.blocked && <div className="companion-action-feedback" role="status">
操作受限:{card.blockReason || '当前操作条件尚未满足'}
</div>}
{detail && <div className="process-collapse"><Collapsible id={`hitl-details:${card.conversationId}:${card.id}`}
triggerClassName="process-collapse-summary" trigger={<><span className="process-collapse-chev">▸</span>操作详情</>}>
<pre className="hitl-detail">{detail}</pre>
</Collapsible></div>}
{actionState && <div className="companion-action-feedback" role="status">
{actionState === 'unknown' ? '正在核实处理结果' : '正在提交你的选择'}
</div>}
{card.choices && (
<div className="hitl-choices">
{card.choices.map(c => (
@@ -373,7 +388,7 @@ function HITLCardView({ card }: { card: HITLCard }) {
{card.allowFreeInput && (
<div className="hitl-free">
<input className="search-input" value={free} onChange={e => setFree(e.target.value)}
placeholder={card.freeInputHint || '自由输入…'} onKeyDown={e => { if (e.key === 'Enter' && free.trim()) answer({ freeText: free.trim() }) }} />
disabled={submitting} placeholder={card.freeInputHint || '补充你的要求'} onKeyDown={e => { if (e.key === 'Enter' && free.trim() && !submitting) void answer({ freeText: free.trim() }) }} />
<button className="btn ghost" disabled={!free.trim() || submitting} onClick={() => answer({ freeText: free.trim() })}>发送</button>
</div>
)}
@@ -450,6 +465,13 @@ export function NoticeMessage({ msg }: { msg: Message }) {
}
export function Conversation() {
const conversationId = useStore(s => s.currentConvId)
const projectId = useStore(s => s.currentProjectId)
// 本地草稿、附件、折叠设置与滚动跟随属于当前会话,切换时一起重置。
return <ConversationSession key={`${projectId}:${conversationId}`} />
}
function ConversationSession() {
const currentConvId = useStore(s => s.currentConvId)
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
const messages = useStore(s => s.messages)
@@ -462,17 +484,25 @@ export function Conversation() {
const artifacts = useStore(s => s.artifacts)
const profiles = useStore(s => s.profiles)
const cards = useStore(s => s.cards)
const projectName = useStore(s => s.projects.find(p => p.id === s.currentProjectId)?.name ?? '项目')
const runtimeMock = useStore(s => s.runtimeMock)
const currentProjectId = useStore(s => s.currentProjectId)
const cardActionStates = useStore(s => s.cardActionStates)
const [text, setText] = useState('')
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
const [dragDepth, setDragDepth] = useState(0)
const [uploading, setUploading] = useState(0)
const [uploadError, setUploadError] = useState('')
const composeActive = useRef(true)
const inputRef = useRef<HTMLTextAreaElement>(null)
const bodyRef = useRef<HTMLDivElement>(null)
const stickRef = useRef(true)
const [showJump, setShowJump] = useState(false)
useEffect(() => {
composeActive.current = true
return () => { composeActive.current = false }
}, [])
artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a]))
cardCache = Object.fromEntries(cards.map(c => [c.id, c]))
@@ -492,8 +522,30 @@ export function Conversation() {
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
}, [messages.length, live?.items, live?.phase, activeHitl.length])
const composeIsCurrent = () => {
const state = store.getState()
return composeActive.current && state.currentConvId === currentConvId && state.currentProjectId === currentProjectId
}
const addMaterials = async (files: File[]) => {
if (!files.length) return
setUploadError('')
setUploading(count => count + files.length)
for (const file of files) {
if (!composeIsCurrent()) return
const id = await store.uploadAttachment(file)
if (!composeIsCurrent()) return
setUploading(count => Math.max(0, count - 1))
if (id) {
setAttachmentIds(ids => [...ids, id])
setAttachmentNames(names => [...names, file.name])
} else {
setUploadError(message => [message, `「${file.name}」未能添加,请重新选择文件`].filter(Boolean).join(';'))
}
}
}
const send = async () => {
if (!text.trim() && attachmentIds.length === 0) return
if (uploading > 0 || (!text.trim() && attachmentIds.length === 0)) return
const t = text
const ids = attachmentIds
const names = attachmentNames
@@ -507,54 +559,54 @@ export function Conversation() {
if (!currentConvId) {
return (
<div className="chat">
<div className="chat-body"><div className="empty-state">
<div style={{ fontSize: 40 }}>⌘</div>
<div>选择或创建一个会话开始</div>
{runtimeMock
? <div style={{ fontSize: 'var(--fs-ui-sm)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error</div>
: <div style={{ fontSize: 'var(--fs-ui-sm)' }}>演示模式可在设置里打开,无需模型密钥</div>}
<div className="chat work-companion-chat">
<div className="chat-body"><div className="empty-state companion-empty">
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
<div className="companion-empty-title">从一件需要完成的工作开始</div>
<div>告诉助手你的目标,或准备好需要一起阅读的材料</div>
<button type="button" className="btn" onClick={() => { void store.createTask() }}>开始新对话</button>
</div></div>
</div>
)
}
return (
<div className={`chat ${dragDepth > 0 ? 'is-dragging' : ''}`}
<div className={`chat work-companion-chat ${dragDepth > 0 ? 'is-dragging' : ''}`}
onDragEnter={e => { if (!hasFiles(e.dataTransfer)) return; e.preventDefault(); setDragDepth(d => d + 1) }}
onDragOver={e => { if (hasFiles(e.dataTransfer)) e.preventDefault() }}
onDragLeave={() => setDragDepth(d => Math.max(0, d - 1))}
onDrop={async e => {
onDrop={e => {
if (!e.dataTransfer?.files?.length) return
e.preventDefault()
setDragDepth(0)
for (const f of Array.from(e.dataTransfer.files)) {
const id = await store.uploadAttachment(f)
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
}
void addMaterials(Array.from(e.dataTransfer.files))
}}>
{dragDepth > 0 && <div className="drop-veil">释放以上传资料</div>}
<div className="chat-header">
<span className="project-badge" title="当前项目">{projectName}</span>
<div className="chat-title">{conversation?.title ?? '会话'}</div>
<select className="effect-badge" value={conversation?.profileId || 'default'} title="切换会话画像"
<Collapsible id={`conversation-settings:${currentProjectId}:${currentConvId}`} triggerClassName="companion-settings-trigger"
trigger={<><Icon name="settings" size={14} /><span>会话设置</span></>} bodyClassName="companion-settings-body">
<label className="companion-setting"><span>工作方式</span>
<select value={conversation?.profileId || 'default'} aria-label="工作方式"
onChange={async e => {
if (!currentConvId) return
await api.setConversationProfile(currentConvId, e.target.value)
await store.openConversation(currentConvId)
if (store.getState().currentConvId === currentConvId) await store.openConversation(currentConvId)
}}>
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<button className="btn ghost" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
</label>
<div className="companion-setting"><span>读取同项目的其他对话</span><button className="btn ghost"
title={conversation?.allowCrossRead === false
? '本会话不读取其他会话记录(点击开启)'
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
<Icon name="users" size={13} />跨读{conversation?.allowCrossRead === false ? '·关' : '·开'}
{conversation?.allowCrossRead === false ? '关闭' : '允许'}
</button>
<SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} />
<span title={connected ? '事件流已连接' : '重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
{!connected && <span className="conn-label">重连中</span>}
<span className="companion-setting-note">实际读取范围还需项目设置允许</span></div>
<label className="companion-setting"><span>这次工作的要求</span><SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} /></label>
</Collapsible>
<span title={connected ? '已连接' : '正在重新连接'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
</div>
<div className="chat-body" ref={bodyRef}
@@ -584,10 +636,10 @@ export function Conversation() {
</div>
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
{currentConvId && <RewindControl convId={currentConvId} messageId={m.id} />}
{anchored.map(c => (
<InlineConfirmCard key={c.id} card={c} callbacks={{
onAction: (card, actionId) => store.cardAction(card, actionId),
actionState: card => cardActionStates?.[card.id],
onLocate: card => store.openInbox(card.id),
}} />
))}
@@ -600,10 +652,16 @@ export function Conversation() {
{showLiveBubble(live?.runId ?? null, messages.filter(m => m.role === 'assistant' && m.runId).map(m => m.runId as string)) && live &&
<AssistantMessage key={`live-${live.runId}`} msg={liveToMessage(live)} live={live} />}
{activeHitl.map(c => <HITLCardView key={c.id} card={c} />)}
{messages.length === 0 && <div className="empty-state">
<ConversationWork />
{messages.length === 0 && !live && cards.length === 0 && activeHitl.length === 0 && <div className="empty-state companion-empty">
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
<div>发送消息开始对话</div>
{runtimeMock && <div style={{ fontSize: 'var(--fs-ui-sm)', color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>}
<div className="companion-empty-title">这次想完成什么</div>
<div>说说目标,也可以先添加材料</div>
<div className="companion-examples" aria-label="可参考的工作目标">
{['帮我整理这些材料中的重点和缺项', '一起比较这两个方案,说明各自适合的情况', '帮我把已有想法整理成一份草稿'].map(example => (
<button type="button" className="companion-example" key={example} onClick={() => { setText(example); inputRef.current?.focus() }}>{example}</button>
))}
</div>
</div>}
</div>
{showJump && (
@@ -616,59 +674,59 @@ export function Conversation() {
<div className="input-bar">
<OutboxPanel />
{!connected && <div className="companion-reconnect" role="status">正在重新连接,最新进展待确认</div>}
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
后台任务在回合结束后仍在执行,完成后回到对话汇报。 */}
{(busy || bgRunning > 0) && (
<div className="input-status" data-testid="input-status">
<span className="live-dot" />
{busy
? <span className="mk-sweep">助手工作中{bgRunning > 0 ? ` · ${bgRunning} 个后台任务` : ''}</span>
: <span>{bgRunning} 个后台任务进行中</span>}
<span className="input-status-note">{busy ? '输入仍可排队' : '完成后回到对话汇报'}</span>
<span style={{ flex: 1 }} />
<button type="button" className="input-status-link" onClick={() => store.openInbox()}>在工作里查看</button>
? <span className="mk-sweep">助手正在处理{bgRunning > 0 ? ` · ${bgRunning} 项后台工作` : ''}</span>
: <span>{bgRunning} 项后台工作</span>}
<span className="input-status-note">{busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,完成后会在这里汇报'}</span>
</div>
)}
{attachmentNames.length > 0 && (
<div className="attachments">
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}><Icon name='paperclip' size={12} /> {n}
<span style={{ cursor: 'pointer', display: 'inline-flex' }} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}><Icon name="x" size={11} /></span>
<button type="button" className="companion-remove-material" aria-label={`移除材料 ${n}`} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}><Icon name="x" size={11} /></button>
</span>)}
</div>
)}
{uploading > 0 && <div className="companion-upload-status" role="status">正在添加 {uploading} 份材料,完成后即可发送</div>}
{uploadError && <div className="companion-upload-error" role="alert">{uploadError}</div>}
<Collapsible id={`operation-settings:${currentProjectId}:${currentConvId}`} triggerClassName="companion-operation-trigger"
trigger={<><Icon name="settings" size={13} /><span>操作设置</span></>} bodyClassName="companion-operation-body">
<label className="companion-setting"><span>操作权限</span><select aria-label="操作权限" value={conversation?.permissionMode || 'default'} onChange={e => {
const mode = e.target.value
if (mode === 'default' || mode === 'plan' || mode === 'auto_edit') void store.setPermissionMode(currentConvId, mode)
}}>
<option value="default">默认</option><option value="plan">计划</option><option value="auto_edit">自动改文件</option>
</select></label>
<p className="companion-setting-note">计划模式只进行读取与规划;自动改文件模式允许文件修改。重要操作仍按当前权限规则确认</p>
</Collapsible>
{text.startsWith('/') && <CommandMenu text={text} onPick={c => setText(c)} />}
<div className="input-row">
<label className="mode-select" title="本会话的权限模式">
<span>权限</span>
<select value={conversation?.permissionMode || 'default'} onChange={e => {
const mode = e.target.value
if (mode === 'default' || mode === 'plan' || mode === 'auto_edit') {
if (currentConvId) void store.setPermissionMode(currentConvId, mode)
}
}}>
<option value="default">默认</option>
<option value="plan">计划</option>
<option value="auto_edit">自动改文件</option>
</select>
</label>
<label className="icon-btn" title="上传附件(文档解析后注入上下文)">
<Icon name="paperclip" size={16} /><input type="file" multiple style={{ display: 'none' }} onChange={async e => {
for (const f of Array.from(e.target.files ?? [])) {
const id = await store.uploadAttachment(f)
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
}
e.target.value = ''
}} />
</label>
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡'
: busy ? '继续输入以排队'
: artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
<textarea ref={inputRef} rows={2} value={text} aria-label="工作目标或消息" placeholder={hasOpenConfirm(cards) ? '有一项操作等你确认,请先查看上方说明'
: busy ? '继续补充,新消息会在当前回合结束后发送'
: '告诉助手你的目标,或添加需要一起阅读的材料'}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
<div className="companion-compose-actions">
<label className="companion-add-material" title="添加材料">
<Icon name="paperclip" size={16} /><span>添加材料</span><input type="file" multiple className="companion-file-input" aria-label="添加材料" onChange={e => {
const files = Array.from(e.currentTarget.files ?? [])
e.currentTarget.value = ''
void addMaterials(files)
}} />
</label>
<span className="companion-input-hint">Enter 发送 · Shift+Enter 换行</span>
<span className="companion-compose-spacer" />
{showStop && (
<button className="btn danger" onClick={() => store.cancelActive()}>停止</button>
<button type="button" className="btn danger" onClick={() => store.cancelActive()}>停止</button>
)}
<button className="btn" onClick={send} disabled={!text.trim() && attachmentIds.length === 0}>{busy ? '排队' : '发送'}</button>
<button type="button" className="btn" onClick={send} disabled={uploading > 0 || (!text.trim() && attachmentIds.length === 0)}>{busy ? '加入队列' : '发送'}</button>
</div>
</div>
</div>
</div>
@@ -697,7 +755,7 @@ function RewindControl({ convId, messageId }: { convId: string; messageId: strin
try {
await api.rewindFiles(convId, messageId)
setOpen(false)
await store.openTask(convId)
if (store.getState().currentConvId === convId) await store.openTask(convId)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useMemo, useState } from 'react'
import { ActivityCardView, Collapsible, activitySummary, pendingCount, type ActivityCard } from '@ui'
import { store, useStore } from '../store'
import { docBridge } from './Workbench'
/** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */
export function ConversationWork() {
const cards = useStore(s => s.cards)
const conversationId = useStore(s => s.currentConvId)
const projectId = useStore(s => s.currentProjectId)
const messages = useStore(s => s.messages)
const actionStates = useStore(s => s.cardActionStates)
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
const [summaryOpen, setSummaryOpen] = useState(rightCollapsed)
useEffect(() => setSummaryOpen(rightCollapsed), [rightCollapsed])
const { current, required, focused, history } = useMemo(() => {
const current = cards.filter(card => card.status !== 'dismissed'
&& card.conversationId === conversationId && card.projectId === projectId)
const required: ActivityCard[] = []
const remaining: ActivityCard[] = []
for (const card of current) {
// A confirmation already visible at its user message has one main action
// path in the conversation; assistant-anchored cards still need this path.
if (card.kind === 'confirm' && card.anchorMessageId
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
const summary = activitySummary(card)
if (card.status === 'action_required' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
else remaining.push(card)
}
// Preserve the host's newest-first order for completed results and history.
const focused = remaining.find(card => card.pinned && card.status === 'active')
?? remaining.find(card => card.status === 'active')
?? remaining.find(card => card.status === 'done') ?? remaining[0]
return { current, required, focused, history: remaining.filter(card => card !== focused) }
}, [cards, messages, conversationId, projectId])
if (current.length === 0) return null
const pending = pendingCount(current)
const active = current.filter(card => card.status === 'active').length
const results = current.filter(card => card.status === 'done' || card.status === 'failed').length
const summary = [pending ? `${pending} 项待处理` : '', active ? `${active} 项工作` : '', results ? `${results} 项结果` : ''].filter(Boolean).join(' · ')
const callbacks = {
onAction: (card: ActivityCard, actionId: string) => { void store.cardAction(card, actionId) },
actionState: (card: ActivityCard) => actionStates?.[card.id],
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
onLocate: (card: ActivityCard) => { store.locateMessage(card.anchorMessageId) },
onArchive: (card: ActivityCard) => { void store.cardToProject(card) },
}
const view = (card: ActivityCard) => <ActivityCardView key={card.id} card={card} callbacks={callbacks} docBridge={docBridge} />
return (
<section className="conversation-work" data-conversation-work aria-label="工作进展">
<div className="conversation-work-heading"><span>工作进展</span><span>{summary || '已处理的工作'}</span></div>
<div className="conversation-work-current">
{required.map(view)}
{focused && <Collapsible id={`conversation-current:${conversationId}:${focused.id}`} open={summaryOpen} onToggle={setSummaryOpen}
triggerClassName="conversation-work-more" trigger={<><span className="process-collapse-chev">▸</span>{focused.title} · 查看摘要</>}>
{view(focused)}
</Collapsible>}
</div>
{history.length > 0 && <Collapsible id={`conversation-work:${projectId}:${conversationId}`}
triggerClassName="conversation-work-more"
trigger={<><span className="process-collapse-chev">▸</span>更多工作与成果 · {history.length} 项</>}>
<div className="conversation-work-history">{history.map(view)}</div>
</Collapsible>}
</section>
)
}
+28 -3
View File
@@ -41,6 +41,8 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
setErr(''); setBusy(true)
try {
const me = await api.login(owner.trim(), password)
// 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。
auth.beginSession()
if (me.mustChangePassword) {
// 初始/重置密码:先完成强制改密再放行。
onMustChange(me.owner)
@@ -57,7 +59,17 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
auth.setKey(key)
try {
const r = await auth.fetch('/api/health')
if (r.ok) { onAuthed(); return }
if (r.ok) {
// 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录):
// 不放行进死路改密门,引导改走账号密码登录。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) {
auth.clear()
setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密')
return
}
onAuthed(); return
}
throw new Error(String(r.status))
} catch {
auth.clear()
@@ -118,7 +130,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
await api.changePassword(current, next)
onDone()
} catch (e) {
setErr(humanLoginError(String(e)).replace('401: ', ''))
setErr(humanChangePasswordError(String(e)))
} finally { setBusy(false) }
}
@@ -145,10 +157,23 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
)
}
/** 错误文案:把 HTTP 状态码翻成人话。 */
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
export function humanLoginError(raw: string): string {
if (raw.includes('401')) return '用户名或密码不正确'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key),
* 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
export function humanChangePasswordError(raw: string): string {
if (raw.includes('current password incorrect')) return '当前密码不正确'
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
if (raw.includes('session expired')) return '会话已过期,请重新登录'
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('401')) return '认证失败,请重新登录'
return raw.replace(/^\d{3}:\s*/, '')
}
+6 -2
View File
@@ -7,6 +7,7 @@ import type { Artifact } from '../contracts'
import { humanSize } from '../messageparts'
import { previewTabsKey } from '../previewtabs'
import { store, useStore } from '../store'
import { surfaceKey } from '../surfaceKey'
export function PreviewPane() {
// 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。
@@ -17,7 +18,10 @@ export function PreviewPane() {
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
const surfaces = useStore(s => s.surfaces)
const surfaceSnap = active?.id.startsWith('surface:')
? surfaces.find(x => 'surface:' + String(x.identity.surfaceId) === active.id)
? surfaces.find(x => {
const id = surfaceKey(x)
return id != null && 'surface:' + id === active.id
})
: undefined
const art = active ? artifacts.find(a => a.id === active.id) : undefined
const textKind = !!art && isTextKind(art)
@@ -56,7 +60,7 @@ export function PreviewPane() {
</div>
<div className="preview-pane-body">
{surfaceSnap
? <SurfaceView snapshot={surfaceSnap} docBridge={{ onOpenDoc: docId => {
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(surfaceKey(surfaceSnap) ?? '', id, {})} docBridge={{ onOpenDoc: docId => {
const art = artifacts.find(a => a.id === docId)
if (art) store.requestPreview(art)
} }} />
+5 -20
View File
@@ -1,10 +1,9 @@
// 工作时间线:思考、对用户的解说、同类工具合并成一行。运行中展开;
// 终轮结束后由调用方套进「已工作 X」折叠。
// 执行记录按工作动作分组;展开状态由调用方控制。
import type { ReactNode } from 'react'
import { Collapsible, ToolBlock } from '@ui'
import { ToolBlock } from '@ui'
import type { Part } from '../contracts'
import { formatDuration, humanSize } from '../messageparts'
import { timelineEntries, toolGroupLabel, toolTail, type ToolGroupItem } from '../toolkind'
import { timelineEntries, toolGroupLabel, type ToolGroupItem } from '../toolkind'
export function ProcessTimeline({ parts, live, idKey, renderPart }: {
parts: Part[]
@@ -16,20 +15,8 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
return (
<div className="tl">
{rows.map((row, i) => {
if (row.kind === 'thinking') {
const dur = formatDuration(row.durationMs)
const label = row.active ? '思考中' : (dur ? `思考 · 持续了 ${dur}` : '思考')
return (
<div className="part-thinking" key={`${idKey}:th:${i}`}>
<Collapsible id={`think:${idKey}:${i}:${row.active ? 'live' : 'done'}`} defaultOpen={row.active} key={row.active ? 'open' : 'shut'}
triggerClassName="part-thinking-summary"
trigger={<><span className="part-thinking-chev">▸</span>
<span className={row.active ? 'mk-sweep' : ''}>{label}</span></>}>
<div className="part-thinking-body">{row.text}</div>
</Collapsible>
</div>
)
}
// 模型内部思考不是用户工作内容,也不属于可展开的执行记录。
if (row.kind === 'thinking') return null
if (row.kind === 'text') {
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
}
@@ -70,7 +57,6 @@ function sizeLabel(n: number): string {
function ToolCallBlock({ item }: { item: ToolGroupItem }) {
const composing = item.phase === 'composing'
const tail = item.running ? toolTail(item.arguments, item.output) : ''
const dur = !item.running ? formatDuration(item.durationMs) : ''
const detail = composing ? undefined : (dur ? `${item.headline} · ${dur}` : item.headline)
return (
@@ -88,7 +74,6 @@ function ToolCallBlock({ item }: { item: ToolGroupItem }) {
{item.arguments && <pre className="tool-preview">{item.arguments}</pre>}
{item.output && <pre className="tool-preview">{item.output}</pre>}
</ToolBlock>
{tail && <pre className="tool-live-preview">{tail}</pre>}
</div>
)
}
+20 -6
View File
@@ -7,11 +7,16 @@ import { store, useStore } from '../store'
import type { SettingDesc } from '../contracts'
import { SettingsShell, Btn, EmptyState } from '@ui'
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
import { RdmMembersSection, RdmDutiesSection, RdmPermMatrixSection } from './adminRdmSections'
export function ProjectSettingsModal() {
const projectId = useStore(s => s.ui.projectSettingsId)
const projects = useStore(s => s.projects)
const project = projects.find(p => p.id === projectId)
const rdmMembersAvailable = useStore(s => s.rdmMembersAvailable)
const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable)
const rdmPermMatrixAvailable = useStore(s => s.rdmPermMatrixAvailable)
const [tab, setTab] = useState('project')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [draft, setDraft] = useState<Record<string, any>>({})
const [loaded, setLoaded] = useState(false)
@@ -49,16 +54,25 @@ export function ProjectSettingsModal() {
return (
<SettingsShell
title={`项目设置 · ${project?.name ?? projectId}`}
active="project"
onSelect={() => undefined}
active={tab}
onSelect={setTab}
onClose={() => store.openProjectSettings(null)}
sections={[{ key: 'project', label: '本项目', icon: 'folder' }]}
sections={[
{ key: 'project', label: '本项目', icon: 'folder' },
// U6/KTD5:成员与文档职责页签按 RDM 命令面门控(普通基座不出现)。
...(rdmMembersAvailable ? [{ key: 'members', label: '成员', icon: 'users' as const }] : []),
...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责 · 整文审签', icon: 'shield' as const }] : []),
...(rdmPermMatrixAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
>
<div>
{!loaded ? (
{tab === 'members' && <RdmMembersSection projectId={projectId} />}
{tab === 'duties' && <RdmDutiesSection projectId={projectId} />}
{tab === 'matrix' && <RdmPermMatrixSection projectId={projectId} />}
{tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !loaded ? (
<EmptyState icon="settings">加载中…</EmptyState>
) : (
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? (
<>
<div className="empty-note" style={{ marginBottom: 10 }}>
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
@@ -79,7 +93,7 @@ export function ProjectSettingsModal() {
))}
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
</>
)}
) : null}
</div>
</SettingsShell>
)
+520
View File
@@ -0,0 +1,520 @@
// U4:项目追溯审计工作区(四入口)——消费 U3 rdm.inspect.* 命令面。
// 只读:审批/确认关系仍回原业务流程(D31:弹层不是唯一路径)。
// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
import { classifyRdmError } from '../rdmErrors'
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
interface InspectProject { projectId: string; name: string; canAudit: boolean }
interface Capabilities { actor: string; projects: InspectProject[]; crossProject: boolean }
interface CoverageItem { face: string; reason: string }
interface ListShell<T = any> { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
interface SummaryData { projectId: string; counts: Record<string, number>; watermark: number; lastOpAt: string; coverage?: CoverageItem[] }
interface TreeItem { kind: string; id: string; title: string; state?: string }
interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number }
interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string }
interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] }
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
// ---- 工作区主组件 ----
const TABS = [
{ id: 'summary', label: '概览' },
{ id: 'materials', label: '资料与文档' },
{ id: 'trace', label: '关联追溯' },
{ id: 'timeline', label: '操作记录' },
] as const
type TabId = (typeof TABS)[number]['id']
/** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断;
* 「无可审计项目」由调用处独立空态承担,不走本组件。 */
function RdmErrorState({ raw }: { raw: string }) {
const c = classifyRdmError(raw)
return (
<div className="rdm-audit-err">
<EmptyState icon="alert">{c.message}</EmptyState>
<Collapsible id="rdm-audit-err-receipt" trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
<code className="rdm-audit-err-raw">{raw}</code>
</Collapsible>
</div>
)
}
export function RdmAuditWorkspace() {
const [caps, setCaps] = useState<Capabilities | null>(null)
const [pid, setPid] = useState('')
const [tab, setTab] = useState<TabId>('summary')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
api.rdmCommand<Capabilities>('rdm.inspect.capabilities')
.then(c => { if (!cancelled) { setCaps(c); if (c.projects.length > 0) setPid(prev => prev || c.projects[0].projectId) } })
.catch(e => { if (!cancelled) setError(e.message) })
return () => { cancelled = true }
}, [])
const project = caps?.projects.find(p => p.projectId === pid)
if (error) return <RdmErrorState raw={error} />
if (!caps) return <div className="empty-note">加载中…</div>
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
return (
<div className="rdm-audit">
<div className="rdm-audit-header">
<select className="ui-input rdm-audit-project" value={pid} onChange={e => setPid(e.target.value)} aria-label="选择项目">
{caps.projects.map(p => (
<option key={p.projectId} value={p.projectId}>{p.name}</option>
))}
</select>
{project?.canAudit && <Chip tone="ok">可审计</Chip>}
{caps.crossProject && <Chip tone="info">跨项目</Chip>}
</div>
<div className="tabs rdm-audit-tabs" role="tablist">
{TABS.map(t => (
<button key={t.id} role="tab" aria-selected={tab === t.id}
className={`tab${tab === t.id ? ' active' : ''}`}
onClick={() => setTab(t.id)}>{t.label}</button>
))}
</div>
<div className="rdm-audit-body">
{tab === 'summary' && <SummaryTab pid={pid} />}
{tab === 'materials' && <MaterialsTab pid={pid} />}
{tab === 'trace' && <TraceTab pid={pid} />}
{tab === 'timeline' && <TimelineTab pid={pid} />}
</div>
</div>
)
}
// ---- 概览 ----
function SummaryTab({ pid }: { pid: string }) {
const [data, setData] = useState<SummaryData | null>(null)
const [err, setErr] = useState('')
useEffect(() => {
let c = false
setData(null); setErr('')
api.rdmCommand<SummaryData>('rdm.inspect.summary', { projectId: pid })
.then(d => { if (!c) setData(d) })
.catch(e => { if (!c) setErr(e.message) })
return () => { c = true }
}, [pid])
if (err) return <RdmErrorState raw={err} />
if (!data) return <div className="empty-note">加载中…</div>
const counts = data.counts || {}
const entries: [string, string, number][] = [
['切片', 'slices', counts.slices ?? 0],
['章节', 'sections', counts.sections ?? 0],
['文档', 'docs', counts.docs ?? 0],
['生效边', 'edgesActive', counts.edgesActive ?? 0],
['待确认边', 'edgesCandidate', counts.edgesCandidate ?? 0],
['结论', 'conclusions', counts.conclusions ?? 0],
['待办', 'openItems', counts.openItems ?? 0],
['挂起上传', 'uploadsHeld', counts.uploadsHeld ?? 0],
['空缺章节', 'emptySections', counts.emptySections ?? 0],
]
return (
<div>
<SectionLabel>统计(截至水位 {data.watermark})</SectionLabel>
<div className="rdm-audit-cards">
{entries.map(([label, , val]) => (
<div key={label} className="rdm-audit-card">
<div className="rdm-audit-card-num">{val}</div>
<div className="rdm-audit-card-label">{label}</div>
</div>
))}
</div>
{data.lastOpAt && <div className="rdm-audit-meta">最近动作:{new Date(data.lastOpAt).toLocaleString('zh-CN')}</div>}
{data.coverage && data.coverage.length > 0 && (
<CoverageList cov={data.coverage} />
)}
</div>
)
}
// ---- 资料与文档 ----
function MaterialsTab({ pid }: { pid: string }) {
const [kind, setKind] = useState<'notes' | 'docs'>('docs')
return (
<div className="rdm-audit-materials">
<div className="rdm-audit-materials-side">
<div className="tabs">
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
</div>
<TreeList pid={pid} kind={kind} />
</div>
</div>
)
}
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
const [err, setErr] = useState('')
const [selected, setSelected] = useState<TreeItem | null>(null)
const [detail, setDetail] = useState<ObjectDetail | null>(null)
// docs 树是层级结构(rdm.inspect.tree parent=<docId> 才返回章节):文档节点
// 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。
const [children, setChildren] = useState<Record<string, TreeItem[]>>({})
const genRef = useRef(0)
useEffect(() => {
const gen = ++genRef.current
setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({})
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
.then(s => { if (gen === genRef.current) setShell(s) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, kind])
const loadMore = () => {
if (!shell?.nextCursor) return
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: shell.nextCursor })
.then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...s.items] } : s) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const toggleDoc = (item: TreeItem) => {
if (children[item.id]) {
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
return
}
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' })
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const selectNode = (item: TreeItem) => {
if (item.kind === 'doc') { toggleDoc(item); return }
setSelected(item); setDetail(null)
const gen = genRef.current
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
.then(d => { if (gen === genRef.current) setDetail(d) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
if (err) return <RdmErrorState raw={err} />
if (!shell) return <div className="empty-note">加载中…</div>
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
return (
<div className="rdm-audit-tree">
<div className="rdm-audit-tree-list">
{shell.items.map(item => (
<div key={item.id}>
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
onClick={() => selectNode(item)}>
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
<span className="rdm-audit-tree-kind">{item.kind}</span>
<span className="rdm-audit-tree-title">{item.title}</span>
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
</button>
{item.kind === 'doc' && children[item.id] && (
<div className="rdm-audit-tree-children">
{children[item.id].length === 0 && <div className="empty-note">无章节</div>}
{children[item.id].map(sub => (
<button key={sub.id} className={`rdm-audit-tree-node${selected?.id === sub.id ? ' sel' : ''}`}
onClick={() => selectNode(sub)}>
<span className="rdm-audit-tree-kind">{sub.kind}</span>
<span className="rdm-audit-tree-title">{sub.title}</span>
{sub.state && <Chip tone={sub.state === 'baselined' ? 'ok' : 'plain'}>{sub.state}</Chip>}
</button>
))}
</div>
)}
</div>
))}
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
{selected && (
<div className="rdm-audit-detail">
<SectionLabel>对象详情</SectionLabel>
{!detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
</div>
)}
</div>
)
}
// ---- 关联追溯 ----
function TraceTab({ pid }: { pid: string }) {
const [sel, setSel] = useState<{ kind: string; id: string; title: string } | null>(null)
const [relations, setRelations] = useState<RelationEntry[] | null>(null)
const [truncated, setTruncated] = useState(false)
const [err, setErr] = useState('')
const genRef = useRef(0)
useEffect(() => {
if (!sel) return
const gen = ++genRef.current
setRelations(null); setErr('')
api.rdmCommand<ListShell<RelationEntry>>('rdm.inspect.relations', { projectId: pid, kind: sel.kind, id: sel.id, depth: 1 })
.then(s => { if (gen === genRef.current) { setRelations(s.items); setTruncated(s.truncated) } })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, sel])
// 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
// (parent=<docId>),按文档聚合后合并展示。
const [sections, setSections] = useState<TreeItem[]>([])
useEffect(() => {
if (sel) return
let c = false
;(async () => {
const top = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
const secs: TreeItem[] = []
for (const d of top.items.filter(i => i.kind === 'doc')) {
const sub = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: d.id })
secs.push(...sub.items.filter(i => i.kind === 'section'))
}
if (!c) setSections(secs)
})().catch(() => {})
return () => { c = true }
}, [pid, sel])
if (!sel) {
return (
<div>
<SectionLabel>选择对象查看来源与影响</SectionLabel>
{sections.length === 0 ? <EmptyState icon="folder">暂无章节</EmptyState> : (
<div className="rdm-audit-pick">
{sections.map(s => (
<button key={s.id} className="btn v-ghost" onClick={() => setSel({ kind: 'section', id: s.id, title: s.title })}>
{s.title}
</button>
))}
</div>
)}
</div>
)
}
const sources = relations?.filter(r => r.to?.id === sel.id && r.to?.kind === sel.kind) ?? []
const impacts = relations?.filter(r => r.from?.id === sel.id && r.from?.kind === sel.kind) ?? []
return (
<div>
<div className="rdm-audit-trace-head">
<button className="btn v-ghost" onClick={() => setSel(null)}>← 返回</button>
<strong>{sel.title}</strong>
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
</div>
{err && <RdmErrorState raw={err} />}
{!relations && !err && <div className="empty-note">加载中…</div>}
{relations && relations.length === 0 && <EmptyState icon="folder">没有已知关联</EmptyState>}
{relations && relations.length > 0 && (
<div className="rdm-audit-trace-cols">
<div className="rdm-audit-trace-col">
<SectionLabel>来源依据({sources.length})</SectionLabel>
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} />)}
</div>
<div className="rdm-audit-trace-col">
<SectionLabel>影响对象({impacts.length})</SectionLabel>
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} />)}
</div>
</div>
)}
{truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
)
}
function RelationRow({ r }: { r: RelationEntry }) {
const other = r.from?.id && r.to?.id ? (r.from?.id < r.to?.id ? r.from : r.to) : (r.from || r.to)
return (
<div className="rdm-audit-rel">
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{r.layer}</Chip>
<span className="rdm-audit-rel-type">{r.type || r.kind || '关联'}</span>
<span className="rdm-audit-rel-endpoint">{other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''}</span>
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' ? 'warn' : 'plain'}>{r.state}</Chip>
</div>
)
}
// ---- 操作记录(事件时间线) ----
function TimelineTab({ pid }: { pid: string }) {
const [items, setItems] = useState<(OpGroup | EventDTO)[]>([])
const [cursor, setCursor] = useState('')
const [hasMore, setHasMore] = useState(false)
const [err, setErr] = useState('')
const [filterOp, setFilterOp] = useState('')
const [filterActor, setFilterActor] = useState('')
const [historyMode, setHistoryMode] = useState(false)
const [historySeq, setHistorySeq] = useState(0)
const [historyData, setHistoryData] = useState<HistoryData | null>(null)
const genRef = useRef(0)
const load = useCallback((cur: string, replace: boolean) => {
const gen = ++genRef.current
if (replace) { setItems([]); setErr('') }
const params: Record<string, unknown> = { projectId: pid, cursor: cur, limit: 30 }
if (filterOp) params.op = filterOp
if (filterActor) params.actor = filterActor
api.rdmCommand<ListShell<OpGroup | EventDTO>>('rdm.inspect.events', params)
.then(s => {
if (gen !== genRef.current) return
setItems(prev => replace ? s.items : [...prev, ...s.items])
setCursor(s.nextCursor || '')
setHasMore(s.hasMore)
})
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, filterOp, filterActor])
useEffect(() => { if (!historyMode) load('', true) }, [load, historyMode])
useEffect(() => {
if (!historyMode) { setHistoryData(null); return }
const gen = genRef.current
setHistoryData(null)
const params: Record<string, unknown> = { projectId: pid, atSeq: historySeq || undefined }
api.rdmCommand<HistoryData>('rdm.inspect.history', params)
.then(d => { if (gen === genRef.current) setHistoryData(d) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, historyMode, historySeq])
return (
<div className="rdm-audit-timeline">
<div className="rdm-audit-timeline-filters">
<input className="ui-input" placeholder="过滤操作者…" value={filterActor}
onChange={e => setFilterActor(e.target.value)} />
<input className="ui-input" placeholder="过滤操作类型…" value={filterOp}
onChange={e => setFilterOp(e.target.value)} />
<button className={`btn v-ghost${historyMode ? ' active' : ''}`} onClick={() => setHistoryMode(!historyMode)}>
{historyMode ? '← 返回当前' : '历史模式'}
</button>
{historyMode && (
<input className="ui-input rdm-audit-seq" type="number" placeholder="截止 seq…"
value={historySeq || ''} onChange={e => setHistorySeq(Number(e.target.value) || 0)} />
)}
</div>
{historyMode ? (
<div>
{!historyData ? <div className="empty-note">加载中…</div> : (
<div>
<SectionLabel>截至 seq {historyData.atSeq}(当前水位 {historyData.watermark})</SectionLabel>
{historyData.countOnlySnapshots ? <div className="rdm-audit-truncated">含 {historyData.countOnlySnapshots} 个旧计数快照(不可作恢复依据)</div> : null}
{historyData.formalMissing ? <div className="empty-note">{historyData.formalMissing}</div> : null}
{historyData.object ? (
<div className="rdm-audit-detail-body">
<div>标题:{historyData.object.title}</div>
<div>状态:{historyData.object.state}</div>
{historyData.object.contentPreview && <div>内容预览:{historyData.object.contentPreview}</div>}
</div>
) : historyData.empty ? <EmptyState icon="folder">该时点无事件</EmptyState> :
<div className="empty-note">输入对象 kind:id 查看历史快照</div>}
{historyData.formal && (
<div className="rdm-audit-formal">
正式版 R{historyData.formal.rev}(内容 v{historyData.formal.seq})——{historyData.formal.actor} 于 {new Date(historyData.formal.at).toLocaleString('zh-CN')}
</div>
)}
</div>
)}
</div>
) : (
<div>
{err && <RdmErrorState raw={err} />}
{!err && items.length === 0 && <EmptyState icon="clock">暂无事件</EmptyState>}
{items.map((item, i) => {
if ('events' in item && Array.isArray((item as OpGroup).events)) {
return <OpGroupView key={item.operationId || i} group={item as OpGroup} />
}
return <EventRow key={(item as EventDTO).seq} ev={item as EventDTO} />
})}
{hasMore && <button className="btn v-ghost rdm-audit-more" onClick={() => load(cursor, false)}>加载更多</button>}
</div>
)}
</div>
)
}
function OpGroupView({ group }: { group: OpGroup }) {
const [open, setOpen] = useState(false)
return (
<div className="rdm-audit-opgroup">
<button className="rdm-audit-opgroup-head" onClick={() => setOpen(!open)}>
<span className="rdm-audit-opgroup-actor">{group.actor}</span>
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
<Chip tone="plain">{group.events.length} 个事件</Chip>
</button>
{open && group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
</div>
)
}
function EventRow({ ev }: { ev: EventDTO }) {
return (
<div className="rdm-audit-event">
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleTimeString('zh-CN')}</span>
<span className="rdm-audit-event-op">{ev.op}</span>
<span className="rdm-audit-event-actor">{ev.actor}</span>
{ev.detail && <span className="rdm-audit-event-detail">{ev.detail.slice(0, 80)}</span>}
{ev.disclosure !== 'ok' && <Chip tone="warn">{ev.disclosure === 'model-candidate' ? '模型候选' : '来源受限'}</Chip>}
</div>
)
}
// ---- 对象详情视图 ----
function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
return (
<div className="rdm-audit-detail-body">
<div><strong>{detail.title || detail.id}</strong></div>
<div>{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}</div>
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : 'plain'}>{detail.state}</Chip></div>}
{detail.labels && <div>标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}</div>}
{detail.versions && detail.versions.length > 0 && (
<div>
<SectionLabel>版本链({detail.versions.length})</SectionLabel>
{detail.versions.map((v: any, i: number) => (
<div key={i} className="rdm-audit-version">
v{v.seq} · {v.actor} · {new Date(v.at).toLocaleString('zh-CN')}
</div>
))}
</div>
)}
{detail.formal && (
<div className="rdm-audit-formal">
正式版 R{detail.formal.rev}(内容 v{detail.formal.seq})——{detail.formal.actor} 于 {new Date(detail.formal.at).toLocaleString('zh-CN')}
</div>
)}
{detail.relations && detail.relations.length > 0 && (
<div>
<SectionLabel>一跳关系({detail.relations.length})</SectionLabel>
{detail.relations.map((r, i) => <RelationRow key={i} r={r} />)}
</div>
)}
{detail.recentEvents && detail.recentEvents.length > 0 && (
<div>
<SectionLabel>最近事件</SectionLabel>
{detail.recentEvents.map(ev => <EventRow key={ev.seq} ev={ev} />)}
</div>
)}
</div>
)
}
// ---- 覆盖范围标注(不冒充完整) ----
function CoverageList({ cov }: { cov: CoverageItem[] }) {
return (
<div className="rdm-audit-coverage">
<SectionLabel>覆盖范围</SectionLabel>
{cov.map(c => (
<div key={c.face} className="rdm-audit-coverage-item">
<Chip tone="plain">{c.face}</Chip>
<span>{c.reason}</span>
</div>
))}
</div>
)
}
+12 -1
View File
@@ -112,6 +112,16 @@ export function SettingsModal() {
)
}
// 输入框语义(2026-10-12 增量 3):仅秘密字段用密码框;普通文本用 text——
// 无条件 type="password" 会把整排设置项当凭据表单(浏览器「password 不在 form 中/
// 缺少 username」警告来源之一),也把普通文本无谓遮蔽。
export function settingInputAttrs(desc: SettingDesc): { type: 'text' | 'password'; autoComplete: string } {
const isSecret = desc.secret || desc.type === 'secret'
return isSecret
? { type: 'password', autoComplete: 'new-password' }
: { type: 'text', autoComplete: 'off' }
}
export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
if (desc.type === 'boolean') {
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
@@ -122,7 +132,8 @@ export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; val
</select>
}
const isSecret = desc.secret || desc.type === 'secret'
return <input type="password" autoComplete="new-password" style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
const attrs = settingInputAttrs(desc)
return <input type={attrs.type} autoComplete={attrs.autoComplete} style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
placeholder={isSecret ? '(已配置,输入以覆盖)' : String(desc.default ?? '')}
onChange={e => onChange(e.target.value)} />
}
+40 -31
View File
@@ -21,7 +21,7 @@ function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClo
if (x + el.offsetWidth > window.innerWidth - 8) x = window.innerWidth - el.offsetWidth - 8
if (y + el.offsetHeight > window.innerHeight - 8) y = r.top - el.offsetHeight - 6
el.style.left = `${Math.max(8, x)}px`
el.style.top = `${y}px`
el.style.top = `${Math.max(8, Math.min(y, window.innerHeight - el.offsetHeight - 8))}px`
const h = (e: MouseEvent) => { if (!el.contains(e.target as Node)) onClose() }
window.addEventListener('mousedown', h)
return () => window.removeEventListener('mousedown', h)
@@ -36,15 +36,27 @@ function MenuItem({ onClick, danger, icon, children }: { onClick: () => void; da
</button>
}
export function TaskSidebar() {
interface TaskSidebarProps {
onCollapse?: () => void
onConversationOpen?: () => void
onFilesOpen?: () => void
onWorkOpen?: () => void
}
export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
onFilesOpen = store.openFiles, onWorkOpen = store.focusPending,
}: TaskSidebarProps = {}) {
const projects = useStore(s => s.projects)
const tasks = useStore(s => s.tasks)
const me = useStore(s => s.me)
const adminSections = useStore(s => s.adminSections)
const artifacts = useStore(s => s.artifacts)
const currentConvId = useStore(s => s.currentConvId)
const currentProjectId = useStore(s => s.currentProjectId)
const showArchived = useStore(s => s.ui.showArchived)
const serverWorkspaces = useStore(s => s.serverWorkspaces)
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
const theme = useStore(s => s.ui.theme)
const pendingBadge = useStore(s =>
s.cards.filter(c => c.status === 'action_required').length +
@@ -55,18 +67,12 @@ export function TaskSidebar() {
const [creatingProject, setCreatingProject] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
// ⌘N/Ctrl+N 新建任务(P6:创建入口唯一 + 快捷键)。
useEffect(() => {
const h = (e: KeyboardEvent) => {
const k = e.key.toLowerCase()
if ((e.metaKey || e.ctrlKey) && k === 'n') { e.preventDefault(); store.createTask() }
if ((e.metaKey || e.ctrlKey) && k === 'b') { e.preventDefault(); store.toggleLeft() }
}
window.addEventListener('keydown', h, true)
return () => window.removeEventListener('keydown', h, true)
}, [])
const createConversation = async (projectId?: string) => {
await store.createTask(projectId)
onConversationOpen?.()
}
const tools = buildWorkspaceEntries(serverWorkspaces)
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
@@ -94,16 +100,21 @@ export function TaskSidebar() {
?? tasks.find(t => t.projectId === pid && !t.archived)
if (next) store.openTask(next.id)
else store.openProject(pid)
onConversationOpen?.()
}
const taskRow = (t: Conversation) => (
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
onClick={() => store.openTask(t.id)}
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
<span className="tsb-task-t" title={t.title}>{t.title || '未命名任务'}</span>
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
<button className="tsb-task-open" onClick={() => { store.openTask(t.id); onConversationOpen?.() }}>
<Icon name="message" size={14} />
<span className="tsb-task-t" title={t.title}>{t.title || '未命名对话'}</span>
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
</button>
{rowActions(t)}
<button className="tsb-row-menu" aria-label={`「${t.title || '未命名对话'}」更多操作`} title="更多对话操作"
onClick={e => setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget })}><Icon name="more" size={14} /></button>
</div>
)
@@ -118,22 +129,21 @@ export function TaskSidebar() {
{stage && <span className="tsb-projrow-stage">{stage}</span>}
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
</button>
<span style={{ flex: 1 }} />
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={onCollapse}><Icon name="panel-left" /></button>
</div>
{/* 单一主创建入口(P6):新建任务 + 次级菜单(新建项目/指定项目新建) */}
{/* 主入口创建会话;项目创建保留在次级菜单。 */}
<div className="tsb-actions">
<div className="tsb-newtask">
<button className="tsb-newtask-main" title="在当前项目新建任务(⌘N)" onClick={() => store.createTask()}>
<Icon name="plus" size={15} />新建任务{currentProject ? <span className="tsb-newtask-proj">{currentProject.name}</span> : null}
<button className="tsb-newtask-main" title="在当前项目新建对话(⌘N)" onClick={() => createConversation()}>
<Icon name="plus" size={15} />新建对话
</button>
<button className="tsb-newtask-caret" title="更多创建选项"
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'new' ? null : { kind: 'new', anchor: el }) }}><Icon name="chevron-down" size={14} /></button>
</div>
<div className="tsb-search">
<Icon name="search" size={14} />
<input placeholder="搜索任务…" value={q} onChange={e => setQ(e.target.value)} />
<input aria-label="搜索本项目会话" placeholder="搜索对话…" value={q} onChange={e => setQ(e.target.value)} />
</div>
</div>
@@ -169,14 +179,14 @@ export function TaskSidebar() {
)}
{rest.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">这个项目还没有会话——点击「新建任务」开始</div>
<div className="tsb-empty">从「新建对话」开始,描述你想完成的工作。</div>
)}
{rest.map(t => taskRow(t))}
</div>
{/* 底部(方案:文件、工作区、账户沉底)。 */}
<div className="tsb-bottom">
<button className="tsb-tool" title="文件搜索、预览、编辑与下载(第三栏「文件」入口)" onClick={() => store.openFiles()}>
<button className="tsb-tool" title="文件搜索、预览、编辑与下载" onClick={onFilesOpen}>
<span className="tsb-tool-icon"><Icon name="folder" size={14} /></span>
<span className="tsb-tool-name">文件</span>
{artifacts.length > 0 && <span className="tsb-group-cnt">{artifacts.length}</span>}
@@ -188,7 +198,6 @@ export function TaskSidebar() {
<kbd>{entry.shortcut}</kbd>
</button>
))}
{serverWorkspaces.length === 0 && <div className="tsb-tool-note">领域工具由系统级插件提供</div>}
{currentProject && (
<button className="tsb-tool" onClick={() => store.openProjectSettings(currentProject.id)}>
<span className="tsb-tool-icon"><Icon name="settings" size={14} /></span>
@@ -208,7 +217,7 @@ export function TaskSidebar() {
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
</button>
<button className="tsb-iconbtn" title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" size={15} /></button>
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.focusPending()}><Icon name="inbox" size={15} />{pendingBadge}</button>}
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={onWorkOpen}><Icon name="inbox" size={15} />{pendingBadge}</button>}
</div>
{/* 弹出菜单们 */}
@@ -229,11 +238,11 @@ export function TaskSidebar() {
</Menu>
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
<MenuItem icon="plus" onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
<MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>
<div className="tsb-menu-div" />
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
<MenuItem key={p.id} onClick={() => { store.createTask(p.id); setMenu(null) }}>在「{p.name}」新建任务</MenuItem>
<MenuItem key={p.id} onClick={() => { createConversation(p.id); setMenu(null) }}>在「{p.name}」新建对话</MenuItem>
))}
</Menu>
@@ -259,13 +268,13 @@ export function TaskSidebar() {
</div>
<div className="tsb-menu-div" />
<MenuItem icon="settings" onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
<MenuItem icon="inbox" onClick={() => { store.focusPending(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
<MenuItem icon="inbox" onClick={() => { onWorkOpen(); setMenu(null) }}>待处理事项{pendingBadge > 0 ? `(${pendingBadge})` : ''}</MenuItem>
<MenuItem icon="key" onClick={() => { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码…</MenuItem>
<MenuItem icon="moon" onClick={() => { store.setUI({ theme: 'dark' }); setMenu(null) }}>深色主题{theme === 'dark' ? ' · 当前' : ''}</MenuItem>
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
{me?.isAdmin && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{(me?.isAdmin || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{me?.auth && <>
<div className="tsb-menu-div" />
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
+43 -11
View File
@@ -1,20 +1,52 @@
// 顶栏:产品标识与待处理铃铛。项目身份在左栏顶部切换器集中显示一次(方案),
// 阶段徽章随切换器;本栏不再重复。
// Project identity lives in context; the top bar keeps work/files reachable when panes close.
import { BrandMark, Icon, pendingCount } from '@ui'
import { store, useStore } from '../store'
import type { WorkbenchPane } from '../workbenchLayout'
export function TopBar() {
interface TopBarProps {
compact?: boolean
activePane?: WorkbenchPane
showReturn?: boolean
onContext?: () => void
onConversation?: () => void
onWork?: () => void
onFiles?: () => void
}
export function TopBar({ compact = false, activePane = 'conversation', showReturn = false,
onContext = store.toggleLeft, onConversation,
onWork = () => { store.openInbox(); store.setUI({ inboxFilter: 'all', previewExpanded: false }) }, onFiles = store.openFiles,
}: TopBarProps = {}) {
const pending = useStore(s => pendingCount(s.cards) + pendingCount(s.projectCards))
const ui = useStore(s => s.ui)
const returnVisible = showReturn || compact && activePane !== 'conversation'
return (
<header className="topbar">
<BrandMark size={22} />
<span className="topbar-name">Agent-System</span>
<header className={`topbar companion-topbar${compact ? ' compact' : ''}`}>
<div className="topbar-identity"><BrandMark size={22} /><span className="topbar-name">工作助手</span></div>
<span className="topbar-spacer" />
<button className="topbar-bell" title={pending > 0 ? `待处理 ${pending},打开工作入口` : '工作入口'}
onClick={() => store.focusPending()}>
<Icon name="inbox" size={16} />
{pending > 0 && <span className="rail-badge" data-testid="topbar-pending">{pending}</span>}
</button>
{returnVisible && <button className="shell-return" aria-label="返回对话" onClick={onConversation}>
<Icon name="chevron-right" className="shell-back-icon" size={14} /><span>返回对话</span>
</button>}
<nav className="shell-navigation" aria-label="工作台导航">
{compact && <>
<button className="shell-nav-button" aria-label="打开上下文" aria-pressed={activePane === 'context'} onClick={onContext}>
<Icon name="panel-left" size={15} /><span>上下文</span>
</button>
<button className="shell-nav-button" aria-label="打开对话" aria-pressed={activePane === 'conversation'} onClick={onConversation}>
<Icon name="message" size={15} /><span>对话</span>
</button>
</>}
<button className="shell-nav-button" aria-label="打开工作"
aria-pressed={compact ? activePane === 'work' : !ui.rightCollapsed && ui.rightTab === 'work'}
title={pending > 0 ? `${pending} 项待处理` : '工作'} onClick={onWork}>
<Icon name="inbox" size={15} /><span>工作</span>
{pending > 0 && <span className="shell-pending" data-testid="topbar-pending">{pending > 99 ? '99+' : pending}</span>}
</button>
<button className="shell-nav-button" aria-label="打开文件"
aria-pressed={compact ? activePane === 'files' : !ui.rightCollapsed && ui.rightTab === 'files'} onClick={onFiles}>
<Icon name="folder" size={15} /><span>文件</span>
</button>
</nav>
</header>
)
}
+45 -2
View File
@@ -4,8 +4,9 @@
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
import { Icon } from '@ui'
import { Icon, SurfaceView } from '@ui'
import { previewTabsKey } from '../previewtabs'
import { surfaceKey } from '../surfaceKey'
import { ArtifactsPanel } from './ArtifactsPanel'
import { PreviewPane } from './PreviewPane'
@@ -21,6 +22,7 @@ export const docBridge: DocBridge = {
const inboxCallbacks = {
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
actionState: (c: ActivityCard) => store.getState().cardActionStates[c.id],
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
@@ -51,6 +53,8 @@ function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxF
}
export function Workbench() {
// Subscribe to shared feedback even when no card object has changed yet.
useStore(s => s.cardActionStates)
const rightTab = useStore(s => s.ui.rightTab)
const cards = useStore(s => s.cards)
const plan = useStore(s => s.plan)
@@ -91,6 +95,11 @@ function FilesTab() {
return <div className="tab-body"><ArtifactsPanel /></div>
}
/** U1:同会话存在后台 progress 卡(进行中/待确认)时,progress 为权威对象,隐藏 CurrentTaskCard 避免双标题。 */
export function hasActiveProgressCard(cards: ActivityCard[]): boolean {
return cards.some(c => c.kind === 'progress' && (c.status === 'active' || c.status === 'action_required'))
}
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
@@ -99,10 +108,11 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
projectCards: ActivityCard[]
cards: ActivityCard[]
}) {
const showCurrentTask = !hasActiveProgressCard(cards)
return (
<>
<div className="tab-body" data-scroll-sync="inbox">
<CurrentTaskCard plan={plan} todos={todos} />
{showCurrentTask ? <CurrentTaskCard plan={plan} todos={todos} /> : null}
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
@@ -115,6 +125,7 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
{inboxScope === 'project'
? <ProjectInbox cards={projectCards} filter={filter} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
<WorkspaceSurfaces />
</div>
</>
)
@@ -172,3 +183,35 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
</div>
)
}
// U6 工作区任务页容器:会话内页面在"工作"入口的常驻紧凑视图——默认显示最新
// 一页,固定(pin)后不随新页面抢占;关闭插件后此处自然消失(surfaces 为空),
// 收件箱/任务卡等原有内容不受影响(禁用回退完整性)。点击标题去文件预览全屏。
function WorkspaceSurfaces() {
const surfaces = useStore(s => s.surfaces)
const pin = useStore(s => s.ui.workspaceSurfacePin)
const usable = surfaces.filter(x => surfaceKey(x) != null)
if (usable.length === 0) return null
const shown = pin ? usable.find(x => surfaceKey(x) === pin) : usable[usable.length - 1]
if (!shown) return null
const shownId = surfaceKey(shown)!
return (
<div className="workspace-surfaces" data-workspace-surfaces>
<div className="ws-head">
<span className="ws-title">任务页面</span>
<span className="ws-meta">{shown.spec?.title || '(未命名)'} · 修订 {shown.revision}</span>
<span style={{ flex: 1 }} />
<button className="icon-btn" title={pin ? '取消固定(跟随最新)' : '固定此页(新页面不抢占)'}
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : shownId })}>
{pin ? '📍' : '📌'}
</button>
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(shownId)}>
↗
</button>
</div>
<div className="ws-body">
<SurfaceView snapshot={shown} />
</div>
</div>
)
}
+10 -2
View File
@@ -7,10 +7,13 @@ import { OverlayHost, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。
* 追溯审计按命令面能力显隐(增量 2):store.rdmAuditAvailable 为 true 才入列。 */
export function workspaceEntries(): WorkspaceEntry[] {
return buildWorkspaceEntries(store.getState().serverWorkspaces)
const s = store.getState()
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable })
}
export function Workspaces() {
@@ -28,6 +31,11 @@ export function Workspaces() {
<AgentsPanel runs={convRuns} />
</OverlayHost>
}
if (active === 'rdm-audit') {
return <OverlayHost title="追溯审计" icon="🔍" shortcut={entry?.shortcut} sayHint="打开追溯审计" onClose={close}>
<RdmAuditWorkspace />
</OverlayHost>
}
if (active.startsWith('plugin:')) {
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
+390
View File
@@ -0,0 +1,390 @@
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 /
// 文档职责 · 整文审签」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
import { useEffect, useState } from 'react'
import { api } from '../api'
import { useStore } from '../store'
import { Btn, EmptyState, Icon } from '@ui'
import {
ROLE_ACTIONS, parseReviewers,
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
memberAddPayload, memberRemovePayload,
assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload,
docFinalPayload, docStatusPayload, permCheckPayload, describeSource,
rolesListPlaceholder, describeRoleListError,
} from '../rdmManagement'
// ---- 系统管理:RDM 角色权限(仅管理员 + rdmRolesAvailable) ----
interface RoleView {
roleId: string
name: string
disabled: boolean
version: number
actions: Record<string, [boolean, boolean]> | null
accounts: string[] | null
}
export function RdmRolesPanel() {
const me = useStore(s => s.me)
const [roles, setRoles] = useState<RoleView[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [newName, setNewName] = useState('')
// 动作编辑器
const [editRole, setEditRole] = useState('')
const [editAction, setEditAction] = useState<string>(ROLE_ACTIONS[0])
const [editDef, setEditDef] = useState(false)
const [editCap, setEditCap] = useState(true)
// 绑定
const [assignOwner, setAssignOwner] = useState('')
const refresh = async () => {
try {
const out = await api.rdmCommand<{ roles: RoleView[] }>('rdm.role.list')
setErr(''); setRoles(out.roles ?? [])
} catch (e) { setRoles(null); setErr(describeRoleListError(String(e))) }
}
useEffect(() => { refresh() }, [])
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
try { await fn(); setNotice(label); await refresh() }
catch (e) { setErr(describeRoleListError(String(e))) } finally { setBusy(false) }
}
if (!me?.isAdmin) return <EmptyState icon="shield">仅平台管理员可管理专业角色</EmptyState>
// 占位行单一出口:加载中仅在无错误时成立(拒绝后永远转圈是误导——见
// rolesListPlaceholder 注释)。
const placeholder = rolesListPlaceholder(roles, err)
const sel = roles?.find(r => r.roleId === editRole)
const editCurrent = sel?.actions?.[editAction]
return (
<div>
<div className="ad-create">
<div className="ad-row">
<input className="lg-input" placeholder="新角色名(如:编制工程师)" value={newName}
onChange={e => setNewName(e.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !newName.trim()}
onClick={() => run(`已创建角色 ${newName.trim()}`, async () => {
await api.rdmCommand('rdm.role.create', roleCreatePayload(newName.trim()))
setNewName('')
})}>创建角色</button>
</div>
<p className="ad-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
</div>
<table className="ad-table">
<thead><tr><th>角色</th><th>状态</th><th>版本</th><th>动作配置</th><th>绑定账号</th><th></th></tr></thead>
<tbody>
{(roles ?? []).map(r => (
<tr key={r.roleId}>
<td><span className="ad-mono">{r.name}</span><br /><span className="ad-dim">{r.roleId}</span></td>
<td>{r.disabled ? <span className="ad-chip ad-chip-warn">已停用</span> : <span className="ad-chip">生效</span>}</td>
<td className="ad-dim">v{r.version}</td>
<td>{Object.keys(r.actions ?? {}).length} 项</td>
<td className="ad-mono">{(r.accounts ?? []).join('、') || '—'}</td>
<td><button className="ad-mini" disabled={busy}
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号回落内置默认)`,
() => api.rdmCommand('rdm.role.disable', roleDisablePayload(r.roleId, !r.disabled)))}>
{r.disabled ? '启用' : '停用'}</button></td>
</tr>
))}
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无专业角色</td></tr>}
{placeholder && <tr><td colSpan={6} className="ad-dim">{placeholder}</td></tr>}
</tbody>
</table>
<h4 className="settings-group-title" style={{ marginTop: 18 }}>动作配置(默认 ∩ 上限)</h4>
<div className="ad-row">
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
<option value="">选择角色…</option>
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
<select className="lg-input" value={editAction}
onChange={e => {
setEditAction(e.target.value)
const cur = sel?.actions?.[e.target.value]
setEditDef(!!cur?.[0]); setEditCap(cur ? cur[1] : true)
}}>
{ROLE_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<label className="ad-check"><input type="checkbox" checked={editDef}
onChange={e => setEditDef(e.target.checked)} />默认允许</label>
<label className="ad-check"><input type="checkbox" checked={editCap}
onChange={e => setEditCap(e.target.checked)} />上限允许</label>
<button className="lg-submit ad-create-btn" disabled={busy || !editRole}
onClick={() => run(`已配置 ${editRole} · ${editAction}`,
() => api.rdmCommand('rdm.role.action', roleActionPayload(editRole, editAction, editDef, editCap)))}>保存动作</button>
</div>
{sel && editCurrent && <p className="ad-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
<h4 className="settings-group-title" style={{ marginTop: 18 }}>绑定账号(每账号一个生效角色)</h4>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="账号(owner)" value={assignOwner}
onChange={e => setAssignOwner(e.target.value)} />
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
<option value="">选择角色…</option>
{(roles ?? []).filter(r => !r.disabled).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
<button className="lg-submit ad-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${editRole}`,
() => api.rdmCommand('rdm.role.assign', roleAssignPayload(assignOwner.trim(), editRole)))}>绑定</button>
</div>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
// ---- 项目设置:成员 ----
interface MemberRow { owner: string; role: string; addedBy: string; addedAt: string }
export function RdmMembersSection({ projectId }: { projectId: string }) {
const [rows, setRows] = useState<MemberRow[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [member, setMember] = useState('')
const [role, setRole] = useState<'member' | 'owner'>('member')
const refresh = async () => {
try {
const out = await api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
setRows(out.members ?? [])
} catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [projectId])
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
try { await fn(); setNotice(label); await refresh() }
catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
<div className="ad-create">
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="成员账号(owner)" value={member}
onChange={e => setMember(e.target.value)} />
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">member(成员)</option>
<option value="owner">owner(负责人)</option>
</select>
<button className="lg-submit ad-create-btn" disabled={busy || !member.trim()}
onClick={() => run(`已添加 ${member.trim()}`, async () => {
await api.rdmCommand('rdm.project.member.add', memberAddPayload(projectId, member.trim(), role))
setMember('')
})}>添加成员</button>
</div>
<p className="ad-hint">加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。</p>
</div>
<table className="ad-table">
<thead><tr><th>账号</th><th>项目角色</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
<tbody>
{(rows ?? []).map(m => (
<tr key={m.owner}>
<td className="ad-mono">{m.owner}</td>
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">OWNER</span> : <span className="ad-chip">MEMBER</span>}</td>
<td className="ad-dim">{m.addedBy || '—'}</td>
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
<td><button className="ad-mini" disabled={busy}
onClick={() => run(`已移除 ${m.owner}`,
() => api.rdmCommand('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button></td>
</tr>
))}
{rows !== null && rows.length === 0 && <tr><td colSpan={5} className="ad-dim">暂无成员</td></tr>}
{rows === null && <tr><td colSpan={5} className="ad-dim">加载中…</td></tr>}
</tbody>
</table>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
// ---- 项目设置:文档职责 · 整文审签 ----
interface DocSubmissionView {
round: number
state: string
digest: string
createdBy: string
createdAt: string
duties: { author: string; reviewers: string[]; approver: string }
reviews: Record<string, boolean>
sections: Record<string, number>
}
export function RdmDutiesSection({ projectId }: { projectId: string }) {
const [docId, setDocId] = useState('')
const [sub, setSub] = useState<DocSubmissionView | null | undefined>(undefined)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [author, setAuthor] = useState('')
const [reviewers, setReviewers] = useState('')
const [approver, setApprover] = useState('')
const cmd = <T,>(id: string, params: Record<string, unknown>) => api.rdmCommand<T>(id, params)
const refreshStatus = async (id: string) => {
if (!id) return
try {
const out = await cmd<{ submission: DocSubmissionView | null }>('rdm.review.doc.status', docStatusPayload(projectId, id))
setSub(out.submission)
} catch (e) { setErr(String(e)); setSub(undefined) }
}
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
try { await fn(); setNotice(label); await refreshStatus(docId) }
catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
<div className="ad-create">
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
onChange={e => setDocId(e.target.value)} />
<Btn variant="ghost" icon="eye" disabled={busy || !docId}
onClick={() => refreshStatus(docId)}>查看整文状态</Btn>
</div>
</div>
{sub === null && <EmptyState icon="inbox">该文档尚无整文提交记录</EmptyState>}
{sub && (
<div className="settings-group">
<h4>第 {sub.round} 轮 · {sub.state === 'submitted' ? <span className="ad-chip ad-chip-warn">审签在途(全文锁定)</span> : sub.state === 'final' ? <span className="ad-chip ad-chip-admin">已终批</span> : <span className="ad-chip">已驳回</span>}</h4>
<div className="setting-row"><label>三权</label><span>编制 <b className="ad-mono">{sub.duties.author}</b> · 审核 <b className="ad-mono">{(sub.duties.reviewers ?? []).join('、')}</b> · 批准 <b className="ad-mono">{sub.duties.approver}</b></span></div>
<div className="setting-row"><label>审核进度</label><span>{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}</span></div>
<div className="setting-row"><label>冻结摘要</label><span className="ad-mono ad-dim">{(sub.digest ?? '').slice(0, 16)}…</span></div>
<div className="ad-row" style={{ marginTop: 8 }}>
<button className="lg-submit ad-create-btn" disabled={busy || sub.state !== 'submitted'}
onClick={() => run('整文终批完成(各章节已基线)',
() => cmd('rdm.review.doc.final', docFinalPayload(projectId, docId)))}>终批(批准人)</button>
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
onClick={() => run('已通过整文审核',
() => cmd('rdm.review.doc.approve', docApprovePayload(projectId, docId, '通过')))}>审核通过</button>
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
onClick={() => run('已驳回整文(解锁回编写态)',
() => cmd('rdm.review.doc.reject', docRejectPayload(projectId, docId, '退回修改')))}>驳回</button>
</div>
</div>
)}
{sub === undefined && docId && <p className="ad-hint">输入文档 ID 后查看整文状态。</p>}
<h4 className="settings-group-title" style={{ marginTop: 18 }}>分配文档三权(整文)</h4>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
onChange={e => setDocId(e.target.value)} />
<input className="lg-input lg-mono" placeholder="编制人(author)" value={author}
onChange={e => setAuthor(e.target.value)} />
<input className="lg-input lg-mono" placeholder="审核人(逗号分隔,≥1 且异于编制/批准)" value={reviewers}
onChange={e => setReviewers(e.target.value)} />
<input className="lg-input lg-mono" placeholder="批准人(approver)" value={approver}
onChange={e => setApprover(e.target.value)} />
</div>
<div className="ad-row">
<button className="lg-submit ad-create-btn"
disabled={busy || !docId || !author.trim() || !approver.trim() || parseReviewers(reviewers).length === 0}
onClick={() => run('已分配文档三权', () => cmd('rdm.review.assign.doc',
assignDocDutiesPayload(projectId, docId, author.trim(), parseReviewers(reviewers), approver.trim())))}>分配三权</button>
<button className="ad-mini" disabled={busy || !docId}
onClick={() => run('整文已提交审核(全文锁定)',
() => cmd('rdm.review.doc.submit', docSubmitPayload(projectId, docId)))}>整文提交(编制人)</button>
</div>
<p className="ad-hint">三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。</p>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
// ---- 项目设置:项目权限(矩阵解释读面,与实际判定同源——UI 不重算) ----
interface MatrixAction { action: string; allowed: boolean; source: string; reason?: string }
interface MatrixOut {
projectId: string; member: string; role: string
profRole: { roleId: string; name: string } | null
actions: MatrixAction[]
}
export function RdmPermMatrixSection({ projectId }: { projectId: string }) {
const me = useStore(s => s.me)
const [member, setMember] = useState('')
const [members, setMembers] = useState<MemberRow[] | null>(null)
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
.then(out => {
setMembers(out.members ?? [])
// 默认自省:普通成员只看自己的矩阵;负责人/管理员可切换他人。
if (me?.owner) setMember(prev => prev || me.owner)
})
.catch(e => setErr(String(e)))
}, [projectId, me?.owner])
const query = async (m: string) => {
if (!m) return
setErr(''); setBusy(true); setMatrix(null)
try {
setMatrix(await api.rdmCommand<MatrixOut>('rdm.perm.check', permCheckPayload(projectId, m)))
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
<div className="ad-create">
<div className="ad-row">
<select className="lg-input" value={member} onChange={e => { setMember(e.target.value); query(e.target.value) }}>
<option value="">选择成员(默认自省)…</option>
{(members ?? []).map(m => <option key={m.owner} value={m.owner}>{m.owner}</option>)}
</select>
<Btn variant="ghost" icon="eye" disabled={busy || !member} onClick={() => query(member)}>查看矩阵</Btn>
</div>
<p className="ad-hint">普通成员可查看自己的有效权限;查看他人矩阵须项目管理权。矩阵与实际判定同源(服务端解释,非前端推算)。</p>
</div>
{matrix && (
<>
<div className="ad-row">
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目关系 <b>{matrix.role}</b>
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b>({matrix.profRole.roleId})</>}
</span>
</div>
<table className="ad-table">
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因(拒绝时)</th></tr></thead>
<tbody>
{(matrix.actions ?? []).map(a => (
<tr key={a.action}>
<td className="ad-mono">{a.action}</td>
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
<td className="ad-dim">{describeSource(a.source)}</td>
<td className="ad-dim">{a.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
</>
)}
{!matrix && !err && members !== null && <p className="ad-hint">选择成员后展示 12 项动作的有效权限与第一拒绝原因。</p>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
+12 -3
View File
@@ -193,9 +193,18 @@ export function PluginsSection() {
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
{plugins.map(p => (
<SettingRow key={p.manifest.id}
label={`${p.manifest.name} v${p.manifest.version}`}
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
<span className="effect-badge">api {p.manifest.apiVersion}</span>
label={p.manifest.name + (p.manifest.version ? ` v${p.manifest.version}` : '')}
desc={[
`工具 ${p.manifest.provides.tools?.length ?? 0}`,
`命令 ${p.manifest.provides.commands?.length ?? 0}`,
`面板 ${p.panels?.length ?? 0}`,
`意图 ${p.intents?.length ?? p.manifest.provides.intents?.length ?? 0}`,
`工作流 ${p.workflows?.length ?? 0}`,
].join(' · ')}>
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
{p.manifest.backend?.mode === 'inproc' && <span className="effect-badge">内置</span>}
<span className="effect-badge">api {p.manifest.apiVersion}</span>
</span>
</SettingRow>
))}
</div>
+4 -4
View File
@@ -29,7 +29,7 @@ export type LiveItem =
| { kind: 'check'; passed: boolean; reason: string }
| { kind: 'final' }
export type LiveTool = Extract<LiveItem, { kind: 'tool' }>
type LiveTool = Extract<LiveItem, { kind: 'tool' }>
const OUTPUT_TAIL = 4096
@@ -246,9 +246,9 @@ export function livePhaseLabel(live: LiveState, now = Date.now()): string {
return '回复中'
}
// showProcessInline:运行中时间线始终展开。run 结束后随「自动折叠过程」偏好收成一行。
export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean {
return live != null || !autoCollapse
// 默认阅读层只显示阶段;运行中也把技术记录留给用户主动展开。
export function showProcessInline(_live: LiveState | null, autoCollapse: boolean): boolean {
return !autoCollapse
}
// hasSettledReply:助手回复落库后,这个 run 的实时气泡才该收起。
+11 -15
View File
@@ -1,5 +1,5 @@
// 消息切分:过程时间线 = 从开头到最后一个 process 部件(中间夹着的内容部件留在时间线里);
// 最终段 = 它之后的正文与交付卡片。渲染顺序仍是落库顺序,只在这一刀上折叠。
// 消息切分:技术记录留在过程时间线,正文、错误与文件入口始终直接可读。
// 两区各自保留落库顺序,后续工具或终轮检查不再把解说藏入折叠区。
import type { Part } from './contracts'
export interface TimelineSplit {
@@ -7,22 +7,18 @@ export interface TimelineSplit {
final: Part[]
}
/** 最后一个 process 部件之前(含自身)是工作时间线,其余是最终回复。
* promoteLastText:回复区没有正文时,把时间线里最后一段解说提上来。失败或被截断的 run 用它保住可见回复。 */
export function splitTimeline(parts: Part[], promoteLastText = false): TimelineSplit {
/** 最后一个 process 部件之前的技术记录留在时间线,其余为可见内容。
* 保留旧 promoteLastText 参数以兼容调用方;解说现在始终可见,不再依赖终态提升。 */
export function splitTimeline(parts: Part[], _promoteLastText = false): TimelineSplit {
let last = -1
for (let i = 0; i < parts.length; i++) if (parts[i].process) last = i
if (last < 0) return { timeline: [], final: parts }
const timeline = parts.slice(0, last + 1)
const final = parts.slice(last + 1)
if (!promoteLastText || final.some(p => p.type === 'text' && (p.text ?? '').trim())) return { timeline, final }
let textAt = -1
for (let i = timeline.length - 1; i >= 0; i--) {
if (timeline[i].type === 'text' && (timeline[i].text ?? '').trim()) { textAt = i; break }
}
if (textAt < 0) return { timeline, final }
const lifted = { ...timeline[textAt], process: undefined }
return { timeline: timeline.filter((_, i) => i !== textAt), final: [lifted, ...final] }
// text 是公开解说;thinking、工具参数与结果、终轮检查不提升为正文。
const visible = (part: Part) => part.type === 'text' || part.type === 'error' || part.type === 'artifact_ref'
const before = parts.slice(0, last + 1)
const timeline = before.filter(part => !visible(part))
const final = [...before.filter(visible), ...parts.slice(last + 1)]
return { timeline, final }
}
/** 同一轮里同名工件只留最新一张,updates 是被折叠掉的次数。 */
+25
View File
@@ -0,0 +1,25 @@
// 审计命令错误分类(2026-10-12 增量 2):RdmAuditWorkspace 不再直出原始报错
//(普通基座上曾是 `400: {"receipt":…unknown or disabled command…}`),改为
//「能力缺失 / 登录已失效 / 查询失败」人话文案;raw 始终保留供「原始回执」折叠区诊断。
// 依据:api.ts json() 对非 2xx 抛 `${status}: ${body}`;后端 unknown/disabled 命令回
// 400 + "unknown or disabled command"(cmdbus.go Dispatch),总线未挂载回
// 503 "commands disabled"(server.go execCommand)。
type RdmErrorKind = 'capability' | 'auth' | 'query'
interface RdmErrorView {
kind: RdmErrorKind
message: string
raw: string
}
export function classifyRdmError(err: unknown): RdmErrorView {
const raw = err instanceof Error ? err.message : String(err)
if (/^40[13]:/.test(raw)) {
return { kind: 'auth', message: '登录已失效,请重新登录后再查看追溯审计', raw }
}
if (/unknown or disabled command|commands disabled/.test(raw)) {
return { kind: 'capability', message: '当前服务未提供审计能力(RDM 命令未注册或已停用)', raw }
}
return { kind: 'query', message: '查询失败,请稍后重试', raw }
}
+136
View File
@@ -0,0 +1,136 @@
// U6/KTD5:RDM 管理与项目页面的纯逻辑层——能力门控(与 rdmAuditReady 同源语义:
// 命令清单不可用=未就绪,fail-closed)与命令载荷构造。页面组件只消费本模块与
// api.rdmCommand,不在组件里散落命令 id / 参数拼写;身份一律经认证会话,载荷不
// 携带任何身份字段(G1-5)。
/** 系统管理「RDM 角色权限」分区消费的命令面。 */
export const RDM_ROLES_COMMANDS = [
'rdm.role.create',
'rdm.role.action',
'rdm.role.assign',
'rdm.role.disable',
'rdm.role.list',
] as const
/** 项目设置「成员」页消费的命令面。 */
export const RDM_MEMBER_COMMANDS = [
'rdm.project.member.list',
'rdm.project.member.add',
'rdm.project.member.remove',
] as const
/** 项目设置「项目权限」页消费的命令面(矩阵解释 + 成员清单)。 */
export const RDM_PERM_MATRIX_COMMANDS = [
'rdm.perm.check',
'rdm.project.member.list',
] as const
/** 项目设置「文档职责 · 整文审签」页消费的命令面。 */
export const RDM_DOC_REVIEW_COMMANDS = [
'rdm.review.assign.doc',
'rdm.review.doc.submit',
'rdm.review.doc.approve',
'rdm.review.doc.reject',
'rdm.review.doc.final',
'rdm.review.doc.status',
] as const
export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined
function ready(cmds: CommandListItem[] | null | undefined, need: readonly string[]): boolean {
if (!Array.isArray(cmds)) return false
return need.every(n => cmds.some(c => c?.id === n && c.enabled === true))
}
/** 角色分区是否就绪(普通基座无 rdm.role.* → 隐藏)。 */
export const rdmRolesReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_ROLES_COMMANDS)
/** 项目成员页是否就绪。 */
export const rdmMembersReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_MEMBER_COMMANDS)
/** 文档职责/整文审签页是否就绪。 */
export const rdmDocReviewReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_DOC_REVIEW_COMMANDS)
/** 项目权限矩阵页是否就绪。 */
export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_PERM_MATRIX_COMMANDS)
/** RDM 动作清单(12 项,与 RDM perms.AllActions 对齐;角色动作编辑器选项)。 */
export const ROLE_ACTIONS = [
'project.create', 'project.archive', 'member.manage', 'duty.assign',
'stage.advance', 'doc.read', 'doc.write', 'doc.publish', 'review.participate',
'trace.edit', 'knowledge.publish', 'project.settings',
] as const
// ---- 载荷构造(字段名与 RDM 命令 schema 一一对应) ----
export const roleCreatePayload = (name: string) => ({ name })
export const roleActionPayload = (roleId: string, action: string, def: boolean, cap: boolean) =>
({ roleId, action, default: def, cap })
export const roleAssignPayload = (owner: string, roleId: string) => ({ owner, roleId })
export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ roleId, disabled })
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') =>
({ projectId, member, role })
export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member })
export const assignDocDutiesPayload = (
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
) => ({ projectId, docId, author, reviewers, approver })
export const docSubmitPayload = (projectId: string, docId: string) => ({ projectId, docId })
export const docApprovePayload = (projectId: string, docId: string, note?: string) =>
note ? { projectId, docId, note } : { projectId, docId }
export const docRejectPayload = (projectId: string, docId: string, reason?: string) =>
reason ? { projectId, docId, reason } : { projectId, docId }
/** round=0(未指定)不携带——服务端只认在途轮次,旧轮次传参即拒。 */
export const docFinalPayload = (projectId: string, docId: string, round = 0) =>
round > 0 ? { projectId, docId, round } : { projectId, docId }
export const docStatusPayload = (projectId: string, docId: string) => ({ projectId, docId })
export const permCheckPayload = (projectId: string, member: string) => ({ projectId, member })
/** 矩阵来源标签(人话映射;未知来源回退原文——服务端新增来源时不至于空白)。 */
export function describeSource(source: string): string {
switch (source) {
case 'admin': return '系统管理员'
case 'prof-role-default': return '专业角色默认'
case 'override': return '项目内覆盖'
case 'relation-default': return '项目关系默认'
case 'non-member': return '非项目成员'
default: return source
}
}
/** 角色清单表格占位行文案:加载中仅在无错误时成立——rdm.role.list 被拒后
* 停留「加载中…」是 2026-10-13 8788 锁死事故的误导性表象(表格永远在转圈)。 */
export function rolesListPlaceholder(roles: unknown[] | null, err: string): string {
if (roles !== null) return roles.length === 0 ? '暂无专业角色' : ''
return err ? '角色清单未加载(见下方提示)' : '加载中…'
}
/** rdm.role.list 错误文案映射:域管理员权限拒绝给人话 + 恢复通道;其余错误
* 原样保留(服务端 reason 变化不至于空白)。台账单管理员制见 RDM G1-4/U1。 */
export function describeRoleListError(raw: string): string {
if (raw.includes('不是系统管理员')) {
return '当前账号不是 RDM 域管理员(台账单管理员制),无法读取角色清单;'
+ '请联系现任管理员经 rdm.perm.transfer 移交,或由部署侧受信通道恢复'
}
return raw
}
/** 审阅人 CSV 解析:去空白、去空项、保序去重(表单输入 → reviewers 数组)。 */
export function parseReviewers(csv: string): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const part of csv.split(',')) {
const v = part.trim()
if (!v || seen.has(v)) continue
seen.add(v)
out.push(v)
}
return out
}
+436 -56
View File
@@ -1,13 +1,16 @@
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api'
import { type AdminZoneSection, api, connectEvents } from './api'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import { canFlushOutbox, deriveShellPhase } from './runstate'
import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
import { rdmAuditReady } from './workspaces'
import { rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmPermMatrixReady } from './rdmManagement'
type FontSizeChoice = 13 | 14 | 16 | 18
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
@@ -27,6 +30,7 @@ import type {
Project,
Run,
StreamEvent,
SurfaceMeta,
SurfaceSnapshot,
TodoList
} from './contracts'
@@ -58,6 +62,8 @@ export interface UIState {
rightTab: 'work' | 'files'
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
filesView: 'list' | 'preview'
/** U6:工作区任务页固定(surfaceId;null=跟随最新;固定后新页面不抢占显示)。 */
workspaceSurfacePin: string | null
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
inboxFilter: 'all' | 'todo'
theme: ThemeChoice
@@ -99,14 +105,27 @@ interface AppState {
artifacts: Artifact[]
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
surfaces: SurfaceSnapshot[]
/** generative-ui U6:项目级跨会话页面聚合(打开项目时权威装载)。 */
projectSurfaces: SurfaceMeta[]
cards: ActivityCard[]
projectCards: ActivityCard[]
/** 同一操作的双入口反馈;未知结果必须先对账,不重新发送副作用。 */
cardActionStates: Record<string, 'submitting' | 'unknown'>
inboxScope: 'conv' | 'project'
/** runtime.mock:只控制演示指令提示,不改变对话能力。 */
runtimeMock: boolean
profiles: AgentProfile[]
pluginPanels: PluginPanel[]
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
rdmAuditAvailable: boolean
/** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */
rdmRolesAvailable: boolean
rdmMembersAvailable: boolean
rdmDocReviewAvailable: boolean
rdmPermMatrixAvailable: boolean
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
adminSections: AdminZoneSection[]
ui: UIState
connected: boolean
/** 发送请求已经出去,run 记录还没回来。 */
@@ -117,18 +136,20 @@ interface AppState {
outboxDraining: boolean
previewTabs: Record<string, PreviewTab[]>
previewActive: Record<string, string | null>
/** Ephemeral explicit opening intent; restoring saved metadata must not advance it. */
previewOpenIntent: number
}
const DEFAULT_UI: UIState = {
leftWidth: 260,
leftCollapsed: false,
rightWidth: 360,
rightCollapsed: false,
rightCollapsed: true,
rightTab: 'work',
filesView: 'list',
filesView: 'list', workspaceSurfacePin: null,
inboxFilter: 'all',
activeWorkspace: null,
theme: 'dark',
theme: 'light',
fontSize: 14,
autoCollapseProcess: true,
settingsOpen: false,
@@ -162,15 +183,20 @@ function loadUI(): UIState {
return { ...DEFAULT_UI }
}
const surfaceActionsInflight: Record<string, boolean> = {}
let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], surfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
rdmAuditAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
adminSections: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {},
previewTabs: {}, previewActive: {}, previewOpenIntent: 0,
}
const listeners = new Set<() => void>()
@@ -252,6 +278,116 @@ function flashLocate(root: string, attr: string, id: string) {
let sse: { close: () => void } | null = null
let projectSse: { close: () => void } | null = null
let projectSeq = 0
let conversationEpoch = 0
let streamSeq = 0
let detailRequest = 0
let detailSeq = 0
let detailVersion = 0
type DetailObservation = { version: number; seq: number; terminalSeq?: number; terminalVersion?: number }
let runObservations: Record<string, DetailObservation> = {}
let messageObservations: Record<string, DetailObservation> = {}
let hitlObservations: Record<string, DetailObservation> = {}
let cardsRequest = 0
let projectCardsRequest = 0
let cardVersion = 0
let projectCardVersion = 0
let cardVersions: Record<string, number> = {}
let projectCardVersions: Record<string, number> = {}
let cardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
let projectCardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
const actionInflight = new Set<string>()
const actionFeedbackOwners: Record<string, symbol> = {}
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
const version = ++detailVersion
observations[id] = { ...observations[id], version, seq,
...(terminal ? { terminalSeq: seq, terminalVersion: version } : {}) }
}
function detailRowIsNewer(observations: Record<string, DetailObservation>, id: string, started: number, seq: number) {
const observation = observations[id]
return observation && (observation.version > started || observation.seq > seq)
}
function terminalObservationIsNewer(id: string, started: number, seq: number) {
const observation = runObservations[id]
return (observation?.terminalVersion ?? 0) > started || (observation?.terminalSeq ?? 0) > seq
}
function mergeDetailRows<T extends { id: string }>(snapshot: T[], current: T[], observations: Record<string, DetailObservation>, started: number, seq: number) {
const fresh = current.filter(row => detailRowIsNewer(observations, row.id, started, seq))
const byId = new Map(fresh.map(row => [row.id, row]))
const snapshotIds = new Set(snapshot.map(row => row.id))
return [...snapshot.map(row => byId.get(row.id) ?? row), ...fresh.filter(row => !snapshotIds.has(row.id))]
}
function scheduleTerminalReconcile() {
const convId = state.currentConvId
const epoch = conversationEpoch
setTimeout(() => {
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
void store.reconcile().then(() => {
if (state.currentConvId === convId && conversationEpoch === epoch) return store.flushOutbox()
})
}, 250)
}
// Snapshot queries may overlap events. Only rows changed after the request began
// override that snapshot; a newer request/context invalidates the old query entirely.
function mergeCardSnapshot(snapshot: ActivityCard[], current: ActivityCard[], versions: Record<string, number>, started: number,
patches: Record<string, Pick<ActivityCard, 'status' | 'tone'>>) {
const fresh = current.filter(c => (versions[c.id] ?? 0) > started)
const freshIds = new Set(fresh.map(c => c.id))
return [...fresh, ...snapshot.filter(c => !freshIds.has(c.id)).map(c =>
(versions[c.id] ?? 0) > started && patches[c.id] ? { ...c, ...patches[c.id], tone: patches[c.id].tone ?? c.tone } : c)]
}
function hitlSource(card: ActivityCard): string | undefined {
for (const node of card.body ?? []) {
const value = node.type === 'kv' ? node.kv?.find(([key]) => key === '来源')?.[1] : undefined
if (value?.startsWith('hitl:')) return value.slice(5)
}
}
function actionFeedback(key: string, value?: 'submitting' | 'unknown', ids: string[] = [], owner?: symbol) {
let states: AppState['cardActionStates'] | undefined
for (const id of [key, ...ids]) {
if (owner && actionFeedbackOwners[id] !== owner) continue
const current = states ?? state.cardActionStates
if (value ? current[id] === value : !(id in current)) continue
states ??= { ...state.cardActionStates }
if (value) states[id] = value
else { delete states[id]; delete actionFeedbackOwners[id] }
}
if (states) set({ cardActionStates: states })
}
function startActionFeedback(key: string, ids: string[] = []) {
const owner = Symbol(key)
for (const id of [key, ...ids]) actionFeedbackOwners[id] = owner
actionFeedback(key, 'submitting', ids, owner)
return owner
}
function settleCardFeedback(cards: ActivityCard[]) {
let states: AppState['cardActionStates'] | undefined
for (const card of cards) {
if (card.status === 'action_required') continue
const source = hitlSource(card)
for (const id of [card.id, ...(source ? [`hitl:${source}`] : [])]) {
if (!(id in (states ?? state.cardActionStates))) continue
states ??= { ...state.cardActionStates }
delete states[id]
delete actionFeedbackOwners[id]
}
}
if (states) set({ cardActionStates: states })
}
const liveEventTypes = new Set<StreamEvent['type']>([
'message.delta', 'thinking.delta', 'tool.call.delta', 'tool.call', 'tool.output',
'tool.result', 'run.phase', 'run.check',
])
export function useStore<T>(selector: (s: AppState) => T): T {
return useSyncExternalStore(
@@ -304,6 +440,19 @@ export const store = {
},
async openProject(projectId: string) {
if (state.currentProjectId !== projectId) {
projectSse?.close(); projectSse = null
sse?.close(); sse = null
projectSeq = 0
projectCardsRequest++
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
conversationEpoch++; streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
cards: [], projectCards: [], plan: null, todos: null, surfaces: [], projectSurfaces: [],
artifacts: [], outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false, filesView: 'list' })
}
set({ currentProjectId: projectId })
if (store.getState().inboxScope === 'project') {
store.loadProjectCards()
@@ -372,7 +521,11 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
const epoch = ++conversationEpoch
streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
setUI({ previewExpanded: false })
persistConvId(convId)
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
if ((state.previewTabs['_'] ?? []).length > 0) {
@@ -383,48 +536,105 @@ export const store = {
})
}
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
api.surfaceList(convId).then(list => { if (state.currentConvId === convId) set({ surfaces: list ?? [] }) }).catch(() => {})
// surfaceList 返回扁平 SurfaceMeta,不可写入 surfaces;必须经 surfaceGet 水合为 Snapshot。
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
api.surfaceList(convId).then(async list => {
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
const metas = list ?? []
// 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。标签只用 Meta。
const conv = previewTabsKey(state.currentConvId)
const tabs = state.previewTabs[conv] ?? []
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
if (!hasSurfaceTab && metas.length > 0) {
const latest = metas[0]
const opened = openPreviewTab(tabs, { id: 'surface:' + String(latest.surfaceId), name: latest.title || '页面' })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
}
const fetched = await Promise.all(metas.map(m =>
api.surfaceGet(String(m.surfaceId)).catch(() => null),
))
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
const hydrated = fetched.filter((s): s is SurfaceSnapshot => surfaceKey(s) != null)
// 慢水合不得覆盖并发 surface.updated 已写入的较新 revision。
set({ surfaces: mergeSurfacesByRevision(state.surfaces, hydrated) })
}).catch(() => {})
const resumeSeq = await store.reconcile()
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
store.connectLive(resumeSeq ?? state.lastSeq)
},
// 终态对账:以 Query 的权威消息树/运行树/游标为准收敛。
// 返回值是实时流应从这个 seq 之后重放;有进行中的 run 时它早于 lastSeq。
async reconcile(): Promise<number | undefined> {
if (!state.currentConvId) return
const convId = state.currentConvId
const epoch = conversationEpoch
const request = ++detailRequest
const started = detailVersion
if (!convId) return
try {
const d = await api.conversationDetail(state.currentConvId)
const liveRunActive = state.live && !isTerminal(d.runs.find(r => r.id === state.live!.runId)?.status)
const d = await api.conversationDetail(convId)
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== detailRequest || d.lastSeq < detailSeq) return
// A background frame may overtake this snapshot without changing its
// messages/runs. Preserve only newer observations of the same object.
const messages = mergeDetailRows(d.messages, state.messages, messageObservations, started, d.lastSeq)
const snapshotRuns = new Map(d.runs.map(run => [run.id, run]))
const runs = mergeDetailRows(d.runs, state.runs, runObservations, started, d.lastSeq).map(run => {
const snapshot = snapshotRuns.get(run.id)
const newerTerminal = terminalObservationIsNewer(run.id, started, d.lastSeq)
return snapshot && isTerminal(snapshot.status) && !isTerminal(run.status) && !newerTerminal ? snapshot : run
})
const activeHitl = mergeDetailRows(d.activeHitl, state.activeHitl, hitlObservations, started, d.lastSeq)
.filter(card => !detailRowIsNewer(hitlObservations, card.id, started, d.lastSeq) || state.activeHitl.some(c => c.id === card.id))
const live = state.live
const liveRunActive = live && (!isTerminal(runs.find(r => r.id === live.runId)?.status) ||
(terminalObservationIsNewer(live.runId, started, d.lastSeq) && !messages.some(m => m.role === 'assistant' && m.runId === live.runId)))
const resumeSeq = d.resumeSeq ?? d.lastSeq
detailSeq = d.lastSeq
set({
messages: d.messages,
runs: d.runs,
activeHitl: d.activeHitl,
messages,
runs,
activeHitl,
lastSeq: Math.max(state.lastSeq, d.lastSeq),
live: liveRunActive ? state.live : null,
connected: true,
})
const [plan, todos] = await Promise.all([api.getPlan(d.conversation.id), api.getTodos(d.conversation.id)])
set({ plan, todos })
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== detailRequest) return
if (state.lastSeq === d.lastSeq) set({ plan, todos })
store.refreshCards(d.conversation.id)
store.refreshTasks()
return resumeSeq
} catch (e) {
set({ error: String(e) })
if (state.currentConvId === convId && conversationEpoch === epoch) set({ error: String(e) })
}
},
connectLive(since?: number) {
if (!state.currentConvId) return
const convId = state.currentConvId
const epoch = conversationEpoch
streamSeq = Math.max(streamSeq, since ?? state.lastSeq)
sse?.close()
sse = connectEvents(convId, since ?? state.lastSeq, {
onOpen: () => set({ connected: true }),
onError: () => set({ connected: false }),
onEvent: e => store.applyEvent(e),
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: true }) },
onError: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: false }) },
onEvent: e => { if (state.currentConvId === convId && conversationEpoch === epoch) store.applyEvent(e) },
})
},
applyEvent(e: StreamEvent) {
if (!state.currentConvId || e.conversationId !== state.currentConvId || !Number.isFinite(e.seq) || e.seq <= streamSeq) return
streamSeq = e.seq
const previousSeq = state.lastSeq
// Running-turn replay reconstructs transient output, but cannot regress the
// persistent card/plan/status snapshot already returned by the detail query.
if (e.seq <= previousSeq && !liveEventTypes.has(e.type)) return
set({ lastSeq: Math.max(previousSeq, e.seq) })
if (e.runId && liveEventTypes.has(e.type)) observeDetail(runObservations, e.runId, e.seq)
const now = eventTime(e)
const project = (mutate: (l: LiveState) => void) => touchLive(e.runId ?? '', mutate, now)
switch (e.type) {
@@ -472,26 +682,33 @@ export const store = {
break
}
case 'hitl.request': {
const card = e.payload as HITLCard
if (card?.id) {
observeDetail(hitlObservations, card.id, e.seq)
set({ activeHitl: [...state.activeHitl.filter(c => c.id !== card.id), card] })
}
store.reconcile()
break
}
case 'hitl.resolved': {
set({ activeHitl: state.activeHitl.filter(c => c.id !== (e.payload as { cardId: string }).cardId) })
const cardId = (e.payload as { cardId: string }).cardId
observeDetail(hitlObservations, cardId, e.seq)
set({ activeHitl: state.activeHitl.filter(c => c.id !== cardId) })
break
}
case 'agent.spawn': {
const p = e.payload as { agentRun: string; name: string; task: string }
set({ live: project(l => { l.agentNotes = { ...l.agentNotes, [p.agentRun]: `已派生 ${p.name}:${p.task}` } }) })
if (state.live) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: `已派生 ${p.name}` } } })
break
}
case 'agent.progress': {
const p = e.payload as { agentRun: string; note?: string }
set({ live: project(l => { if (p.note) l.agentNotes = { ...l.agentNotes, [p.agentRun]: p.note! } }) })
if (state.live && p.note) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.note } } })
break
}
case 'agent.result': {
const p = e.payload as { agentRun: string; summary?: string }
set({ live: project(l => { l.agentNotes = { ...l.agentNotes, [p.agentRun]: p.summary ?? '完成' } }) })
if (state.live) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.summary ?? '任务已结束' } } })
store.reconcile()
break
}
@@ -509,25 +726,26 @@ export const store = {
// KTD8:单一状态所有者——服务端快照为权威,本地按 revision 守卫:
// 旧修订/重复事件不覆盖新修订;切会话由 selectConversation 清空。
const p = e.payload as SurfaceSnapshot
if (!p?.identity?.surfaceId) break
const id = String(p.identity.surfaceId)
const surfaces = state.surfaces.filter(x => String(x.identity.surfaceId) !== id)
const existing = state.surfaces.find(x => String(x.identity.surfaceId) === id)
const id = surfaceKey(p)
if (!id) break
const surfaces = state.surfaces.filter(x => surfaceKey(x) !== id)
const existing = state.surfaces.find(x => surfaceKey(x) === id)
if (!existing || p.revision > existing.revision) {
surfaces.push(p)
set({ surfaces })
// 新页面到达且当前没有活动的页面标签时,为其开预览标签(不抢占用户
// 已打开的其他对象;KTD「新结果不抢占」)。
// Only update the title of the page already being read. New background
// pages stay in the work list and never open a pane or change selection.
const conv = previewTabsKey(state.currentConvId)
const tabs = state.previewTabs[conv] ?? []
const activeId = state.previewActive[conv] ?? null
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
if (!hasSurfaceTab || (activeId ?? '').startsWith('surface:')) {
if (activeId === 'surface:' + id) {
const opened = openPreviewTab(tabs, { id: 'surface:' + id, name: p.spec?.title || '页面' })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
} else {
store.toast(`新成果可查看:${p.spec?.title || '任务页面'}`)
}
}
break
@@ -540,14 +758,20 @@ export const store = {
}
case 'card.status': {
const p = e.payload as { cardId: string; status: ActivityCard['status']; tone?: string }
cardVersions[p.cardId] = ++cardVersion
cardPatches[p.cardId] = { status: p.status, tone: p.tone }
const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)
set({ cards })
settleCardFeedback(cards)
break
}
case 'card.resolved': {
const p = e.payload as { cardId: string }
cardVersions[p.cardId] = ++cardVersion
cardPatches[p.cardId] = { status: 'resolved' }
const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)
set({ cards })
settleCardFeedback(cards)
break
}
case 'artifact.created':
@@ -563,17 +787,19 @@ export const store = {
break
}
case 'run.completed': {
if (e.runId) observeDetail(runObservations, e.runId, e.seq, true)
const p = e.payload as { usage?: unknown; context?: ContextObs }
if (p?.context && e.runId) {
set({ contextObs: { ...state.contextObs, [e.runId]: p.context } })
}
setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
scheduleTerminalReconcile()
break
}
case 'run.failed':
case 'error': {
if (e.runId && e.type === 'run.failed') observeDetail(runObservations, e.runId, e.seq, true)
// 终态:短暂延迟后对账(等待消息树落库),折叠过程输出,再发送排队消息。
setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
scheduleTerminalReconcile()
break
}
default: {
@@ -584,11 +810,15 @@ export const store = {
},
async sendMessage(text: string, attachmentIds: string[], requestId?: string): Promise<'ok' | 'busy'> {
if (!state.currentConvId) return 'ok'
const convId = state.currentConvId
const epoch = conversationEpoch
const sameContext = () => state.currentConvId === convId && conversationEpoch === epoch
if (!convId) return 'ok'
const id = requestId ?? newQueuedMessage(text, attachmentIds, []).id
set({ posting: true })
try {
const out = await api.sendMessage(state.currentConvId, text, attachmentIds, id)
const out = await api.sendMessage(convId, text, attachmentIds, id)
if (!sameContext()) return 'ok'
if (out.command) {
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
if (out.conversation) await store.openConversation(out.conversation.id)
@@ -598,6 +828,8 @@ export const store = {
}
if (out.run) {
const started = out.run
observeDetail(runObservations, started.id)
observeDetail(messageObservations, out.message.id)
// 立刻把 run 合进列表,停止/排队不必等终态对账。已有同 id 记录保持不动。
const runs = mergeStartedRun(state.runs, started)
const messages = state.messages.some(m => m.id === out.message.id)
@@ -618,6 +850,7 @@ export const store = {
}
return 'ok'
} catch (e) {
if (!sameContext()) return 'ok'
if (isConversationBusy(e)) {
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [] }) })
return 'busy'
@@ -626,7 +859,7 @@ export const store = {
await store.reconcile()
return 'ok'
} finally {
set({ posting: false })
if (sameContext()) set({ posting: false })
}
},
@@ -685,45 +918,124 @@ export const store = {
},
async resolveHITL(cardId: string, resp: { choiceId?: string; freeText?: string }) {
const key = `hitl:${cardId}`
const related = [...state.cards, ...state.projectCards].filter(c => hitlSource(c) === cardId).map(c => c.id)
if (actionInflight.has(key) || state.cardActionStates[key]) return
const convId = state.currentConvId
const epoch = conversationEpoch
actionInflight.add(key)
const feedbackOwner = startActionFeedback(key, related)
try {
const out = await api.resolveHITL(cardId, resp)
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
if (!out.resolved) {
actionFeedback(key, 'unknown', related, feedbackOwner)
await store.reconcile()
return
}
set({ activeHitl: state.activeHitl.filter(c => c.id !== cardId) })
// Resolution is not necessarily execution: fetch the actual confirmation
// outcome (including cancellation) instead of stamping every choice resolved.
if (convId) await store.refreshCards(convId)
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
actionFeedback(key, undefined, [], feedbackOwner)
for (const id of related) {
const current = [...state.cards, ...state.projectCards].find(c => c.id === id)
actionFeedback(id, current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
}
if (out.pivoted && out.pivotText) {
await store.sendMessage(out.pivotText, [])
}
} catch (e) {
store.toast(`HITL 解析失败:${e}`)
const rejected = /^4\d\d:/.test(String(e instanceof Error ? e.message : e))
actionFeedback(key, rejected ? undefined : 'unknown', related, feedbackOwner)
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
if (rejected) {
store.toast('本次确认未被接受,请查看当前事项后再处理')
} else {
store.toast('确认结果暂时未知,正在核实处理状态')
await store.reconcile()
}
} finally {
actionInflight.delete(key)
}
},
upsertCard(card: ActivityCard) {
if (card.conversationId && card.conversationId !== state.currentConvId) return
cardVersions[card.id] = ++cardVersion
delete cardPatches[card.id]
const i = state.cards.findIndex(c => c.id === card.id)
const cards = i >= 0 ? state.cards.map(c => c.id === card.id ? card : c) : [card, ...state.cards]
set({ cards })
settleCardFeedback([card])
},
async refreshCards(convId: string) {
try { set({ cards: await api.listCards(convId) }) } catch { /* 会话竞态,静默 */ }
const epoch = conversationEpoch
const request = ++cardsRequest
const started = cardVersion
try {
const snapshot = await api.listCards(convId)
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== cardsRequest) return
const cards = mergeCardSnapshot(snapshot, state.cards, cardVersions, started, cardPatches)
set({ cards })
settleCardFeedback(cards)
} catch { /* Keep the last observed snapshot while disconnected. */ }
},
// 卡片动作:确认投影卡(来源 hitl:)路由回 HITL 解析(恢复挂起 run,服务端同步销项);
// 其余卡走卡片动作 API,并把服务端持久化的回执消息追加进本地时间线(id 一致,对账不重复)。
async cardAction(card: ActivityCard, actionId: string, freeText?: string) {
const hitlRef = card.body?.find(n => n.type === 'kv')?.kv?.find(([k]) => k === '来源')?.[1]
if (hitlRef?.startsWith('hitl:')) {
await store.resolveHITL(hitlRef.slice(5), actionId ? { choiceId: actionId } : { freeText })
const source = hitlSource(card)
if (source) {
await store.resolveHITL(source, actionId ? { choiceId: actionId } : { freeText })
return
}
if (actionInflight.has(card.id) || state.cardActionStates[card.id]) return
const convId = state.currentConvId
const pid = state.currentProjectId
const epoch = conversationEpoch
const startedVersion = card.conversationId === state.currentConvId ? cardVersion : projectCardVersion
const sameContext = () => state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
actionInflight.add(card.id)
const feedbackOwner = startActionFeedback(card.id)
try {
const out = await api.cardAction(card.id, actionId, freeText)
store.upsertCard(out.card)
if (out.receipt) set({ messages: [...state.messages, out.receipt as Message] })
if (!sameContext()) return
const newer = (card.conversationId === state.currentConvId ? cardVersions[card.id] : projectCardVersions[card.id]) > startedVersion
if (!newer && card.conversationId === state.currentConvId) store.upsertCard(out.card)
else if (!newer) {
projectCardVersions[out.card.id] = ++projectCardVersion
const cards = state.projectCards
set({ projectCards: cards.some(c => c.id === out.card.id) ? cards.map(c => c.id === out.card.id ? out.card : c) : [out.card, ...cards] })
}
if (out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) {
observeDetail(messageObservations, out.receipt.id)
set({ messages: [...state.messages, out.receipt as Message] })
}
// 命令确认卡的动作响应是决议前快照。再拉一次,内联卡才能从待确认过渡到折叠。
if (card.kind === 'confirm' && actionId !== 'recheck' && out.card.status === 'action_required' && card.conversationId) {
await store.refreshCards(card.conversationId)
if (!newer && card.kind === 'confirm' && actionId !== 'recheck' && out.card.status === 'action_required' && card.conversationId) {
if (card.conversationId === state.currentConvId) await store.refreshCards(card.conversationId)
else await store.loadProjectCards()
}
if (sameContext()) {
const current = [...state.cards, ...state.projectCards].find(c => c.id === card.id)
actionFeedback(card.id, actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
}
} catch (e) {
store.toast(`操作失败:${e}`)
const rejected = /^4\d\d:/.test(String(e instanceof Error ? e.message : e))
actionFeedback(card.id, rejected ? undefined : 'unknown', [], feedbackOwner)
if (!sameContext()) return
if (rejected) {
store.toast('本次操作未被接受,请查看当前事项后再处理')
} else {
store.toast('操作结果暂时未知,正在核实处理状态')
if (card.conversationId === state.currentConvId) await store.refreshCards(card.conversationId)
else await store.loadProjectCards()
}
} finally {
actionInflight.delete(card.id)
}
},
@@ -738,6 +1050,7 @@ export const store = {
async refreshArtifacts(projectId: string) {
try {
const artifacts = await api.listArtifacts(projectId)
if (state.currentProjectId !== projectId) return
set({ artifacts })
} catch { /* 项目删除竞态等,静默 */ }
},
@@ -756,6 +1069,21 @@ export const store = {
const ws = await api.listWorkspaces()
set({ serverWorkspaces: ws ?? [] })
} catch { /* ignore */ }
// 审计入口能力门控(增量 2):与工作区清单同源刷新——能力撤销(插件卸载/命令停用)后入口随之隐藏。
try {
const cmds = await api.listCommands()
set({
rdmAuditAvailable: rdmAuditReady(cmds),
rdmRolesAvailable: rdmRolesReady(cmds),
rdmMembersAvailable: rdmMembersReady(cmds),
rdmDocReviewAvailable: rdmDocReviewReady(cmds),
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
})
} catch { set({ rdmAuditAvailable: false }) }
try {
const sections = await api.adminZoneSections()
set({ adminSections: sections ?? [] })
} catch { /* ignore */ }
try {
const settings = await api.getSettings()
const runtime = settings.user.runtime
@@ -765,14 +1093,16 @@ export const store = {
},
async uploadAttachment(file: File): Promise<string | null> {
if (!state.currentProjectId) return null
const pid = state.currentProjectId
if (!pid) return null
try {
const up = await api.upload(state.currentProjectId, file)
store.toast(`已上传 ${up.filename}${up.parsed ? `(解析:${up.parsed.meta.parser})` : ''}`)
await store.refreshArtifacts(state.currentProjectId)
const up = await api.upload(pid, file)
if (state.currentProjectId !== pid) return null
store.toast(`已收到材料:${up.filename}${up.parsed ? ' · 已读取内容' : ''}`)
await store.refreshArtifacts(pid)
return up.artifactId
} catch (e) {
store.toast(`上传失败:${e}`)
if (state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择')
return null
}
},
@@ -786,7 +1116,15 @@ export const store = {
async loadProjectCards() {
const pid = store.getState().currentProjectId
if (!pid) return
try { set({ projectCards: await api.listProjectCards(pid) }) } catch { /* 静默:切项目竞态 */ }
const request = ++projectCardsRequest
const started = projectCardVersion
try {
const snapshot = await api.listProjectCards(pid)
if (state.currentProjectId !== pid || request !== projectCardsRequest) return
const projectCards = mergeCardSnapshot(snapshot, state.projectCards, projectCardVersions, started, projectCardPatches)
set({ projectCards })
settleCardFeedback(projectCards)
} catch { /* Keep existing readable content while reconnecting. */ }
},
setInboxScope(scope: 'conv' | 'project') {
@@ -804,15 +1142,19 @@ export const store = {
const pid = store.getState().currentProjectId
if (!pid) return
projectSse?.close()
projectSse = connectEvents('', projectSeq, { onEvent: e => store.applyProjectEvent(e) }, pid)
projectSse = connectEvents('', projectSeq, { onEvent: e => { if (state.currentProjectId === pid) store.applyProjectEvent(e) } }, pid)
},
applyProjectEvent(e: StreamEvent) {
projectSeq = Math.max(projectSeq, e.seq)
if (!Number.isFinite(e.seq) || e.seq <= projectSeq) return
projectSeq = e.seq
const upsert = (card: ActivityCard) => {
if (card.projectId !== state.currentProjectId) return
projectCardVersions[card.id] = ++projectCardVersion
const list = store.getState().projectCards
const i = list.findIndex(c => c.id === card.id)
set({ projectCards: i >= 0 ? list.map(c => (c.id === card.id ? card : c)) : [card, ...list] })
settleCardFeedback([card])
}
switch (e.type) {
case 'card.created':
@@ -821,14 +1163,20 @@ export const store = {
break
case 'card.status': {
const p = e.payload as { cardId: string; status: ActivityCard['status']; tone?: string }
projectCardVersions[p.cardId] = ++projectCardVersion
projectCardPatches[p.cardId] = { status: p.status, tone: p.tone }
const list = store.getState().projectCards
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)) })
settleCardFeedback(state.projectCards)
break
}
case 'card.resolved': {
const p = e.payload as { cardId: string }
projectCardVersions[p.cardId] = ++projectCardVersion
projectCardPatches[p.cardId] = { status: 'resolved' }
const list = store.getState().projectCards
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)) })
settleCardFeedback(state.projectCards)
break
}
}
@@ -849,7 +1197,8 @@ export const store = {
},
async tidyCards() {
const done = state.cards.filter(c => c.status === 'done')
// U1:不收纳失败语义卡(failed,或旧 done+danger),避免把失败当完成清掉。
const done = state.cards.filter(c => c.status === 'done' && c.tone !== 'danger')
for (const c of done) {
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
}
@@ -872,6 +1221,7 @@ export const store = {
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
locateMessage(messageId?: string) {
if (!messageId) return
setUI({ rightCollapsed: true, previewExpanded: false })
setTimeout(() => flashLocate('.chat', 'data-msg-id', messageId), 60)
},
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
@@ -885,20 +1235,48 @@ export const store = {
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
previewOpenIntent: state.previewOpenIntent + 1,
})
syncFilesEntry(true)
},
openSurface(surfaceId: string) {
// generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
const snap = state.surfaces.find(x => surfaceKey(x) === surfaceId)
const conv = previewTabsKey(state.currentConvId)
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: 'surface:' + surfaceId, name: snap?.spec?.title || '页面' })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
previewOpenIntent: state.previewOpenIntent + 1,
})
syncFilesEntry(true)
},
// U5:页面动作提交——requestId 由客户端生成(服务端幂等键);草稿只在此刻
// 随动作外发(未提交输入不上传、不进模型上下文)。在途防抖挡双击。
async handleSurfaceAction(surfaceId: string, actionId: string, data: Record<string, unknown>) {
const inflight = surfaceActionsInflight[surfaceId + ':' + actionId]
if (inflight) return
const snap = state.surfaces.find(x => surfaceKey(x) === surfaceId)
if (!snap) { store.toast('页面快照不可用,无法提交'); return }
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2))
surfaceActionsInflight[surfaceId + ':' + actionId] = true
try {
const res = await api.submitSurfaceAction(surfaceId, {
actionId, revision: snap.revision, requestId, data,
})
if (res?.state === 'awaiting_confirm') store.toast('高危动作已发确认卡,请确认后执行')
else if (res?.state === 'committed') store.toast('已提交')
else store.toast('动作状态:' + (res?.state ?? 'unknown'))
} catch (e: any) {
const msg = String(e?.message ?? e)
if (msg.includes('revision conflict')) store.toast('页面已更新,请刷新后重试')
else if (msg.includes('not enabled') || msg.includes('403')) store.toast('动作被拒绝:插件未启用')
else if (msg.includes('different params')) store.toast('重复请求参数不一致,已拒绝')
else store.toast('动作失败:' + msg)
} finally {
delete surfaceActionsInflight[surfaceId + ':' + actionId]
}
},
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
activatePreview(id: string) {
const conv = previewTabsKey(state.currentConvId)
@@ -923,6 +1301,8 @@ export const store = {
}
function touchLive(_runId: string, mutate: (l: LiveState) => void, now = Date.now()): LiveState | null {
if (state.runs.find(r => r.id === _runId)?.kind === 'subagent') return state.live
if (state.live && state.live.runId !== _runId && !isTerminal(state.runs.find(r => r.id === state.live?.runId)?.status)) return state.live
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: now }
const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } }
mutate(next)
+54
View File
@@ -577,3 +577,57 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.session-note { width: 12rem; }
.rewind-actions { display: flex; gap: 8px; }
.rewind-error { color: var(--err-text); font-size: var(--fs-ui-sm); }
/* ---- U4 追溯审计工作区(令牌驱动;@ui/tokens.css 消费) ---- */
.rdm-audit { display: flex; flex-direction: column; min-height: 300px; gap: 0; }
.rdm-audit-header { display: flex; align-items: center; gap: 10px; padding: 0 0 10px; }
.rdm-audit-project { max-width: 220px; height: 32px; font-size: var(--fs-ui-sm); }
.rdm-audit-tabs { padding: 0 0 10px; }
.rdm-audit-body { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
.rdm-audit-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; margin-bottom: 12px; }
.rdm-audit-card { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; text-align: center; }
.rdm-audit-card-num { font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
.rdm-audit-card-label { font-size: var(--fs-ui-xs); color: var(--text-dim); margin-top: 2px; }
.rdm-audit-meta { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 8px; }
.rdm-audit-materials { display: flex; gap: 0; min-height: 200px; }
.rdm-audit-materials-side { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.rdm-audit-tree { display: flex; gap: 14px; min-height: 200px; }
.rdm-audit-tree-list { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.rdm-audit-tree-node { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: none; border: none; border-radius: var(--radius-sm); cursor: pointer; text-align: left; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-tree-node:hover { background: var(--bg-hover); color: var(--text); }
.rdm-audit-tree-node.sel { background: var(--bg-active); color: var(--text); }
.rdm-audit-tree-caret { width: 14px; flex: none; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.rdm-audit-tree-children { padding-left: 18px; display: flex; flex-direction: column; gap: 0; }
.rdm-audit-tree-kind { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 3.2em; }
.rdm-audit-tree-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rdm-audit-more { margin-top: 8px; }
.rdm-audit-truncated { font-size: var(--fs-ui-sm); color: var(--text-faint); padding: 6px 0; }
.rdm-audit-detail { flex: 1; min-width: 0; border-left: 1px solid var(--border-soft); padding-left: 14px; }
.rdm-audit-detail-body { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-ui-sm); }
.rdm-audit-version { padding: 3px 0; color: var(--text-dim); }
.rdm-audit-formal { margin-top: 8px; padding: 8px 10px; background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--ok-text); }
.rdm-audit-pick { display: flex; flex-wrap: wrap; gap: 8px; }
.rdm-audit-trace-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.rdm-audit-trace-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.rdm-audit-trace-col { min-width: 0; }
.rdm-audit-rel { display: flex; align-items: center; gap: 6px; padding: 4px 0; font-size: var(--fs-ui-sm); }
.rdm-audit-rel-type { color: var(--text-dim); }
.rdm-audit-rel-endpoint { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rdm-audit-timeline-filters { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.rdm-audit-timeline-filters .ui-input { max-width: 160px; height: 30px; font-size: var(--fs-ui-sm); }
.rdm-audit-seq { max-width: 100px; }
.rdm-audit-opgroup { border: 1px solid var(--border-soft); border-radius: var(--radius-sm); margin-bottom: 4px; overflow: hidden; }
.rdm-audit-opgroup-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; background: var(--bg-panel); border: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text-dim); text-align: left; }
.rdm-audit-opgroup-head:hover { background: var(--bg-hover); }
.rdm-audit-opgroup-actor { font-weight: 550; color: var(--text); }
.rdm-audit-opgroup-time { font-size: var(--fs-ui-xs); color: var(--text-faint); }
.rdm-audit-event { display: flex; align-items: center; gap: 8px; padding: 4px 10px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 5em; }
.rdm-audit-event-op { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--accent-text); min-width: 8em; }
.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; }
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
.rdm-audit-coverage { margin-top: 12px; }
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
.rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }
+140 -4
View File
@@ -1,13 +1,16 @@
import { describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as apiModule from './api'
import { api } from './api'
import { store } from './store'
import type { StreamEvent, SurfaceSnapshot } from './contracts'
import { previewTabsKey } from './previewtabs'
import type { StreamEvent, SurfaceMeta, SurfaceSnapshot } from './contracts'
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
// 服务端快照是唯一权威(KTD8 单一状态所有者)。
function surfEvent(seq: number, surfaceId: string, revision: number, title: string): StreamEvent<SurfaceSnapshot> {
function surfEvent(seq: number, surfaceId: string, revision: number, title: string, conversationId = 'c1'): StreamEvent<SurfaceSnapshot> {
return {
seq, conversationId: 'c1', ts: new Date().toISOString(), v: 1, type: 'surface.updated',
seq, conversationId, ts: new Date().toISOString(), v: 1, type: 'surface.updated',
payload: {
identity: { surfaceId, owner: 'alice', pluginId: 'generative-ui', conversationId: 'c1' },
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' },
@@ -17,6 +20,24 @@ function surfEvent(seq: number, surfaceId: string, revision: number, title: stri
}
}
beforeEach(async () => {
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => ({
conversation: { id, projectId: 'p1', title: id, createdAt: '', updatedAt: '' },
messages: [], runs: [], activeHitl: [], lastSeq: 0,
}))
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
vi.spyOn(api, 'listCards').mockResolvedValue([])
vi.spyOn(api, 'listTasks').mockResolvedValue([])
vi.spyOn(apiModule, 'connectEvents').mockReturnValue({ close: () => {} })
await store.openConversation('c1')
for (const tab of store.getState().previewTabs.c1 ?? []) store.closePreview(tab.id)
store.setUI({ rightTab: 'work', rightCollapsed: true, filesView: 'list' })
})
afterEach(() => vi.restoreAllMocks())
describe('surface.updated projection', () => {
it('新事件入库;旧修订与重复事件不覆盖', () => {
store.applyEvent(surfEvent(1, 's1', 1, '初版'))
@@ -30,6 +51,7 @@ describe('surface.updated projection', () => {
})
it('多页面并存,互不覆盖', () => {
store.applyEvent(surfEvent(1, 's1', 1, '页面一'))
store.applyEvent(surfEvent(5, 's2', 1, '页面二'))
const list = store.getState().surfaces
expect(list.filter(x => String(x.identity.surfaceId) === 's1')).toHaveLength(1)
@@ -37,3 +59,117 @@ describe('surface.updated projection', () => {
})
})
describe('surface.updated 可见性联动', () => {
it('keeps new background pages available without opening the file pane', () => {
store.applyEvent(surfEvent(101, 'sv1', 1, '可见性页'))
const ui = store.getState().ui
expect(ui.rightTab).toBe('work')
expect(ui.rightCollapsed).toBe(true)
expect(ui.filesView).toBe('list')
const tabs = store.getState().previewTabs[previewTabsKey(store.getState().currentConvId)] ?? []
expect(tabs.some(t => t.id === 'surface:sv1')).toBe(false)
store.openSurface('sv1')
expect(store.getState().ui.rightTab).toBe('files')
expect(store.getState().previewActive.c1).toBe('surface:sv1')
})
it('抑制开标签时留 toast 痕迹', () => {
// 预置:已有 surface 标签但 active 是别的工件标签 → 新页面不开标签
store.applyEvent(surfEvent(102, 'sv2', 1, '第二页'))
// sv2 已入 surfaces 数组(事件投影),标签未强开——至少状态一致
expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true)
})
})
describe('U6 工作区任务页', () => {
it('pin 固定后新页面事件不改变固定对象;取消后跟随最新', () => {
store.setUI({ workspaceSurfacePin: null })
store.applyEvent(surfEvent(201, 'w1', 1, '第一页'))
const first = store.getState().ui.workspaceSurfacePin
expect(first).toBeNull() // 默认跟随(不自动 pin)
// 用户固定 w1
store.setUI({ workspaceSurfacePin: 'w1' })
store.applyEvent(surfEvent(202, 'w2', 1, '第二页'))
// pin 不变,且 surfaces 数组两页并存(禁用回退时容器随空数组自然消失)
expect(store.getState().ui.workspaceSurfacePin).toBe('w1')
const w2 = store.getState().surfaces.find(x => String(x.identity.surfaceId) === 'w2')
expect(w2?.spec.title).toBe('第二页')
store.setUI({ workspaceSurfacePin: null })
expect(store.getState().ui.workspaceSurfacePin).toBeNull()
})
})
describe('saved surfaces on conversation reload', () => {
const snapshot = (id: string, revision = 1) => surfEvent(1, id, revision, id).payload!
const meta = (id: string): SurfaceMeta => ({
surfaceId: id, title: id, pluginId: 'generative-ui', revision: 1,
conversationId: 'c1', updatedAt: '2026-10-12T00:00:00Z',
})
beforeEach(() => {
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
vi.spyOn(api, 'surfaceGet').mockImplementation(async id => snapshot(id))
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => ({
conversation: { id, projectId: 'p1', title: id, createdAt: '', updatedAt: '' },
messages: [], runs: [], activeHitl: [], lastSeq: 0,
}))
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
vi.spyOn(api, 'listCards').mockResolvedValue([])
vi.spyOn(api, 'listTasks').mockResolvedValue([])
vi.spyOn(apiModule, 'connectEvents').mockReturnValue({ close: () => {} })
store.setUI({ workspaceSurfacePin: null })
const key = previewTabsKey(store.getState().currentConvId)
for (const tab of store.getState().previewTabs[key] ?? []) store.closePreview(tab.id)
})
afterEach(() => vi.restoreAllMocks())
it('hydrates the metadata directory before exposing two renderable snapshots', async () => {
vi.mocked(api.surfaceList).mockResolvedValue([meta('reload-1'), meta('reload-2')])
await store.openConversation('reload')
await vi.waitFor(() => {
expect(store.getState().surfaces).toEqual([snapshot('reload-1'), snapshot('reload-2')])
})
expect(api.surfaceGet).toHaveBeenCalledWith('reload-1')
expect(api.surfaceGet).toHaveBeenCalledWith('reload-2')
expect(store.getState().previewActive.reload).toBe('surface:reload-1')
})
it('isolates one unavailable page while restoring the remaining page', async () => {
vi.mocked(api.surfaceList).mockResolvedValue([meta('missing'), meta('available')])
vi.mocked(api.surfaceGet).mockImplementation(async id => {
if (id === 'missing') throw new Error('404: removed page')
return snapshot(id)
})
await store.openConversation('partial')
await vi.waitFor(() => expect(store.getState().surfaces).toEqual([snapshot('available')]))
})
it('keeps a newer live revision when an older snapshot arrives late', async () => {
let resolve!: (value: SurfaceSnapshot) => void
vi.mocked(api.surfaceList).mockResolvedValue([meta('racing')])
vi.mocked(api.surfaceGet).mockReturnValue(new Promise<SurfaceSnapshot>(r => { resolve = r }))
await store.openConversation('race')
store.applyEvent(surfEvent(301, 'racing', 2, 'live revision', 'race'))
resolve(snapshot('racing', 1))
await new Promise<void>(r => setTimeout(r, 0))
expect(store.getState().surfaces).toHaveLength(1)
expect(store.getState().surfaces[0].revision).toBe(2)
expect(store.getState().surfaces[0].spec.title).toBe('live revision')
})
it('discards hydration from the previous conversation after switching', async () => {
let resolve!: (value: SurfaceSnapshot) => void
vi.mocked(api.surfaceList).mockResolvedValueOnce([meta('previous')]).mockResolvedValue([])
vi.mocked(api.surfaceGet).mockReturnValue(new Promise<SurfaceSnapshot>(r => { resolve = r }))
await store.openConversation('previous-conv')
await store.openConversation('current-conv')
resolve(snapshot('previous'))
await new Promise<void>(r => setTimeout(r, 0))
expect(store.getState().currentConvId).toBe('current-conv')
expect(store.getState().surfaces).toEqual([])
})
})
+19
View File
@@ -0,0 +1,19 @@
// surface 投影可能短暂混入无 identity 的条目;读 id 一律经此助手,避免渲染崩溃。
import type { SurfaceSnapshot } from './contracts'
export function surfaceKey(x: { identity?: { surfaceId?: unknown } } | null | undefined): string | null {
const id = x?.identity?.surfaceId
return id == null || id === '' ? null : String(id)
}
/** 按 surfaceId 合并;同 id 取 revision 较大者。跳过无 identity 的脏项。 */
export function mergeSurfacesByRevision(a: SurfaceSnapshot[], b: SurfaceSnapshot[]): SurfaceSnapshot[] {
const byId = new Map<string, SurfaceSnapshot>()
for (const s of [...a, ...b]) {
const id = surfaceKey(s)
if (!id) continue
const existing = byId.get(id)
if (!existing || (s.revision ?? 0) >= (existing.revision ?? 0)) byId.set(id, s)
}
return [...byId.values()]
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
// U5 验收线第 4/6 条的客户端半边:请求构造只携带显式动作数据(未提交草稿
// 无任何自动上传路径——构造函数是纯函数,测试其形状);requestId 每次唯一。
function buildActionRequest(snapshot: { revision: number }, actionId: string, drafts: Record<string, unknown>) {
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now())
return {
actionId,
revision: snapshot.revision,
requestId,
data: drafts,
}
}
describe('surface action request', () => {
it('携带当前修订与显式草稿;requestId 唯一', () => {
const snap = { revision: 3 }
const a = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
const b = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
expect(a.revision).toBe(3)
expect(a.actionId).toBe('generative-ui.submit')
expect(a.data).toEqual({ f1: { years: '5' } })
expect(a.requestId).not.toBe(b.requestId)
})
})
+69
View File
@@ -0,0 +1,69 @@
export interface WorkbenchLayoutOptions {
width: number
contextOpen: boolean
leftCollapsed: boolean
rightCollapsed: boolean
leftWidth: number
rightWidth: number
previewWidth: number
rightTab: 'work' | 'files'
filesView: 'list' | 'preview'
previewOpen: boolean
previewExpanded: boolean
}
export type WorkbenchPane = 'context' | 'conversation' | 'work' | 'files'
const COMPACT_WORKBENCH_WIDTH = 1024
function boundedWidth(value: number, fallback: number, min: number, max: number) {
return Math.min(max, Math.max(min, Number.isFinite(value) ? value : fallback))
}
// Only the rendered widths are bounded. Saved desktop preferences stay in the store.
export function selectWorkbenchLayout(options: WorkbenchLayoutOptions) {
const width = Number.isFinite(options.width) ? Math.max(0, options.width) : 1440
const available = Math.max(0, width - 8)
const compact = width < COMPACT_WORKBENCH_WIDTH
const filesWide = options.rightTab === 'files' && options.filesView === 'preview' && options.previewOpen
const previewExpanded = filesWide && options.previewExpanded && !options.rightCollapsed
if (compact) {
const pane: WorkbenchPane = options.contextOpen ? 'context' : options.rightCollapsed ? 'conversation' : options.rightTab
return {
compact, pane, showContext: pane === 'context', showConversation: pane === 'conversation',
showRight: pane === 'work' || pane === 'files', contextCollapsed: false, previewExpanded,
leftWidth: pane === 'context' ? available : 0,
rightWidth: pane === 'work' || pane === 'files' ? available : 0,
leftDivider: false, rightDivider: false,
}
}
const showRight = !options.rightCollapsed
const leftDivider = !options.leftCollapsed
const rightDivider = showRight && !previewExpanded
const panelCount = 1 + Number(!previewExpanded) + Number(showRight)
const gutters = (panelCount - 1) * 4 + (Number(leftDivider) + Number(rightDivider)) * 8
const leftMin = options.leftCollapsed ? 52 : 220
const rightMin = showRight ? filesWide ? 420 : 280 : 0
let leftWidth = options.leftCollapsed ? 52 : boundedWidth(options.leftWidth, 260, leftMin, 420)
let rightWidth = showRight
? filesWide ? boundedWidth(options.previewWidth, 700, rightMin, 1100) : boundedWidth(options.rightWidth, 360, rightMin, 560)
: 0
if (previewExpanded) rightWidth = available - gutters - leftWidth
else {
const conversationMin = filesWide && showRight ? 320 : 360
const budget = available - gutters - conversationMin
if (leftWidth + rightWidth > budget) {
const extraLeft = leftWidth - leftMin
const extraRight = rightWidth - rightMin
const ratio = Math.max(0, budget - leftMin - rightMin) / (extraLeft + extraRight || 1)
leftWidth = leftMin + Math.floor(extraLeft * ratio)
rightWidth = rightMin + Math.floor(extraRight * ratio)
}
}
const pane: WorkbenchPane = previewExpanded ? 'files' : 'conversation'
return {
compact, pane,
showContext: true, showConversation: !previewExpanded, showRight, contextCollapsed: options.leftCollapsed,
previewExpanded, leftWidth, rightWidth, leftDivider, rightDivider,
}
}
+28 -1
View File
@@ -1,6 +1,9 @@
// 体系工具排位:服务端声明的工作区对所有用户可见,按 shortcut 数字占位;
// 内置「代理」补进空位。文件浏览在第三栏「文件」入口(方案:入口收敛),不再占体系工具位。
// 插件管理面板不进这条清单。
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
// 没有任何 rdm.inspect.* 命令,入口可达而进入即 400。仅当命令面探测通过
//(rdmAuditReady / store.rdmAuditAvailable)才纳入;代理仍无条件以 ⌘1 补位。
export interface WorkspaceEntry {
key: string
@@ -16,8 +19,26 @@ export interface WorkspaceSource {
shortcut?: string
}
/** 审计工作区消费的全部 rdm.inspect.* 命令(capabilities 入口 + 四页签取数)。 */
export const RDM_AUDIT_COMMANDS = [
'rdm.inspect.capabilities',
'rdm.inspect.summary',
'rdm.inspect.tree',
'rdm.inspect.object',
'rdm.inspect.relations',
'rdm.inspect.events',
'rdm.inspect.history',
] as const
/** GET /api/commands 清单是否足以支撑审计工作区:七命令齐备且 enabled。清单不可用(未登录/请求失败)视为未就绪。 */
export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
}
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
{ key: 'agents', title: '代理', icon: '🤖' },
{ key: 'rdm-audit', title: '追溯审计', icon: '🔍' },
]
function shortcutNum(raw?: string): number | null {
@@ -28,7 +49,12 @@ function shortcutNum(raw?: string): number | null {
return n >= 1 && n <= 9 ? n : null
}
export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry[] {
interface BuildWorkspaceOptions {
/** rdm.inspect.* 命令面探测通过(store.rdmAuditAvailable)。默认 false:能力未知/缺失不展示审计入口。 */
rdmAudit?: boolean
}
export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWorkspaceOptions = {}): WorkspaceEntry[] {
const used = new Set<number>()
const ranked: { rank: number; entry: Omit<WorkspaceEntry, 'shortcut'> }[] = []
const unranked: Omit<WorkspaceEntry, 'shortcut'>[] = []
@@ -56,6 +82,7 @@ export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry
return 99
}
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
if (opts.rdmAudit === true) ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
return ranked
.filter(item => item.rank <= 9)
+11
View File
@@ -40,4 +40,15 @@ describe('auth 凭证模块', () => {
expect(auth.apiKey()).toBe('')
expect(auth.withKey('/x')).toBe('/x')
})
it('beginSession:密码会话生效后清残留 Bearer,fetch 不再带头', async () => {
auth.setKey('ask_stale')
auth.beginSession()
expect(auth.apiKey()).toBe('')
const spy = vi.fn().mockResolvedValue(new Response('{}'))
vi.stubGlobal('fetch', spy)
await auth.fetch('/api/me')
const [, init] = spy.mock.calls[0] as [string, RequestInit | undefined]
expect(new Headers(init?.headers ?? {}).get('Authorization')).toBeNull()
})
})
+266
View File
@@ -0,0 +1,266 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { setPersistedOpen } from '@ui'
import type { ActivityCard, HITLCard, Message } from '../src/contracts'
import { Conversation } from '../src/components/Conversation'
import { ConversationWork } from '../src/components/ConversationWork'
import { ProcessTimeline } from '../src/components/ProcessTimeline'
import { store, type LiveState } from '../src/store'
// SSR has no browser subscription snapshot. Keep the production store/actions and
// real UI renderers; substitute only the hook boundary with a complete snapshot.
const snapshot = vi.hoisted(() => ({ state: undefined as ReturnType<typeof store.getState> | undefined }))
vi.mock('../src/store', async importOriginal => {
const actual = await importOriginal<typeof import('../src/store')>()
return { ...actual, useStore: (select: (s: ReturnType<typeof store.getState>) => unknown) => select(snapshot.state!) }
})
const timestamp = '2026-10-12T10:00:00Z'
function card(over: Partial<ActivityCard> = {}): ActivityCard {
return {
id: 'card_1', projectId: 'prj_1', conversationId: 'cnv_1',
kind: 'progress', title: '整理项目材料', status: 'active', tone: 'info',
body: [{ type: 'kv', kv: [['状态', '进行中'], ['正在做', '核对会议结论'], ['材料键', 'private-material-key']] }],
createdAt: timestamp, updatedAt: timestamp, ...over,
}
}
function reply(parts: Message['parts']): Message {
return { id: 'msg_1', conversationId: 'cnv_1', role: 'assistant', runId: 'run_1', createdAt: timestamp, parts }
}
function hitl(over: Partial<HITLCard> = {}): HITLCard {
return { id: 'hitl_detail1', runId: 'run_1', conversationId: 'cnv_1', kind: 'confirm',
question: '执行工具 run_shell?', detail: '{"command":"printf confirm-record"}',
choices: [{ id: 'yes', label: '执行', description: '按已确认的参数调用', impact: '会访问外部资源并返回结果' },
{ id: 'no', label: '取消', description: '跳过本次调用', impact: '不会执行本次操作' }],
allowFreeInput: false, createdAt: timestamp, resolved: false, ...over }
}
function render() { return renderToStaticMarkup(createElement(Conversation)) }
function renderWork() { return renderToStaticMarkup(createElement(ConversationWork)) }
function completedResults(): ActivityCard[] {
// The host lists cards newest-first; submit_card publishes markdown results.
return [
card({ id: 'result_latest', kind: 'result', title: '最新整理成果', status: 'done', tone: 'ok',
updatedAt: '2026-10-12T12:00:00Z', body: [{ type: 'markdown', markdown: '最新成果的主要结论' }] }),
card({ id: 'result_older', kind: 'result', title: '先前整理成果', status: 'done', tone: 'ok', pinned: true,
updatedAt: '2026-10-12T10:00:00Z', body: [{ type: 'markdown', markdown: '先前成果的主要结论' }] }),
]
}
beforeEach(() => {
setPersistedOpen('hitl-details:cnv_1:hitl_detail1', false)
setPersistedOpen('conversation-work:prj_1:cnv_1', false)
const base = store.getState()
snapshot.state = {
...base, currentProjectId: 'prj_1', currentConvId: 'cnv_1', connected: true,
tasks: [{ id: 'cnv_1', projectId: 'prj_1', title: '讨论项目', createdAt: timestamp, updatedAt: timestamp }],
messages: [], cards: [], runs: [], activeHitl: [], live: null, artifacts: [], outbox: [], posting: false,
ui: { ...base.ui, rightCollapsed: true, autoCollapseProcess: true },
cardActionStates: {},
}
})
describe('conversation work reading', () => {
it('keeps ordinary Q&A free of an empty progress object', () => {
snapshot.state!.messages = [reply([{ type: 'text', text: '可以先确定项目目标' }])]
const html = render()
expect(html).toContain('可以先确定项目目标')
expect(html).not.toContain('data-conversation-work')
expect(html).not.toContain('data-testid="input-status"')
})
it('shows the current real work with the auxiliary pane closed', () => {
snapshot.state!.cards = [card()]
const html = render()
expect(html).toContain('整理项目材料')
expect(html).toContain('核对会议结论')
expect(html).not.toContain('private-material-key')
})
it('shows the newest completed result by default with older results in closed history', () => {
snapshot.state!.cards = completedResults()
const html = renderWork()
expect(html).toContain('data-card-id="result_latest"')
expect(html).toContain('最新成果的主要结论')
expect(html).not.toContain('data-card-id="result_older"')
expect(html).not.toContain('先前成果的主要结论')
expect(html).toContain('更多工作与成果 · 1 项')
setPersistedOpen('conversation-work:prj_1:cnv_1', true)
expect(renderWork()).toContain('先前成果的主要结论')
})
it('keeps the newest result ahead of an already resolved confirmation', () => {
snapshot.state!.cards = [card({ id: 'confirm_resolved', kind: 'confirm', status: 'resolved', title: '已处理确认',
updatedAt: '2026-10-12T13:00:00Z' }), ...completedResults()]
const html = renderWork()
expect(html).toContain('最新成果的主要结论')
expect(html).not.toContain('data-card-id="confirm_resolved"')
expect(html).toContain('更多工作与成果 · 2 项')
})
it.each([false, true])('keeps active work ahead of completed results (pinned=%s)', pinned => {
const [latest, older] = completedResults()
snapshot.state!.cards = [latest, card({ id: 'work_active', title: '正在核验材料',
updatedAt: '2026-10-12T11:00:00Z',
body: [{ type: 'kv', kv: [['正在做', '核对活动工作的结论']] }] }), older]
if (pinned) snapshot.state!.cards.push(card({ id: 'work_pinned', pinned: true, title: '优先核验材料',
updatedAt: '2026-10-12T09:00:00Z',
body: [{ type: 'kv', kv: [['正在做', '核对固定工作的结论']] }] }))
const html = renderWork()
expect(html).toContain(pinned ? '核对固定工作的结论' : '核对活动工作的结论')
expect(html).not.toContain('data-card-id="result_latest"')
expect(html).not.toContain('data-card-id="result_older"')
if (pinned) expect(html).not.toContain('data-card-id="work_active"')
})
it('keeps the newest completed result as a compact anchor with the auxiliary pane open', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = completedResults()
const html = renderWork()
expect(html).toContain('最新整理成果 · 查看摘要')
expect(html).not.toContain('最新成果的主要结论')
expect(html).not.toContain('先前整理成果')
})
it('keeps a compact work anchor when the same summary is visible in the right pane', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card()]
const html = render()
expect(html).toContain('整理项目材料')
expect(html).not.toContain('核对会议结论')
snapshot.state!.ui.rightCollapsed = true
expect(render()).toContain('核对会议结论')
})
it('keeps secondary message operations available through progressive disclosure', () => {
snapshot.state!.messages = [reply([{ type: 'text', text: '已经整理好要点' }])]
const html = render()
expect(html).toContain('已经整理好要点')
expect(html).toContain('消息操作')
expect(html).not.toContain('排除出上下文')
expect(html).not.toContain('重新生成')
})
it('keeps failure impact and coverage limits visible without expanding history', () => {
snapshot.state!.cards = [card({ status: 'failed', tone: 'danger', body: [
{ type: 'kv', kv: [['状态', '失败'], ['当前发现', '已整理两份材料'], ['剩余', '评审意见未能读取,完整结论尚未生成']] },
] })]
const html = render()
expect(html).toContain('已整理两份材料')
expect(html).toContain('评审意见未能读取,完整结论尚未生成')
})
it('keeps a completed result limitation visible while another task is active', () => {
snapshot.state!.cards = [card(), card({ id: 'card_partial', status: 'done', tone: 'warn', title: '已整理部分材料',
body: [{ type: 'kv', kv: [['当前发现', '两份材料已整理'], ['剩余', '第三份材料未能读取,请补充清晰版本']] }],
})]
const html = render()
expect(html).toContain('第三份材料未能读取,请补充清晰版本')
})
it('hides running command records while keeping the final reply readable', () => {
const live: LiveState = { runId: 'run_1', phase: 'responding', startedAt: Date.now(), agentNotes: {}, items: [
{ kind: 'tool', name: 'run_shell', callId: 'call_1', args: 'secret-command-argument', output: 'raw-command-output', state: 'running', startedAt: Date.now(), endedAt: 0 },
{ kind: 'final' }, { kind: 'text', index: 1, text: '已找到需要补充的两项要求' },
] }
snapshot.state!.live = live
const html = render()
expect(html).toContain('已找到需要补充的两项要求')
expect(html).not.toContain('secret-command-argument')
expect(html).not.toContain('raw-command-output')
expect(html).toContain('执行记录')
})
it('uses a real compact stage without exposing an internal tool name', () => {
snapshot.state!.live = { runId: 'run_1', phase: 'acting', startedAt: Date.now(), agentNotes: {}, items: [
{ kind: 'tool', name: 'run_shell', callId: 'call_1', args: '', output: '', state: 'running', startedAt: Date.now(), endedAt: 0 },
] }
const html = render()
expect(html).toContain('正在处理')
expect(html).not.toContain('run_shell')
})
it('exposes anchored required confirmation outside work history', () => {
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理资料' }]), role: 'user' }]
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
title: '新增一份项目草稿', actions: [{ id: 'confirm', label: '确认新增', style: 'primary' }] })]
const html = render()
expect(html).toContain('新增一份项目草稿')
expect(html).toContain('确认新增')
})
it('keeps a required confirmation accessible when its anchor is an assistant message', () => {
snapshot.state!.messages = [reply([{ type: 'text', text: '准备新增项目草稿' }])]
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
title: '新增一份项目草稿', actions: [{ id: 'confirm', label: '确认新增', style: 'primary' }] })]
expect(render()).toContain('确认新增')
})
it('does not reveal internal thinking when records are deliberately opened', () => {
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
parts: [{ type: 'thinking', process: true, text: 'private-internal-deliberation' }],
live: true, idKey: 'thinking-test', renderPart: () => null,
}))
expect(html).not.toContain('private-internal-deliberation')
})
it('blocks repeated HITL choices when the shared action result is unknown', () => {
snapshot.state!.cardActionStates = { 'hitl:hitl_1': 'unknown' }
snapshot.state!.activeHitl = [{ id: 'hitl_1', runId: 'run_1', kind: 'choice', question: '选择整理范围',
conversationId: 'cnv_1', allowFreeInput: false, createdAt: timestamp, resolved: false,
choices: [{ id: 'all', label: '全部材料', description: '整理三份材料', impact: '生成一份草稿' }],
}]
const html = render()
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>[\s\S]*?全部材料/)
expect(html).toContain('正在核实处理结果')
})
it('folds command details while retaining the confirmation and consequences', () => {
snapshot.state!.activeHitl = [hitl()]
const html = render()
expect(html).not.toContain('confirm-record')
expect(html).toContain('执行工具 run_shell?')
expect(html).toContain('按已确认的参数调用')
expect(html).toContain('会访问外部资源并返回结果')
expect(html).toContain('操作详情')
})
it('makes the original command detail readable after the user expands it', () => {
snapshot.state!.activeHitl = [hitl()]
setPersistedOpen('hitl-details:cnv_1:hitl_detail1', true)
const html = render()
expect(html).toMatch(/aria-expanded="true"[^>]*>[\s\S]*?操作详情/)
expect(html).toContain('confirm-record')
})
it('keeps a blocking restriction visible outside the operation detail fold', () => {
snapshot.state!.activeHitl = [hitl({ blocked: true, blockReason: '目标文件需要先完成评审,当前不能覆盖' })]
const html = render()
expect(html).toContain('目标文件需要先完成评审,当前不能覆盖')
expect(html).not.toContain('confirm-record')
})
it('folds the exact workflow script suffix supplied in the question', () => {
snapshot.state!.activeHitl = [hitl({ question: '运行这段脚本?\nprint("workflow-record-script")', detail: undefined })]
const html = render()
expect(html).toContain('运行这段脚本?')
expect(html).toContain('会访问外部资源并返回结果')
expect(html).not.toContain('workflow-record-script')
expect(html).toContain('操作详情')
})
it('retains the full workflow script and separate detail on deliberate expansion', () => {
snapshot.state!.activeHitl = [hitl({ question: '运行这段脚本?\nprint("workflow-record-script")' })]
setPersistedOpen('hitl-details:cnv_1:hitl_detail1', true)
const html = render()
expect(html).toContain('workflow-record-script')
expect(html).toContain('confirm-record')
expect(html).toMatch(/aria-expanded="true"[^>]*>[\s\S]*?操作详情/)
})
it('retains multiline business consequences in an ordinary question', () => {
snapshot.state!.activeHitl = [hitl({ question: '批准计划并继续执行?\n会覆盖旧草稿,原件保留', detail: undefined })]
expect(render()).toContain('会覆盖旧草稿,原件保留')
})
})
+7 -4
View File
@@ -175,10 +175,13 @@ describe('livePhaseLabel', () => {
})
describe('showProcessInline', () => {
it('stays expanded for the whole live run, including the responding phase', () => {
expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(true)
expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(true)
expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(true)
it('keeps live technical records folded under the default reading preference', () => {
expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(false)
expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(false)
expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(false)
})
it('honors the advanced preference to expose live records', () => {
expect(showProcessInline(live({ phase: 'acting' }), false)).toBe(true)
})
it('records thinking duration from the first delta to the last', () => {
let items: LiveItem[] = applyThinkingDelta([], '先', 1000)
+32
View File
@@ -0,0 +1,32 @@
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
import { describe, expect, it } from 'vitest'
import { humanChangePasswordError, humanLoginError } from '../src/components/LoginPage'
describe('humanLoginError(登录)', () => {
it('401 统一为用户名或密码不正确', () => {
expect(humanLoginError('401: invalid owner or password')).toBe('用户名或密码不正确')
})
it('429/412 各有文案,其余剥状态码前缀', () => {
expect(humanLoginError('429: too many failed attempts; retry later')).toBe('失败次数过多,请稍后再试')
expect(humanLoginError('412: auth disabled (embedded single-tenant mode)')).toBe('本服务未开启登录(嵌入式单租户档)')
expect(humanLoginError('400: bad')).toBe('bad')
})
})
describe('humanChangePasswordError(改密门)', () => {
it('当前密码错误不再伪装成登录失败', () => {
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
})
it('无会话(API key 凭证)给出可行动指引', () => {
expect(humanChangePasswordError('401: session login required (API keys do not own passwords)')).toBe('请先用账号密码登录再改密(API 密钥不能修改密码)')
})
it('会话过期与失效 key 各有文案', () => {
expect(humanChangePasswordError('401: session expired')).toBe('会话已过期,请重新登录')
expect(humanChangePasswordError('401: unauthorized: invalid or revoked API key')).toBe('本地残留的 API 密钥已失效,请退出登录后重试')
})
it('未知 401 回落通用文案,其余错误剥前缀透传', () => {
expect(humanChangePasswordError('401: whatever')).toBe('认证失败,请重新登录')
expect(humanChangePasswordError('400: password must be 8-256 characters')).toBe('password must be 8-256 characters')
})
})
+69 -8
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { splitTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../src/messageparts'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolOutput, liveToMessage, showProcessInline } from '../src/live'
import type { Part } from '../src/contracts'
function p(partial: Partial<Part> & { type: Part['type'] }): Part {
@@ -7,7 +8,68 @@ function p(partial: Partial<Part> & { type: Part['type'] }): Part {
}
describe('splitTimeline', () => {
it('keeps everything up to the last process part in the timeline', () => {
it('keeps public Markdown before, between and after tools in reading order', () => {
const parts: Part[] = [
p({ type: 'text', text: '我会先核对**两份可读材料**。', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'read' } }),
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok', summary: '原始读取结果' } }),
p({ type: 'text', text: '评审意见未能读取,完整结论尚未生成。' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'search' } }),
p({ type: 'text', text: '接下来确认日期冲突。' }),
]
const split = splitTimeline(parts)
expect(split.final.map(part => part.text)).toEqual([
'我会先核对**两份可读材料**。',
'评审意见未能读取,完整结论尚未生成。',
'接下来确认日期冲突。',
])
expect(split.timeline.map(part => part.type)).toEqual(['tool_call', 'tool_result', 'tool_call'])
})
it('keeps live scope limits visible while execution records are folded by default', () => {
let items = applyThinkingDelta([], '内部分析,不向用户展示', 0)
items = applyTextDelta(items, 0, '**范围限制:**目前仅整理两份材料,评审意见尚未读取。')
items = applyToolCall(items, 'run_python', 'c1', '{"script":"print(42)"}')
items = applyToolOutput(items, 'c1', '42\n')
const live = { runId: 'r1', phase: 'tool', items, agentNotes: {}, startedAt: 0 }
const message = liveToMessage(live)
expect(message.parts[1].process).toBe(true)
expect(showProcessInline(live, true)).toBe(false)
const split = splitTimeline(message.parts)
expect(split.final.map(part => part.text)).toEqual(['**范围限制:**目前仅整理两份材料,评审意见尚未读取。'])
expect(split.final.every(part => part.type === 'text')).toBe(true)
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'tool_call'])
})
it('keeps finalizing checks and internal thinking out of visible text without duplicating explanations', () => {
const parts: Part[] = [
p({ type: 'text', process: true, text: '评审意见尚未读取。' }),
p({ type: 'thinking', process: true, text: '内部推理' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'read' } }),
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok', summary: '诊断输出' } }),
p({ type: 'check', process: true, check: { passed: false, reason: '纸张不是 A4' } }),
p({ type: 'text', text: '已整理两份材料,请补充清晰的评审意见。' }),
]
for (const finished of [false, true]) {
const split = splitTimeline(parts, finished)
expect(split.final.map(part => part.text)).toEqual(['评审意见尚未读取。', '已整理两份材料,请补充清晰的评审意见。'])
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'tool_call', 'tool_result', 'check'])
expect(split.final.length + split.timeline.length).toBe(parts.length)
}
})
it('keeps errors and file references outside a technical record even before a later tool', () => {
const parts: Part[] = [
p({ type: 'error', process: true, error: { code: 'read.failed', message: '评审意见无法读取' } }),
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '已整理的材料.docx' } }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
p({ type: 'text', text: '已整理另外两份材料' }),
]
const split = splitTimeline(parts)
expect(split.timeline.map(x => x.type)).toEqual(['tool_call'])
expect(split.final.map(x => x.type)).toEqual(['error', 'artifact_ref', 'text'])
})
it('keeps only execution records folded when narration precedes the last process part', () => {
const parts: Part[] = [
p({ type: 'text', text: '先找资料', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
@@ -16,8 +78,8 @@ describe('splitTimeline', () => {
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
]
const split = splitTimeline(parts)
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_result'])
expect(split.final.map(x => x.type)).toEqual(['text', 'artifact_ref'])
expect(split.timeline.map(x => x.type)).toEqual(['tool_call', 'tool_result'])
expect(split.final.map(x => x.type)).toEqual(['text', 'text', 'artifact_ref'])
})
it('keeps a card sandwiched between process parts inside the timeline', () => {
@@ -32,7 +94,7 @@ describe('splitTimeline', () => {
expect(split.final.map(x => x.text)).toEqual(['最终'])
})
it('lifts the last narration when a finished run has no final text', () => {
it('keeps all narrations visible even when a finished run has no final text', () => {
const parts: Part[] = [
p({ type: 'text', text: '先写一版', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'run_python' } }),
@@ -40,10 +102,9 @@ describe('splitTimeline', () => {
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'run_python' } }),
]
const split = splitTimeline(parts, true)
expect(split.final.map(x => x.text)).toEqual(['纪要已经整理好'])
expect(split.final[0].process).toBeUndefined()
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_call'])
expect(splitTimeline(parts).final).toEqual([])
expect(split.final.map(x => x.text)).toEqual(['先写一版', '纪要已经整理好'])
expect(split.timeline.map(x => x.type)).toEqual(['tool_call', 'tool_call'])
expect(splitTimeline(parts).final).toEqual(split.final)
})
it('puts a message with no process parts entirely in the final section', () => {
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { classifyRdmError } from '../src/rdmErrors'
// 2026-10-12 增量 2(审计错误四分类):审计页不再把原始报错(400 回执 JSON)直出,
// 改为「能力缺失 / 登录已失效 / 查询失败」文案并保留原始回执供诊断;
// 「无可审计项目」由组件空态承担,不走本分类。
// 分类依据:api.ts json() 抛 `${status}: ${body}`;后端 unknown/disabled 命令
// 回 400 + "unknown or disabled command"(cmdbus.go Dispatch),总线未挂载回
// 503 "commands disabled"(server.go execCommand)。
describe('classifyRdmError', () => {
it('400 unknown or disabled command → 能力缺失', () => {
const raw = '400: {"receipt":{"state":"rejected","error":"unknown or disabled command: rdm.inspect.capabilities"}}'
const e = classifyRdmError(new Error(raw))
expect(e.kind).toBe('capability')
expect(e.message).toContain('当前服务未提供审计能力')
expect(e.raw).toContain('unknown or disabled command')
})
it('503 commands disabled → 能力缺失', () => {
expect(classifyRdmError('503: commands disabled').kind).toBe('capability')
})
it('401/403 → 登录已失效', () => {
expect(classifyRdmError('401: session expired').kind).toBe('auth')
expect(classifyRdmError(new Error('403: forbidden')).kind).toBe('auth')
})
it('其余错误 → 查询失败,保留原始串', () => {
const e = classifyRdmError('500: boom')
expect(e.kind).toBe('query')
expect(e.message).toContain('查询失败')
expect(e.raw).toBe('500: boom')
})
it('非 Error 输入可容忍', () => {
expect(classifyRdmError(undefined).kind).toBe('query')
expect(classifyRdmError({ weird: true }).kind).toBe('query')
})
})
+98
View File
@@ -0,0 +1,98 @@
// U6 页面接线契约测试(方案 r1 U6/KTD5):RDM 管理/项目页面的能力门控与命令
// 载荷构造是纯逻辑——先红后实现。门控语义与 rdmAuditReady 同源:命令清单不可用
//(未登录/请求失败/null)一律视为未就绪(fail-closed),普通基座不出现 RDM 页面。
import { describe, expect, it } from 'vitest'
import {
RDM_ROLES_COMMANDS, RDM_MEMBER_COMMANDS, RDM_DOC_REVIEW_COMMANDS, ROLE_ACTIONS,
rdmRolesReady, rdmMembersReady, rdmDocReviewReady,
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
memberAddPayload, memberRemovePayload, assignDocDutiesPayload,
docSubmitPayload, docApprovePayload, docRejectPayload, docFinalPayload, docStatusPayload,
RDM_PERM_MATRIX_COMMANDS, rdmPermMatrixReady, permCheckPayload, describeSource,
parseReviewers,
} from '../src/rdmManagement'
const cmd = (id: string, enabled = true) => ({ id, enabled })
const all = (ids: readonly string[]) => ids.map(id => cmd(id))
describe('RDM 页面能力门控', () => {
it('命令齐备且 enabled 才就绪', () => {
expect(rdmRolesReady(all(RDM_ROLES_COMMANDS))).toBe(true)
expect(rdmMembersReady(all(RDM_MEMBER_COMMANDS))).toBe(true)
expect(rdmDocReviewReady(all(RDM_DOC_REVIEW_COMMANDS))).toBe(true)
})
it('缺一命令即未就绪', () => {
const drop = (ids: readonly string[], skip: string) => ids.filter(i => i !== skip).map(id => cmd(id))
expect(rdmRolesReady(drop(RDM_ROLES_COMMANDS, 'rdm.role.list'))).toBe(false)
expect(rdmMembersReady(drop(RDM_MEMBER_COMMANDS, 'rdm.project.member.add'))).toBe(false)
expect(rdmDocReviewReady(drop(RDM_DOC_REVIEW_COMMANDS, 'rdm.review.doc.final'))).toBe(false)
})
it('disabled / null / 非数组 / 请求失败均未就绪(fail-closed)', () => {
expect(rdmRolesReady(RDM_ROLES_COMMANDS.map(id => cmd(id, false)))).toBe(false)
expect(rdmRolesReady(null)).toBe(false)
expect(rdmMembersReady(undefined)).toBe(false)
expect(rdmDocReviewReady('not-array' as unknown as never)).toBe(false)
})
})
describe('RDM 动作清单(12 项,与 perms.AllActions 对齐)', () => {
it('精确等于 RDM 动作枚举且不重复', () => {
expect([...ROLE_ACTIONS].sort()).toEqual([
'doc.publish', 'doc.read', 'doc.write', 'duty.assign', 'knowledge.publish',
'member.manage', 'project.archive', 'project.create', 'project.settings',
'review.participate', 'stage.advance', 'trace.edit',
])
expect(new Set(ROLE_ACTIONS).size).toBe(12)
})
})
describe('命令载荷构造(身份只经认证会话——载荷不含任何身份字段)', () => {
it('角色管理载荷', () => {
expect(roleCreatePayload('工程师')).toEqual({ name: '工程师' })
expect(roleActionPayload('role_ab', 'doc.write', false, true))
.toEqual({ roleId: 'role_ab', action: 'doc.write', default: false, cap: true })
expect(roleAssignPayload('bob', 'role_ab')).toEqual({ owner: 'bob', roleId: 'role_ab' })
expect(roleDisablePayload('role_ab', true)).toEqual({ roleId: 'role_ab', disabled: true })
})
it('项目成员载荷(role 缺省 member)', () => {
expect(memberAddPayload('p1', 'carol')).toEqual({ projectId: 'p1', member: 'carol', role: 'member' })
expect(memberAddPayload('p1', 'carol', 'owner')).toEqual({ projectId: 'p1', member: 'carol', role: 'owner' })
expect(memberRemovePayload('p1', 'carol')).toEqual({ projectId: 'p1', member: 'carol' })
})
it('文档职责与整文审签载荷', () => {
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave'))
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave' })
expect(docSubmitPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
expect(docApprovePayload('p1', 'd1', '通过')).toEqual({ projectId: 'p1', docId: 'd1', note: '通过' })
expect(docRejectPayload('p1', 'd1', '依据不足')).toEqual({ projectId: 'p1', docId: 'd1', reason: '依据不足' })
expect(docFinalPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
expect(docFinalPayload('p1', 'd1', 3)).toEqual({ projectId: 'p1', docId: 'd1', round: 3 })
expect(docStatusPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
})
it('审阅人 CSV 解析:去空白、去空项、保序去重', () => {
expect(parseReviewers('bob, carol,,bob')).toEqual(['bob', 'carol'])
expect(parseReviewers(' ')).toEqual([])
expect(parseReviewers('bob')).toEqual(['bob'])
})
})
describe('项目权限矩阵页(rdm.perm.check 消费)', () => {
it('命令面齐备才就绪(perm.check + 成员清单);缺一/disabled/清单不可用均未就绪', () => {
const all2 = RDM_PERM_MATRIX_COMMANDS.map(id => ({ id, enabled: true }))
expect(rdmPermMatrixReady(all2)).toBe(true)
expect(rdmPermMatrixReady(RDM_PERM_MATRIX_COMMANDS.slice(1).map(id => ({ id, enabled: true })))).toBe(false)
expect(rdmPermMatrixReady(RDM_PERM_MATRIX_COMMANDS.map(id => ({ id, enabled: false })))).toBe(false)
expect(rdmPermMatrixReady(null)).toBe(false)
})
it('矩阵查询载荷(身份只经认证会话)', () => {
expect(permCheckPayload('p1', 'bob')).toEqual({ projectId: 'p1', member: 'bob' })
})
it('来源标签:解释读面的人话映射;未知来源回退原文', () => {
expect(describeSource('admin')).toBe('系统管理员')
expect(describeSource('prof-role-default')).toContain('角色')
expect(describeSource('override')).toContain('覆盖')
expect(describeSource('relation-default')).toContain('关系')
expect(describeSource('non-member')).toContain('成员')
expect(describeSource('weird')).toBe('weird')
})
})
+39
View File
@@ -0,0 +1,39 @@
// RDM 角色权限面板拒绝态(2026-10-13 8788 dev 锁死复现):rdm.role.list 被拒后
// 表格停留「加载中…」且错误为原始 400 JSON——测纯函数两件事:占位行不再假装
// 加载中;域管理员权限拒绝映射为人话 + 恢复通道。UI 不重算权限(KTD5),文案
// 只描述拒绝事实,不判权限。
import { describe, expect, it } from 'vitest'
import { rolesListPlaceholder, describeRoleListError } from '../src/rdmManagement'
describe('rolesListPlaceholder', () => {
it('keeps 加载中 only while loading without error', () => {
expect(rolesListPlaceholder(null, '')).toBe('加载中…')
})
it('replaces the loading row with a failure row once the fetch rejected', () => {
expect(rolesListPlaceholder(null, 'Error: 400: {"receipt":…}')).toBe('角色清单未加载(见下方提示)')
})
it('returns the empty label for an empty list and nothing otherwise', () => {
expect(rolesListPlaceholder([], '')).toBe('暂无专业角色')
expect(rolesListPlaceholder([{ roleId: 'r1' }], '')).toBe('')
// 已有数据时即便残留 err 也不再渲染占位行(表格有内容即事实来源)
expect(rolesListPlaceholder([{ roleId: 'r1' }], 'stale')).toBe('')
})
})
describe('describeRoleListError', () => {
const denied = 'Error: 400: {"receipt":{"commandId":"rdm.role.list","state":"rejected",'
+ '"reason":"rejected: 权限拒绝:weichen 不是系统管理员(全局动作 project.settings)"}}'
it('maps the domain-admin denial to a readable message with the transfer channel', () => {
const got = describeRoleListError(denied)
expect(got).toContain('不是 RDM 域管理员')
expect(got).toContain('rdm.perm.transfer')
expect(got).not.toContain('"receipt"')
})
it('passes other errors through untouched', () => {
expect(describeRoleListError('Error: 500: boom')).toBe('Error: 500: boom')
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { settingInputAttrs } from '../src/components/SettingsModal'
// 2026-10-12 增量 3:配置输入按字段类型渲染——此前 SettingInput 默认分支无条件
// type="password",浏览器把整排设置项当凭据表单(「password 不在 form 中/缺少
// username」警告的来源之一),且普通文本被无谓遮蔽。新契约:仅秘密字段用密码框。
describe('settingInputAttrs', () => {
it('secret=true → 密码框 + new-password', () => {
expect(settingInputAttrs({ key: 'llm.apiKey', label: '密钥', type: 'string', secret: true } as any))
.toEqual({ type: 'password', autoComplete: 'new-password' })
})
it('type=secret → 密码框', () => {
expect(settingInputAttrs({ key: 'x', label: 'X', type: 'secret' } as any).type).toBe('password')
})
it('普通文本 → text + off(不遮蔽、不当凭据)', () => {
const a = settingInputAttrs({ key: 'llm.baseUrl', label: '地址', type: 'string' } as any)
expect(a).toEqual({ type: 'text', autoComplete: 'off' })
})
it('无类型标注的默认项 → text', () => {
expect(settingInputAttrs({ key: 'ui.language', label: '语言' } as any).type).toBe('text')
})
})
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import type { SurfaceSnapshot } from '../src/contracts'
import { mergeSurfacesByRevision, surfaceKey } from '../src/surfaceKey'
function snap(id: string, revision: number): SurfaceSnapshot {
return {
identity: {
surfaceId: id,
owner: 'o',
pluginId: 'p',
},
compat: { schemaVersion: 1, catalogVersion: '1' },
revision,
activationEpoch: 0,
spec: { title: id, nodes: [] },
}
}
describe('surfaceKey', () => {
it('reads nested identity and skips missing', () => {
expect(surfaceKey(snap('s1', 1))).toBe('s1')
expect(surfaceKey(undefined)).toBeNull()
expect(surfaceKey({} as SurfaceSnapshot)).toBeNull()
expect(surfaceKey({ identity: {} } as SurfaceSnapshot)).toBeNull()
})
})
describe('mergeSurfacesByRevision', () => {
it('keeps higher revision and drops dirty entries', () => {
const dirty = { revision: 99 } as SurfaceSnapshot
const merged = mergeSurfacesByRevision(
[snap('a', 1), dirty],
[snap('a', 3), snap('b', 1)],
)
expect(merged.map(s => [surfaceKey(s), s.revision])).toEqual([
['a', 3],
['b', 1],
])
})
})
+421
View File
@@ -0,0 +1,421 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { store } from '../src/store'
import type { ActivityCard, Conversation, Message, Run, StreamEvent, SurfaceSnapshot } from '../src/contracts'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((ok, fail) => { resolve = ok; reject = fail })
return { promise, resolve, reject }
}
function card(id = 'task', status: ActivityCard['status'] = 'active', conversationId = 'a'): ActivityCard {
return { id, status, conversationId, projectId: 'p', kind: 'progress', title: '整理材料', createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z' }
}
function detail(id: string, runs: Run[] = [], lastSeq = 0, resumeSeq = lastSeq) {
return { conversation: { id, projectId: 'p', title: id, createdAt: '', updatedAt: '' } as Conversation, messages: [] as Message[], runs, activeHitl: [], lastSeq, resumeSeq }
}
function event(seq: number, type: StreamEvent['type'], payload: unknown, runId?: string, conversationId = 'a'): StreamEvent {
return { seq, type, payload, runId, conversationId, ts: '2026-10-12T00:00:00Z', v: 1 }
}
class FakeEventSource {
onopen = null
onerror = null
onmessage = null
addEventListener() {}
close() {}
}
beforeEach(async () => {
vi.stubGlobal('EventSource', FakeEventSource)
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => detail(id))
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
vi.spyOn(api, 'listCards').mockResolvedValue([])
vi.spyOn(api, 'listTasks').mockResolvedValue([])
vi.spyOn(api, 'listArtifacts').mockResolvedValue([])
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
vi.spyOn(api, 'listProjectCards').mockResolvedValue([])
await store.openProject('p')
await store.openConversation('a')
await store.refreshCards('a')
})
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('work projection ordering and context', () => {
it('settles foreground completion and queued input when background events overtake its detail response', async () => {
vi.useFakeTimers()
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run]))
await store.openConversation('a')
store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在整理' }, 'front'))
store.upsertCard(card('background'))
store.enqueueOutbox('继续整理', ['material'], ['材料'])
const query = deferred<ReturnType<typeof detail>>()
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
const send = deferred<Awaited<ReturnType<typeof api.sendMessage>>>()
const submission = vi.spyOn(api, 'sendMessage').mockReturnValue(send.promise)
store.applyEvent(event(2, 'run.completed', {}, 'front'))
await vi.advanceTimersByTimeAsync(250)
store.applyEvent(event(3, 'agent.progress', { agentRun: 'background', note: '仍在核对' }, 'background'))
store.applyEvent(event(4, 'card.status', { cardId: 'background', status: 'done' }, 'background'))
vi.mocked(api.listCards).mockResolvedValue([card('background', 'done')])
query.resolve({ ...detail('a', [{ ...run, status: 'completed' }], 2), messages: [
{ id: 'reply', conversationId: 'a', runId: 'front', role: 'assistant', parts: [{ type: 'text', text: '整理完成' }], createdAt: '' },
] })
await vi.advanceTimersByTimeAsync(0)
expect(store.getState().runs.find(r => r.id === 'front')?.status).toBe('completed')
expect(store.getState().messages.map(m => m.id)).toContain('reply')
expect(store.getState().live).toBeNull()
expect(store.getState().lastSeq).toBe(4)
expect(store.getState().cards.find(c => c.id === 'background')?.status).toBe('done')
expect(store.getState().outbox).toEqual([])
expect(submission).toHaveBeenCalledWith('a', '继续整理', ['material'], expect.any(String))
send.resolve({ message: { id: 'queued', conversationId: 'a', role: 'user', parts: [], createdAt: '' } })
await vi.advanceTimersByTimeAsync(0)
})
it('waits for the newer terminal run snapshot before settling its live output', async () => {
vi.useFakeTimers()
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run]))
await store.openConversation('a')
store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在整理' }, 'front'))
const query = deferred<ReturnType<typeof detail>>()
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
const pending = store.reconcile()
store.applyEvent(event(2, 'run.failed', { code: 'failed', message: '未完成' }, 'front'))
query.resolve(detail('a', [{ ...run, status: 'completed' }], 1))
await pending
expect(store.getState().runs[0].status).toBe('running')
expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '正在整理' })
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [{ ...run, status: 'failed' }], 2))
await vi.advanceTimersByTimeAsync(250)
expect(store.getState().runs[0].status).toBe('failed')
expect(store.getState().live).toBeNull()
})
it('does not restore a resolved HITL from a detail snapshot overtaken by its resolution', async () => {
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run]))
await store.openConversation('a')
const query = deferred<Awaited<ReturnType<typeof api.conversationDetail>>>()
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
const pending = store.reconcile()
store.applyEvent(event(2, 'hitl.resolved', { cardId: 'ask' }, 'front'))
query.resolve({ ...detail('a', [{ ...run, status: 'completed' }], 1), activeHitl: [
{ id: 'ask', runId: 'front', conversationId: 'a', question: '继续?', kind: 'confirm', allowFreeInput: false, createdAt: '', resolved: false },
] })
await pending
expect(store.getState().activeHitl).toEqual([])
expect(store.getState().runs[0].status).toBe('completed')
expect(store.getState().lastSeq).toBe(2)
})
it('preserves a run and user message started after a detail query began', async () => {
const query = deferred<ReturnType<typeof detail>>()
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
const pending = store.reconcile()
vi.spyOn(api, 'sendMessage').mockResolvedValue({
message: { id: 'new-message', conversationId: 'a', role: 'user', parts: [], createdAt: '' },
run: { id: 'new-run', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' },
})
await store.sendMessage('继续', [])
query.resolve(detail('a'))
await pending
expect(store.getState().runs.map(r => r.id)).toEqual(['new-run'])
expect(store.getState().messages.map(m => m.id)).toEqual(['new-message'])
expect(store.getState().live?.runId).toBe('new-run')
})
it('drops a terminal reconciliation timer after leaving its conversation', async () => {
vi.useFakeTimers()
store.applyEvent(event(1, 'run.completed', {}, 'front'))
await store.openConversation('b')
const calls = vi.mocked(api.conversationDetail).mock.calls.length
await vi.advanceTimersByTimeAsync(250)
expect(api.conversationDetail).toHaveBeenCalledTimes(calls)
expect(store.getState().currentConvId).toBe('b')
})
it('does not regress a finished card on an older or duplicated event', () => {
store.applyEvent(event(10, 'card.created', { card: card() }))
store.applyEvent(event(12, 'card.status', { cardId: 'task', status: 'done' }))
store.applyEvent(event(11, 'card.status', { cardId: 'task', status: 'active' }))
store.applyEvent(event(12, 'card.status', { cardId: 'task', status: 'failed' }))
expect(store.getState().cards[0].status).toBe('done')
})
it('rejects late card events from a different conversation', async () => {
await store.openConversation('b')
store.applyEvent(event(10, 'card.created', { card: card('foreign') }))
expect(store.getState().cards).toEqual([])
})
it('does not let a card query finish in another conversation', async () => {
const query = deferred<ActivityCard[]>()
vi.mocked(api.listCards).mockImplementation(id => id === 'a' ? query.promise : Promise.resolve([]))
const pending = store.refreshCards('a')
await store.openConversation('b')
query.resolve([card('old')])
await pending
expect(store.getState().cards).toEqual([])
})
it('keeps card events newer than an in-flight card snapshot', async () => {
store.applyEvent(event(10, 'card.created', { card: card() }))
const query = deferred<ActivityCard[]>()
vi.mocked(api.listCards).mockReturnValueOnce(query.promise)
const pending = store.refreshCards('a')
store.applyEvent(event(11, 'card.status', { cardId: 'task', status: 'done' }))
store.applyEvent(event(12, 'card.created', { card: card('new-task') }))
query.resolve([card()])
await pending
expect(store.getState().cards.find(c => c.id === 'task')?.status).toBe('done')
expect(store.getState().cards.some(c => c.id === 'new-task')).toBe(true)
})
it('preserves a status event that arrives before its first card snapshot', async () => {
const query = deferred<ActivityCard[]>()
vi.mocked(api.listCards).mockReturnValueOnce(query.promise)
const pending = store.refreshCards('a')
store.applyEvent(event(1, 'card.status', { cardId: 'task', status: 'done' }))
query.resolve([card()])
await pending
expect(store.getState().cards[0].status).toBe('done')
})
it('does not let a delayed conversation snapshot replace the newly selected one', async () => {
const query = deferred<ReturnType<typeof detail>>()
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
const pending = store.reconcile()
await store.openConversation('b')
query.resolve({ ...detail('a'), messages: [{ id: 'old-message', conversationId: 'a', role: 'user', parts: [], createdAt: '' }] })
await pending
expect(store.getState().currentConvId).toBe('b')
expect(store.getState().messages).toEqual([])
})
it('restores foreground output through replay without replaying old card states', async () => {
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run], 10, 4))
vi.mocked(api.listCards).mockResolvedValue([card('task', 'done')])
await store.openConversation('a')
await store.refreshCards('a')
store.applyEvent(event(5, 'message.delta', { index: 0, text: '已经整理' }, 'front'))
store.applyEvent(event(6, 'card.status', { cardId: 'task', status: 'active' }))
expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '已经整理' })
expect(store.getState().cards[0].status).toBe('done')
})
it('does not duplicate replayed foreground text', async () => {
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run], 0))
await store.openConversation('a')
store.applyEvent(event(1, 'message.delta', { index: 0, text: '结论' }, 'front'))
store.applyEvent(event(1, 'message.delta', { index: 0, text: '结论' }, 'front'))
expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '结论' })
})
it('background notes do not create a foreground turn or block the composer', () => {
store.applyEvent(event(1, 'agent.progress', { agentRun: 'background', note: '正在读取' }, 'background'))
expect(store.getState().live).toBeNull()
})
it('new task pages do not replace an open document or reopen a hidden right pane', () => {
store.requestPreview({ id: 'doc', projectId: 'p', name: '需求.md', kind: 'markdown', size: 0, createdAt: '', updatedAt: '' })
store.setUI({ rightCollapsed: true })
const snap: SurfaceSnapshot = { identity: { surfaceId: 'page', pluginId: 'generative-ui', owner: 'o' }, revision: 1, activationEpoch: 0, compat: { schemaVersion: 1, catalogVersion: '1' }, spec: { title: '新成果', nodes: [] } }
store.applyEvent(event(1, 'surface.updated', snap, 'background'))
expect(store.getState().previewActive.a).toBe('doc')
expect(store.getState().ui.rightCollapsed).toBe(true)
expect(store.getState().surfaces[0].identity.surfaceId).toBe('page')
})
it('resets project event cursor when changing projects and drops delayed project lists', async () => {
await store.openProject('p')
store.applyProjectEvent(event(100, 'card.created', { card: { ...card('p-card'), conversationId: undefined } }))
const query = deferred<ActivityCard[]>()
vi.mocked(api.listProjectCards).mockReturnValueOnce(query.promise)
const pending = store.loadProjectCards()
await store.openProject('q')
store.applyProjectEvent(event(1, 'card.created', { card: { ...card('q-card'), projectId: 'q', conversationId: undefined } }))
query.resolve([{ ...card('old-p'), conversationId: undefined }])
await pending
expect(store.getState().projectCards.map(c => c.id)).toEqual(['q-card'])
})
})
describe('shared action feedback', () => {
it.each(['card', 'hitl'] as const)('clears its definite rejected %s action after A to B navigation', async kind => {
const confirm = { ...card(`reject-${kind}`, 'action_required'), kind: 'confirm' as const,
...(kind === 'hitl' ? { body: [{ type: 'kv' as const, kv: [['来源', `hitl:reject-${kind}`]] as [string, string][] }] } : {}) }
store.upsertCard(confirm)
const response = deferred<never>()
const request = kind === 'hitl' ? vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise) : vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
const pending = store.cardAction(confirm, 'confirm')
await store.openConversation('b')
response.reject(new Error('400: rejected'))
await pending
expect(store.getState().cardActionStates[confirm.id]).toBeUndefined()
expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBeUndefined()
expect(store.getState().cards).toEqual([])
expect(store.getState().messages).toEqual([])
vi.mocked(api.listCards).mockResolvedValue([confirm])
await store.openConversation('a')
await store.refreshCards('a')
await store.cardAction(confirm, 'confirm')
expect(request).toHaveBeenCalledTimes(2)
})
it.each([
['card', new TypeError('connection lost')], ['card', new Error('503: unavailable')],
['hitl', new TypeError('connection lost')], ['hitl', new Error('503: unavailable')],
] as const)('retains unknown feedback for an off-context %s request with %s', async (kind, failure) => {
const id = `unknown-${kind}-${failure instanceof TypeError ? 'network' : 'server'}`
const confirm = { ...card(id, 'action_required'), kind: 'confirm' as const,
...(kind === 'hitl' ? { body: [{ type: 'kv' as const, kv: [['来源', `hitl:${id}`]] as [string, string][] }] } : {}) }
store.upsertCard(confirm)
const response = deferred<never>()
const request = kind === 'hitl' ? vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise) : vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
const pending = store.cardAction(confirm, 'confirm')
await store.openConversation('b')
response.reject(failure)
await pending
expect(store.getState().cardActionStates[confirm.id]).toBe('unknown')
if (kind === 'hitl') expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBe('unknown')
vi.mocked(api.listCards).mockResolvedValue([confirm])
await store.openConversation('a')
await store.refreshCards('a')
await store.cardAction(confirm, 'confirm')
expect(request).toHaveBeenCalledTimes(1)
store.applyEvent(event(1, 'card.resolved', { cardId: confirm.id }))
expect(store.getState().cardActionStates[confirm.id]).toBeUndefined()
expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBeUndefined()
})
it('does not let an old rejected action clear feedback owned by a newer HITL projection', async () => {
const confirm = { ...card('replacement', 'action_required'), kind: 'confirm' as const }
store.upsertCard(confirm)
const old = deferred<never>()
vi.spyOn(api, 'cardAction').mockReturnValue(old.promise)
const first = store.cardAction(confirm, 'confirm')
store.applyEvent(event(1, 'card.resolved', { cardId: confirm.id }))
const projected = { ...confirm, body: [{ type: 'kv' as const, kv: [['来源', 'hitl:replacement']] as [string, string][] }] }
store.applyEvent(event(2, 'card.updated', { card: projected }))
const replacement = deferred<Awaited<ReturnType<typeof api.resolveHITL>>>()
vi.spyOn(api, 'resolveHITL').mockReturnValue(replacement.promise)
const second = store.cardAction(projected, 'confirm')
old.reject(new Error('400: rejected'))
await first
expect(store.getState().cardActionStates.replacement).toBe('submitting')
expect(store.getState().cardActionStates['hitl:replacement']).toBe('submitting')
vi.mocked(api.listCards).mockResolvedValue([{ ...projected, status: 'resolved' }])
replacement.resolve({ resolved: true, pivoted: false, pivotText: '' })
await second
expect(store.getState().cardActionStates.replacement).toBeUndefined()
})
it('does not append another conversation receipt to the currently open conversation', async () => {
const foreign = { ...card('other-confirm', 'action_required', 'b'), kind: 'confirm' as const }
store.applyProjectEvent(event(1, 'card.created', { card: foreign }))
vi.spyOn(api, 'cardAction').mockResolvedValue({ card: { ...foreign, status: 'resolved' }, receipt: {
id: 'foreign-receipt', conversationId: 'b', role: 'system', createdAt: '', parts: [{ type: 'text', text: 'other conversation outcome' }],
} })
await store.cardAction(foreign, 'confirm')
expect(store.getState().messages).toEqual([])
expect(store.getState().projectCards[0].status).toBe('resolved')
})
it('unlocks a completed read-only recheck even while confirmation remains pending', async () => {
const confirm = { ...card('recheck', 'action_required'), kind: 'confirm' as const }
store.upsertCard(confirm)
vi.spyOn(api, 'cardAction').mockResolvedValue({ card: confirm })
await store.cardAction(confirm, 'recheck')
expect(store.getState().cardActionStates.recheck).toBeUndefined()
})
it('does not overwrite a newer resolved event with the older action response', async () => {
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const }
store.upsertCard(confirm)
const response = deferred<{ card: ActivityCard }>()
vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
vi.mocked(api.listCards).mockResolvedValue([confirm])
const pending = store.cardAction(confirm, 'confirm')
store.applyEvent(event(1, 'card.resolved', { cardId: 'confirm' }))
response.resolve({ card: confirm })
await pending
expect(store.getState().cards[0].status).toBe('resolved')
})
it('shares one lock across a raw HITL and its activity-card projection', async () => {
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const, body: [{ type: 'kv', kv: [['来源', 'hitl:ask']] }] } satisfies ActivityCard
store.upsertCard(confirm)
const response = deferred<{ resolved: boolean; pivoted: boolean; pivotText: string }>()
const request = vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise)
const first = store.resolveHITL('ask', { choiceId: 'yes' })
const second = store.cardAction(confirm, 'yes')
expect(store.getState().cardActionStates.confirm).toBe('submitting')
expect(request).toHaveBeenCalledTimes(1)
vi.mocked(api.listCards).mockResolvedValue([{ ...confirm, status: 'resolved' }])
response.resolve({ resolved: true, pivoted: false, pivotText: '' })
await Promise.all([first, second])
expect(store.getState().cardActionStates.confirm).toBeUndefined()
})
it('uses the server cancellation outcome rather than labelling every resolved HITL executed', async () => {
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const, body: [{ type: 'kv', kv: [['来源', 'hitl:ask']] }] } satisfies ActivityCard
store.upsertCard(confirm)
vi.spyOn(api, 'resolveHITL').mockResolvedValue({ resolved: true, pivoted: false, pivotText: '' })
vi.mocked(api.listCards).mockResolvedValue([{ ...confirm, status: 'dismissed' }])
await store.resolveHITL('ask', { choiceId: 'no' })
expect(store.getState().cards[0].status).toBe('dismissed')
})
it('leaves a new conversation untouched when an older send returns', async () => {
const response = deferred<Awaited<ReturnType<typeof api.sendMessage>>>()
vi.spyOn(api, 'sendMessage').mockReturnValue(response.promise)
const pending = store.sendMessage('整理材料', [])
await store.openConversation('b')
response.resolve({ message: { id: 'old-message', conversationId: 'a', role: 'user', createdAt: '', parts: [{ type: 'text', text: '整理材料' }] }, run: { id: 'old-run', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' } })
await pending
expect(store.getState().messages).toEqual([])
expect(store.getState().live).toBeNull()
})
it('locks a card before awaiting so two views submit one effect', async () => {
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const, actions: [{ id: 'confirm', label: '生成草稿' }] }
const response = deferred<{ card: ActivityCard }>()
const calls = vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
store.upsertCard(confirm)
const first = store.cardAction(confirm, 'confirm')
const second = store.cardAction(confirm, 'confirm')
expect(store.getState().cardActionStates?.confirm).toBe('submitting')
expect(calls).toHaveBeenCalledTimes(1)
response.resolve({ card: { ...confirm, status: 'resolved' } })
await Promise.all([first, second])
expect(store.getState().cards[0].status).toBe('resolved')
expect(store.getState().cardActionStates?.confirm).toBeUndefined()
})
it('keeps an uncertain effect disabled until a server outcome is observed', async () => {
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const }
store.upsertCard(confirm)
vi.spyOn(api, 'cardAction').mockRejectedValue(new TypeError('connection lost'))
vi.mocked(api.listCards).mockResolvedValue([confirm])
await store.cardAction(confirm, 'confirm')
expect(store.getState().cardActionStates?.confirm).toBe('unknown')
store.applyEvent(event(1, 'card.resolved', { cardId: 'confirm' }))
expect(store.getState().cardActionStates?.confirm).toBeUndefined()
})
it('does not write a completed old action into a different conversation', async () => {
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const }
const response = deferred<{ card: ActivityCard }>()
vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
const pending = store.cardAction(confirm, 'confirm')
await store.openConversation('b')
response.resolve({ card: { ...confirm, status: 'resolved' } })
await pending
expect(store.getState().cards).toEqual([])
})
})
+135
View File
@@ -0,0 +1,135 @@
import { Children, createElement, isValidElement, type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { TopBar } from '../src/components/TopBar'
import { TaskSidebar } from '../src/components/TaskSidebar'
import { selectWorkbenchLayout, type WorkbenchLayoutOptions } from '../src/workbenchLayout'
import { store } from '../src/store'
vi.mock('../src/store', () => {
const state = {
projects: [{ id: 'prj_1', name: '材料项目' }],
tasks: [{ id: 'cnv_1', projectId: 'prj_1', title: '整理项目材料', updatedAt: '2026-10-12T10:00:00Z' }],
me: null, adminSections: [], artifacts: [], cards: [], projectCards: [], serverWorkspaces: [],
rdmAuditAvailable: false, currentConvId: 'cnv_1', currentProjectId: 'prj_1',
ui: { theme: 'light', showArchived: false, rightCollapsed: true, rightTab: 'work' },
}
return { useStore: (select: (value: typeof state) => unknown) => select(state), store: {
openInbox: vi.fn(), openFiles: vi.fn(), focusPending: vi.fn(), setUI: vi.fn(),
} }
})
const desktop: WorkbenchLayoutOptions = {
width: 1440, contextOpen: false, leftCollapsed: false, rightCollapsed: false,
leftWidth: 260, rightWidth: 360, previewWidth: 700, rightTab: 'work',
previewOpen: false, previewExpanded: false, filesView: 'list',
}
describe('selectWorkbenchLayout', () => {
it.each([320, 390, 736])('shows one conversation pane at %ipx and keeps saved desktop widths unchanged', width => {
const options = { ...desktop, width, leftWidth: 420, rightWidth: 560, rightCollapsed: true }
const layout = selectWorkbenchLayout(options)
expect(layout.compact).toBe(true)
expect([layout.showContext, layout.showConversation, layout.showRight]).toEqual([false, true, false])
expect(options.leftWidth).toBe(420)
expect(options.rightWidth).toBe(560)
})
it('opens context on a narrow screen and returns to the existing conversation', () => {
const options = { ...desktop, width: 390, rightCollapsed: true }
expect(selectWorkbenchLayout({ ...options, contextOpen: true }).pane).toBe('context')
expect(selectWorkbenchLayout({ ...options, contextOpen: false }).pane).toBe('conversation')
})
it('uses the same right pane for work and expanded file reading', () => {
const work = selectWorkbenchLayout({ ...desktop, width: 390 })
expect(work.pane).toBe('work')
expect([work.showContext, work.showConversation, work.showRight]).toEqual([false, false, true])
const files = selectWorkbenchLayout({ ...desktop, width: 390, rightTab: 'files', filesView: 'preview', previewOpen: true, previewExpanded: true })
expect(files.pane).toBe('files')
expect([files.showContext, files.showConversation, files.showRight]).toEqual([false, false, true])
expect(files.previewExpanded).toBe(true)
expect(files.rightWidth).toBe(382)
})
it('keeps the conversation visible after an expanded file pane is closed', () => {
const layout = selectWorkbenchLayout({ ...desktop, rightTab: 'files', filesView: 'preview', previewOpen: true, previewExpanded: true, rightCollapsed: true })
expect(layout.showConversation).toBe(true)
expect(layout.showRight).toBe(false)
expect(layout.previewExpanded).toBe(false)
})
it.each([1024, 1440])('fits stored oversized work panes inside %ipx while reserving conversation space', width => {
const layout = selectWorkbenchLayout({ ...desktop, width, leftWidth: 4000, rightWidth: 4000 })
expect(layout.compact).toBe(false)
expect(layout.leftWidth).toBeGreaterThanOrEqual(220)
expect(layout.leftWidth).toBeLessThanOrEqual(420)
expect(layout.rightWidth).toBeGreaterThanOrEqual(280)
expect(layout.rightWidth).toBeLessThanOrEqual(560)
expect(layout.leftWidth + layout.rightWidth + 360 + 32).toBeLessThanOrEqual(width)
})
it('fits wide file reading at 1024px without discarding the conversation', () => {
const layout = selectWorkbenchLayout({ ...desktop, width: 1024, leftWidth: 420, previewWidth: 1100, rightTab: 'files', filesView: 'preview', previewOpen: true })
expect(layout.showConversation).toBe(true)
expect(layout.rightWidth).toBeGreaterThanOrEqual(420)
expect(layout.leftWidth + layout.rightWidth + 320 + 32).toBeLessThanOrEqual(1024)
})
it('enlarges desktop reading inside the existing right pane and rejects invalid saved widths', () => {
const layout = selectWorkbenchLayout({ ...desktop, leftWidth: NaN, previewWidth: Infinity, rightTab: 'files', filesView: 'preview', previewOpen: true, previewExpanded: true })
expect(layout.showConversation).toBe(false)
expect(layout.showRight).toBe(true)
expect(layout.previewExpanded).toBe(true)
expect(Number.isFinite(layout.leftWidth + layout.rightWidth)).toBe(true)
expect(layout.leftWidth + layout.rightWidth + 20).toBeLessThanOrEqual(1440)
})
})
describe('workbench navigation', () => {
function action(node: ReactNode, label: string): (() => void) | undefined {
for (const child of Children.toArray(node)) {
if (!isValidElement<{ children?: ReactNode; 'aria-label'?: string; onClick?: () => void }>(child)) continue
if (child.props['aria-label'] === label) return child.props.onClick
const found = action(child.props.children, label)
if (found) return found
}
}
it('keeps work and file entrances available when the auxiliary pane is closed', () => {
const html = renderToStaticMarkup(createElement(TopBar))
expect(html).toContain('aria-label="打开工作"')
expect(html).toContain('aria-label="打开文件"')
})
it('offers a clear return to the conversation while reading a file on a narrow screen', () => {
const html = renderToStaticMarkup(createElement<NonNullable<Parameters<typeof TopBar>[0]>>(TopBar, {
compact: true, activePane: 'files', onConversation: () => undefined,
}))
expect(html).toContain('aria-label="返回对话"')
for (const name of ['打开上下文', '打开工作', '打开文件']) expect(html).toContain(`aria-label="${name}"`)
})
it('opens ordinary work with all results reachable instead of only pending confirmations', () => {
action(TopBar(), '打开工作')?.()
expect(store.openInbox).toHaveBeenCalledOnce()
expect(store.setUI).toHaveBeenCalledWith({ inboxFilter: 'all', previewExpanded: false })
})
it('connects both the return action and conversation navigation to the same return path', () => {
const onConversation = vi.fn()
const topbar = TopBar({ compact: true, activePane: 'files', onConversation })
action(topbar, '返回对话')?.()
action(topbar, '打开对话')?.()
expect(onConversation).toHaveBeenCalledTimes(2)
})
it('names conversation creation accurately and exposes conversation selection to the keyboard', () => {
const html = renderToStaticMarkup(createElement(TaskSidebar))
expect(html).toContain('新建对话')
expect(html).not.toContain('新建任务')
expect(html).toMatch(/<button[^>]*class="tsb-task-open"/)
expect(html).toContain('更多对话操作')
expect(html.match(/材料项目/g)).toHaveLength(1)
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { hasActiveProgressCard } from '../src/components/Workbench'
import type { ActivityCard } from '@ui'
function card(over: Partial<ActivityCard>): ActivityCard {
return {
id: 'card_1', projectId: 'prj_1', conversationId: 'cnv_1',
kind: 'progress', title: '材料整理', status: 'active', tone: 'info',
createdAt: '2026-10-12T10:00:00Z', updatedAt: '2026-10-12T10:00:00Z',
...over,
}
}
describe('hasActiveProgressCard', () => {
it('active/action_required progress 卡视为占用当前任务位', () => {
expect(hasActiveProgressCard([card({ status: 'active' })])).toBe(true)
expect(hasActiveProgressCard([card({ status: 'action_required' })])).toBe(true)
})
it('完成/失败 progress 或非 progress 不隐藏 CurrentTaskCard', () => {
expect(hasActiveProgressCard([card({ status: 'done', tone: 'ok' })])).toBe(false)
expect(hasActiveProgressCard([card({ status: 'failed', tone: 'danger' })])).toBe(false)
expect(hasActiveProgressCard([card({ kind: 'confirm', status: 'action_required' })])).toBe(false)
expect(hasActiveProgressCard([])).toBe(false)
})
})
+57 -8
View File
@@ -1,26 +1,75 @@
import { describe, expect, it } from 'vitest'
import { buildWorkspaceEntries } from '../src/workspaces'
import { buildWorkspaceEntries, rdmAuditReady, RDM_AUDIT_COMMANDS } from '../src/workspaces'
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口,体系工具只补「代理」。
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置「代理」。
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
// 没有任何 rdm.inspect.* 命令,入口展示后进入即 400。新契约:仅当命令面探测
// 通过(opts.rdmAudit)才纳入 rdm-audit;代理仍无条件以 ⌘1 补位。
describe('buildWorkspaceEntries', () => {
it('没有领域插件时,代理补在 ⌘1', () => {
it('没有领域插件且无 RDM 能力:只有代理占 ⌘1', () => {
const entries = buildWorkspaceEntries([])
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理'])
})
it('声明了 shortcut 的工作区占住该位,内置工具让位', () => {
it('没有领域插件且 RDM 能力通过:代理 ⌘1、追溯审计 ⌘2', () => {
const entries = buildWorkspaceEntries([], { rdmAudit: true })
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理', '⌘2 追溯审计'])
})
it('声明了 shortcut 的工作区占住该位,内置工具让位(能力开)', () => {
const entries = buildWorkspaceEntries([
{ id: 'trace', title: '追溯网', shortcut: '1' },
], { rdmAudit: true })
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理', '追溯审计'])
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3'])
expect(entries[0].key).toBe('ws:trace')
})
it('无 RDM 能力:让位后只有代理,不出现追溯审计', () => {
const entries = buildWorkspaceEntries([
{ id: 'trace', title: '追溯网', shortcut: '1' },
])
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理'])
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2'])
expect(entries[0].key).toBe('ws:trace')
})
it('未编号的工作区排在内置工具后面', () => {
const entries = buildWorkspaceEntries([
{ id: 'mem', title: '记忆库' },
])
expect(entries.map(e => e.title)).toEqual(['代理', '记忆库'])
], { rdmAudit: true })
expect(entries.map(e => e.title)).toEqual(['代理', '追溯审计', '记忆库'])
})
it('九位占满时低序内置保留,溢出项截断', () => {
const server = Array.from({ length: 8 }, (_, i) => ({ id: `w${i}`, title: `w${i}`, shortcut: String(i + 1) }))
const entries = buildWorkspaceEntries(server, { rdmAudit: true })
// ⌘1–8 被声明占住;代理取 ⌘9;追溯审计溢出截断(slice 0,9)。
expect(entries.filter(e => e.key.startsWith('ws:'))).toHaveLength(8)
expect(entries.map(e => e.title)).toContain('代理')
expect(entries.map(e => e.title)).not.toContain('追溯审计')
})
})
// 能力探测依据后端 GET /api/commands(server.go listCommands:{ id, title, …, enabled })。
// 审计工作区消费七个 rdm.inspect.* 命令;任一缺失或 enabled=false 都视为能力未就绪。
describe('rdmAuditReady', () => {
const all = RDM_AUDIT_COMMANDS.map(id => ({ id, enabled: true }))
it('七命令齐备且启用 → true', () => {
expect(rdmAuditReady(all)).toBe(true)
})
it('普通基座空清单 / 缺任一命令 → false', () => {
expect(rdmAuditReady([])).toBe(false)
expect(rdmAuditReady(all.filter(c => c.id !== 'rdm.inspect.history'))).toBe(false)
})
it('存在但 enabled=false → false', () => {
const disabled = all.map(c => (c.id === 'rdm.inspect.capabilities' ? { ...c, enabled: false } : c))
expect(rdmAuditReady(disabled)).toBe(false)
})
it('清单不可用(未登录/请求失败)→ false,不展示入口', () => {
expect(rdmAuditReady(null)).toBe(false)
expect(rdmAuditReady(undefined)).toBe(false)
})
})
+8
View File
@@ -18,5 +18,13 @@ export default defineConfig({
'/api': { target: 'http://127.0.0.1:8787', changeOrigin: false },
},
},
// E2E(U4 运行验收):preview 服务 dist 并把 /api 代理到被测后端(e2e.sh 注入
// PLAYWRIGHT_API_TARGET;缺省与 dev 代理同值,本地 npm run preview 也保持可用)。
preview: {
port: Number(process.env.PLAYWRIGHT_PREVIEW_PORT || 4173),
proxy: {
'/api': { target: process.env.PLAYWRIGHT_API_TARGET || 'http://127.0.0.1:8787', changeOrigin: false },
},
},
test: { environment: 'node' },
})