Compare commits

...
Author SHA1 Message Date
weichen 278b54cf9d Add render/fetch lifecycle logs to cockpit (temporary diagnostics)
Why: scenario 1 hangs at 加载中 with the fetch resolved but the DOM stuck;
lifecycle logs (instance id, effect start, resolve, setProjects, render
state) will locate where the update is lost.

Evidence: tsc green (SELF).

Impact: debug-only logs; removed after diagnosis.
2026-10-17 01:57:02 +00:00
weichen 49ba8b1735 Merge pull request 'Stabilize cockpit e2e login: settle identity refresh before entry click' (#103) from feature/document-visualization-cockpit-i4 into main 2026-10-16 15:39:52 +00:00
weichen b594d2748e Stabilize cockpit e2e login: settle identity refresh before entry click
Why: after login the store's identity epoch and the capability probe each
settle once more; the app closes open workspaces on identity change, so
clicking the sidebar entry too early raced the overlay close (intermittent
加载中 hang, caught across I4 e2e runs).

Evidence: instrumented probe (rows=2 parsed then overlay closed on identity
bump); settled-login rerun follows on the Dev I4 card.

Impact: spec stability only.

Refs: rollout I4
2026-10-16 15:39:11 +00:00
weichen afd661c932 Merge pull request 'Unwrap projects payload in cockpit project loader' (#102) from fix/cockpit-project-list-shape into main 2026-10-16 15:32:02 +00:00
weichen c9ef3c0f0a Unwrap projects payload in cockpit project loader
Why: rdm.project.list returns { projects: [...] }; the cockpit component
assumed a bare array and always rendered 暂无可见研发项目, caught by I4 e2e
scenario 1.

Evidence: tsc/vitest/build green (SELF); browser e2e rerun recorded on the
Dev I4 card.

Impact: cockpit entry only.

Refs: rollout I4
2026-10-16 15:31:52 +00:00
weichen 72f1a259e1 Merge pull request 'Wire cockpit flag into sidebar workspace entries' (#101) from fix/cockpit-sidebar-entry into main 2026-10-16 15:28:37 +00:00
weichen 804170ec66 Wire cockpit flag into sidebar workspace entries
Why: TaskSidebar builds its own workspace entry list without the cockpit
flag, so the 文档驾驶舱 entry never appeared in the sidebar even when the
command probe passed (caught by I4 e2e scenario 1).

Evidence: tsc/vitest/vite build green (SELF); cockpit e2e suite re-run via
Dev scripts/e2e.sh follows on the Dev I4 card.

Impact: sidebar entry visibility only; no behavior change elsewhere.

Refs: rollout I4
2026-10-16 15:27:16 +00:00
weichen 7759d8077f Merge pull request 'Add cockpit seven-scenario Playwright suite (I4)' (#100) from feature/document-visualization-cockpit-i4 into main 2026-10-16 14:33:21 +00:00
weichen 8d43b1de12 Add cockpit seven-scenario Playwright suite (I4)
Why: implement the reviewed I4 browser acceptance - seven scenarios over the
cockpit: first-entry stats/tree/panorama, tree-section reader positioning on
the shared selection, four evidence tabs with counts and honest empty/
restricted states, pending-probe adoption via in-session API then refresh
(snapshot and version block advance together, DV-R05), route-abort outage
keeping the previous snapshot with a retry entry (DV-R08), non-member
account sees no projects (DV-R06 negative), and 390px/18px/keyboard/reduced-
motion usability (DV-R07).

Evidence: tsc green; suite driven by Dev scripts/e2e.sh (seed: cockpit-e2e
structure YAML via the structure-content loader, pending probe proposal,
non-member account); GATE is the Dev-side e2e.sh run recorded on the Dev I4
card.

Impact: e2e spec only; no product source change.

Refs: rollout I4
2026-10-16 14:32:58 +00:00
weichen d66bd7941c Merge pull request 'Raise knip baseline to 51 for cockpit I3 surface' (#99) from fix/cockpit-i3-knip into main 2026-10-16 12:52:02 +00:00
weichen a022257b40 Raise knip baseline to 51 for cockpit I3 surface
Why: I3 adds the reader/evidence-panel module whose contract types
(documentCockpit.ts) are consumed by tests and the I4 combination work; the
repo's knip ratchet exists for exactly this deliberate, documented raise.
Code paths themselves reduced first (attempted de-export churned unrelated
surface, reverted in favor of the baseline bump).

Evidence: knip 51 <= 51 via scripts/check-knip.mjs; vitest 324/324; tsc and
vite build green.

Impact: baseline file only.

Refs: rollout I3
2026-10-16 12:51:53 +00:00
weichen 3f6ced099c Merge pull request 'Add cockpit reader view and evidence panel (I3)' (#98) from feature/document-visualization-cockpit-i3 into main 2026-10-16 12:21:48 +00:00
weichen 86770e35fb Add cockpit reader view and evidence panel (I3)
Why: implement the reviewed I3 increment - continuous body reading with
per-section status lines, version summary with collapsed line-diff (backend
versions projection from RDM PR #420), right evidence panel with
sources/history/impacts/conflicts tabs, panorama-reader positioning on the
shared selection, per user+project view preferences (panorama/reader,
density, panel tab), strict-seq history merging and honest restricted states.

Evidence: 10 new pure-logic tests (prefs validation, version summary and
diff marks, strict history merge drops, reader assembly three-state); vitest
324/324 green; tsc and vite build green; tokens-only styles.

Impact: cockpit-only; no fourth column, no standalone graph (relations stay
in the right panel); reader entry is explicit (panorama button / view toggle).

Refs: rollout I3
2026-10-16 12:21:24 +00:00
weichen 65ab7a8a44 Merge pull request 'Guard the admin create path with a browser e2e scenario' (#97) from fix/admin-create-e2e-guard into main 2026-10-16 11:22:07 +00:00
weichen 119fff96e8 Guard the admin create path with a browser e2e scenario
Why: the 400 (unknown field name) outage shipped because no automated
check drove the UsersPanel create path against the strict backend
contract; this scenario pins the request body, the pending notice, the
table row state and the CLI handoff hint into the composition suite.
Evidence: passing run against the deployed shared dev (SELF, task card).
Impact: e2e/ only; no production code.
Refs: backend 8dfc223, frontend #94
2026-10-16 10:47:41 +00:00
weichen 96ec03d461 Merge pull request 'Add document cockpit workspace consuming rdm.cockpit.* (I2)' (#96) from feature/document-visualization-cockpit-i2 into main 2026-10-16 09:04:20 +00:00
weichen 17996cf4dd Add document cockpit workspace consuming rdm.cockpit.* (I2)
Why: implement the reviewed I2 increment - statistics band, structure tree
with lazy section pages, panorama center (project overview + node summary),
shared selection/filter, snapshot-consistency banner and honest states, per
the frozen contract (Dev docs/superpowers 2026-10-16, rollout section 7).

Evidence: vitest 314/314 green (14 new pure-logic tests covering capability
gate, true-zero vs unavailable stats, filter toggle, snapshot equality and
update notice, context key, selection survival, honest badges); tsc -b and
vite production build green.

Impact: new workspace entry (rdm-cockpit, gated on the seven cockpit
commands); no changes to existing surfaces; reader view and right panel
land in I3.

Refs: rollout I2
2026-10-16 09:03:48 +00:00
weichen dbb5f0462f Merge pull request 'Close the account chain UI: forced-change current password and recovery proposals' (#95) from fix/account-chain-ui into main 2026-10-16 08:03:14 +00:00
weichen 125261410c Merge remote-tracking branch 'origin/main' into fix/account-chain-ui 2026-10-16 08:03:01 +00:00
weichen e895a4f6a8 Close the account chain UI against the current backend contract
Why: the forced password gate still submitted an empty current password
after the backend began requiring it (current_required), locking every
account seeded with an initial password out of the workbench, and the
admin user panel had no client for the owner-confirmed recovery
proposal contract that replaced one-time key issuing (HTTP 410).
Evidence: npm test 300 passed; tsc -b && vite build ok; knip issues
54 <= baseline 55 (SELF).
Impact: mode-aware ChangePasswordGate with current-password input,
admin recovery proposal panel, owner-side account settings section,
recovery proposal client methods; no backend changes.
Refs: backend 8dfc223, b9c0a34; frontend #94
2026-10-16 07:49:25 +00:00
weichen 46e99fedbb Merge pull request 'Align admin user creation with the pending-account contract' (#94) from fix/admin-user-create-contract into main 2026-10-16 07:48:49 +00:00
weichen bcb81719ce Drop the login-key issue client and fix hint punctuation
Why: independent review flagged the issue client still targeting the 410
endpoint and a hint sentence ending with a period.
Evidence: npm run build + npm test re-run on the fix (SELF).
Impact: api client surface only.
Refs: review brief /tmp/review-brief-admin-user-contract.md
2026-10-16 07:15:43 +00:00
weichen d8e610aebe Align admin user creation with the pending-account contract
Why: backend 8dfc223 replaced POST /api/admin/users with strict
{owner, profile} decoding and returned 410 for login-key issuing, so the
UI create form failed with 400 unknown field name. Creation now sends
{owner, profile:{name}} only; password input, admin checkbox and the
login-key issue entry are removed; notice points to the local CLI
initialize-account flow. passwords.ts lost its only consumer.
Evidence: npm run build + npm test (34 files/293 passed) + knip identical
to baseline on the task worktree (SELF).
Impact: UsersPanel and api client only; no style/token changes.
Refs: backend 8dfc223, b9c0a34
2026-10-16 06:41:27 +00:00
weichen 3bbb918795 Merge pull request 'Gate project override settings to owners and admins' (#93) from fix/duty-settings-gating into main 2026-10-16 05:10:17 +00:00
weichen 10635387e0 Gate project override settings to owners and admins
Why: S9 acceptance finding 3 — the project settings modal exposed the
host override settings (including the model/key group, masked) to plain
project members; the duties data plane was already permission-enforced,
but the host settings surface had no gate.

Evidence: vitest 34 files / 293 tests pass with the change; tsc reports
no errors in the modified file. Ruling per the acceptance plan P1-B:
the modal entry stays (granted duty managers need the RDM tabs), while
the host override settings group renders only for the project owner or
a system admin; RDM tabs keep their own fail-closed gating.

Impact: ProjectSettingsModal project tab renders an explanatory empty
state for non-privileged viewers; members/duties/matrix tabs unchanged.
Refs: S9 acceptance report P1-B
2026-10-16 05:09:58 +00:00
weichen a81e94a4f2 Merge pull request 'Show the layered Python environment in admin'
Reviewed against the layered environment plan. Required CI on the current head passed where this repository runs CI.
2026-10-15 14:24:46 +00:00
weichen 781cde6667 Merge remote-tracking branch 'origin/main' into feature/layered-python-env 2026-10-15 14:01:27 +00:00
weichenandCursor e33d17e1a6 Show the layered Python environment in the admin zone.
Why: Approving an extra package, rebuilding the base layer, reclaiming unused layers, and uploading a wheel need an admin surface that uses the existing admin controls.
Evidence: SELF tsc of the new panel reported no errors in frontend sources. UI package type errors come from missing node_modules in this worktree, not from this panel.
Impact: Administrators get a Python 环境 section. Other admin sections are unchanged.
Refs: .task/feature-layered-python-env.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 13:52:55 +00:00
weichen 5c0e9f2711 Merge pull request 'Show document duties in three shared views' (#90) from feature/rdm-duty-matrix into main 2026-10-15 09:15:09 +00:00
weichen 04269eed25 Merge remote-tracking branch 'origin/main' into feature/rdm-duty-matrix 2026-10-15 09:14:32 +00:00
weichenandCursor 985fc0f931 Adopt the server duty draft when a save result is unknown
Why: a lost write response was keeping the old duty version, so the next save replayed it, and a busy save could stay locked if another request advanced the sequence.
Evidence: SELF vitest duty-draft 6 passed, tsc passed, knip 43, Playwright duty flows 3 passed.
Impact: an unknown save reloads server roles, confirmation resets, and busy clears even if a later read is discarded.
Refs: docs/proposals/2026-10-15-rdm-duty-matrix-rework.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 09:06:24 +00:00
weichen 4835c45b8d Merge pull request 'Stabilize the nonblocking choice browser baseline' (#91) from fix/workbench-choice-browser-wait into main 2026-10-15 08:38:20 +00:00
weichenandCursor 6093beb27e Merge origin/main into the duty matrix branch
Why: project settings must keep the shared duty draft and must not regain template document creation from main.
Evidence: SELF vitest 292 passed, tsc --noEmit passed, knip unused exports 43.
Impact: the duties tab stays assignment-only while conversation ordering from main is included.
Refs: docs/proposals/2026-10-15-rdm-duty-matrix-rework.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 08:37:53 +00:00
weichen c2ff4fcc8e Stabilize the nonblocking choice browser baseline
Why: Wait for the unrelated foreground reply before measuring duplicate replies.

Evidence: All 24 scenarios passed against deployed 8788; independent replay passed the targeted scenario.

Impact: Test synchronization only; the duplicate-reply assertion is unchanged.

Refs: Agent-System-Frontend #89; Agent-System-Dev #565
2026-10-15 08:37:23 +00:00
weichen 03db4b81be Merge pull request 'Keep chat ordered while background results update independently' (#89) from feature/workbench-flow-completion into main 2026-10-15 08:18:46 +00:00
weichen 3555f815af Keep late background progress out of a newer conversation turn
Why: Completion or progress from an earlier task could appear in the process of an unrelated newer answer.

Evidence: The new ownership regression fails before the guard and passes afterward; the frontend suite, TypeScript and unchanged export gate pass.

Impact: Project child progress only into its originating live turn while still reconciling background results.

Refs: feature/workbench-flow-completion chronological display requirement
2026-10-15 07:51:41 +00:00
weichen 11f66aee9d Verify complete saved records beyond one event page
Why: Full execution history must retain outputs after the 500-event API page boundary.

Evidence: Ordered 502-event replay and nonadvancing-cursor rejection tests pass.

Impact: Add regression coverage only; production source is unchanged.

Refs: feature/workbench-flow-completion review testing gap
2026-10-15 07:45:31 +00:00
weichenandCursor 06249dd83f Keep the duty draft exports inside the knip baseline
Why: the settings page only needs the draft operations the views call. Extra aliases and unused helpers pushed the unused-export count over the baseline.
Evidence: SELF node scripts/check-knip.mjs reported 43 <= 43, and duty-draft vitest passed.
Impact: chapter-review command aliases are gone from the settings gate. The draft behavior is unchanged.
Refs: modules/frontend/knip-baseline.json
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 07:34:49 +00:00
weichen 83c22681f9 Preserve rejected message drafts and keep later chat unblocked
Why: A rejected result-reference request could discard the user text or uploads after the composer cleared.

Evidence: 286 frontend tests, TypeScript and the unchanged unused-export gate pass; mixed-input browser regression preserves the draft.

Impact: Validate incompatible inputs before clearing, retain failed pending input with its request identity, and require explicit retry while other messages can continue.

Refs: feature/workbench-flow-completion review frontend-races finding
2026-10-15 07:24:13 +00:00
weichenandCursor 44205e1262 Cover the shared duty draft in the browser
Why: the three views, the blocked empty-reviewer save, and the narrow-screen matrix need a browser check that does not depend on a live account.
Evidence: SELF playwright e2e/rdm-duties.playwright.ts, 3 passed against the local Vite server.
Impact: the duties end-to-end file now drives assignment, stale responses, and the narrow-screen guidance.
Refs: feature/rdm-duty-matrix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 07:23:02 +00:00
weichenandCursor 1b7ba779f1 Show document duties in three shared views
Why: the settings page was mixing duty assignment with whole-document review and chapter objects. The three views need one draft and one save check.
Evidence: SELF vitest run of the frontend suite, 278 tests passed, including duty-draft and design-guard.
Impact: the duties tab assigns existing documents only. Submit, approve, reject, and final stay out of this page. Commands that are not ready keep the tab hidden.
Refs: feature/rdm-duty-matrix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 07:20:48 +00:00
weichen 0cd1f9954a Keep the replay progress helper private
Why: The frontend unused-export gate rejected a helper used only within its module.

Evidence: The unchanged knip baseline and TypeScript checks pass.

Impact: Remove an unnecessary export without changing replay behavior.

Refs: feature/workbench-flow-completion
2026-10-15 07:15:05 +00:00
weichen 92c390a08a Synchronize guarded work dispatch state from Core
Why: Keep persisted task decisions aligned across Go and TypeScript.

Evidence: make contracts generated both consumers from Core 1a5ef6b.

Impact: Include the reserved run identity and typed dispatching state.

Refs: feature/workbench-flow-completion
2026-10-15 07:06:10 +00:00
weichen 06fa1a40a6 Keep conversations ordered while background work updates independently
Why: Repeated progress and coupled scrolling obscured chat, and task choices lacked a reliable independent interaction path.

Evidence: 285 frontend unit tests and 23 browser scenarios pass locally before the shared event-loader extraction; final combination checks follow in Dev.

Impact: Coalesce cumulative progress, retain execution records, show timestamps and quiet notifications, support versioned work actions and explicit result references without automatic chat backfill.

Refs: feature/workbench-flow-completion
2026-10-15 07:05:57 +00:00
weichen 9242cee89d Synchronize the work card contract
Why: Consume the Core work result and optional decision snapshot.

Evidence: Generated with make contracts from Core 00f1826.

Impact: Add optional types without changing existing cards.

Refs: feature/workbench-flow-completion
2026-10-15 06:46:40 +00:00
weichen 8a9d129a94 Merge pull request 'Add template-driven document creation to the RDM duties tab' (#88) from feature/frontend-u4-docs-instantiate into main 2026-10-15 03:42:29 +00:00
weichen f7d9197011 Add template-driven document creation to the RDM duties tab
Why: the duties tab only listed existing documents — rdm.docs.instantiate
had no UI entry, so a fresh project showed an empty duties tab with no
setup path (owner trial feedback 2026-10-15).

Evidence: new docs-instantiate-params tests green; full vitest run shows
only the 12 pre-existing conversation-work failures also present at clean
HEAD; design-guard green.

Impact: duties tab gains a template picker + title input wired to
rdm.docs.instantiate (backend command rdm.docs.templates lists published
identities); instantiation refreshes the catalog and selects the new doc.

Refs: Agent-System-RDM docs/records/dhf/u4-header-footer-slots.md
2026-10-15 03:42:05 +00:00
weichen 5ab81432d1 Merge pull request 'Redesign the workbench visual hierarchy' (#87) from feature/workbench-visual-redesign into main 2026-10-14 16:53:45 +00:00
weichen 9b9f24b7be Enforce tokenized workbench visuals across themes
Why: Independent review found component-local color derivation, a private transition, and missing dark/molv coverage in the visual candidate.
Evidence: Frontend design guard passes and 19 Playwright scenarios cover light, dark, molv, and 320-1920px layouts.
Impact: Keeps the approved visual direction while moving color ownership to @ui tokens and removing private motion.
Refs: Agent-System-Dev feature/workbench-visual-redesign
2026-10-14 16:50:17 +00:00
weichen 8dc68fd1e3 Keep the create action legible on dark navigation
Why: The inherited light-theme divider and hover treatment disappeared inside the new inverse create button.
Evidence: Focused browser layout test and production build pass locally.
Impact: Changes only the create action divider and hover surface.
Refs: Agent-System-Dev feature/workbench-visual-redesign
2026-10-14 16:40:50 +00:00
weichen ddf3135c3f Establish a distinct workbench visual frame
Why: Spacing-only refinements still looked like the original flat gray workspace and did not satisfy the approved visual redesign.
Evidence: 272 unit tests, 16 browser scenarios across 320-1920px, and the production build pass locally.
Impact: Gives navigation, authored messages, assistant results, and work details clearly different visual surfaces while preserving behavior.
Refs: Agent-System-Dev feature/workbench-visual-redesign
2026-10-14 16:38:26 +00:00
weichen b04f4ded95 Redesign the workbench visual hierarchy
Why: The three-column workspace read as flat and visually scattered, making conversation, current work, and navigation difficult to distinguish.
Evidence: 272 unit tests, 16 focused browser scenarios across 320-1920px, and the production build pass locally.
Impact: Refines the existing workbench presentation without changing its data flow or fast/slow task behavior.
Refs: Agent-System-Dev feature/workbench-visual-redesign
2026-10-14 16:35:17 +00:00
weichen 710ef836b5 Merge pull request 'Separate conversation history from work details' (#85) from feature/work-companion-visibility into main 2026-10-14 14:40:27 +00:00
weichen e10e447dab Keep collaboration events distinct from partial text
Why: Status words shared with a final reply could suppress a child completion record.

Evidence: 272 frontend unit tests, build, and 15 isolated browser scenarios.

Impact: Tag client-side collaboration replay so partial-delta deduplication preserves agent notes.

Refs: Agent-System-Dev feature/work-companion-visibility UI1
2026-10-14 14:27:28 +00:00
weichen b175506ebd Deduplicate partial streamed narration in process history
Why: A dropped delta batch can leave a short event prefix next to the complete saved message.

Evidence: 271 frontend unit tests, build, and 14 isolated browser scenarios.

Impact: Suppress overlapping partial replay text and thinking while retaining saved content.

Refs: Agent-System-Dev feature/work-companion-visibility UI1
2026-10-14 14:25:04 +00:00
weichen 7afae7f20b Preserve saved process details during event replay
Why: Event replay could hide saved retry notes and leave completed cards outside the active work filter.

Evidence: 270 frontend unit tests, build, and 14 isolated browser scenarios.

Impact: Merge replay into saved history, retain child progress, and locate cards under the right scope.

Refs: Agent-System-Dev feature/work-companion-visibility UI1 UI2
2026-10-14 14:17:33 +00:00
weichen 099a92022d Separate conversation history from work details
Why: Fast and slow channel cards duplicate across panes and completed replies hide process output.

Evidence: npm test; npm run build; 12 isolated Playwright scenarios with reload and responsive widths.

Impact: Preserve ordered folded process history, load durable run events, and give work cards one detailed pane.

Refs: Agent-System-Dev feature/work-companion-visibility UI1 UI2 UI3
2026-10-14 14:07:43 +00:00
weichen 5313cfad4e Merge pull request 'Add export controls with scoped asynchronous state' (#73) from feature/rdm-audit-export-entry into main 2026-10-14 02:49:17 +00:00
weichen 4e1eb39035 Integrate export controls with current identity boundaries
Why: Complete the authorized export entry while preserving current main project and session behavior.

Evidence: SELF full frontend tests and build, 263 unit tests; deferred browser races reproduced then all 6 passed. Raw records stay outside source until Dev task closure.

Impact: Export callbacks are invalidated on project, identity and unmount changes. Command readiness and current main security paths remain intact.

Refs: Dev PR #488; Frontend PR #73; user takeover authorization on 2026-10-14
2026-10-14 02:38:58 +00:00
weichen eb2d6d32f9 Merge pull request 'Use verified project identities for member workflows' (#82) from feature/project-member-access into main 2026-10-13 11:52:27 +00:00
weichen 40c2afe054 Merge the upstream member command rename
Why: Current main carries the same corrected lowercase command ID as this member candidate.

Evidence: Preserve the canonical wrapper and set-role ID; compare the final tree against the independently tested 2ba275b object.

Impact: Update ancestry only; no intended product behavior change.

Refs: Frontend PR83; project-member-access
2026-10-13 11:35:36 +00:00
weichen 42cda9c056 Merge pull request 'Rename member setRole command call to set-role' (#83) from fix/set-role-rename into main 2026-10-13 11:03:41 +00:00
weichen 15b962694b Merge origin/main 2026-10-13 11:03:27 +00:00
weichen 67585c1701 Rename member setRole command call to set-role
Why: RDM 命令 ID 契约改名(camelCase 违基座校验,RDM 仓同批修复);前端调用点同步。
Evidence: tsc 通过。
Impact: 单处命令 ID。
2026-10-13 10:50:36 +00:00
weichen 2ba275b991 Keep document duties tied to the current selection
Why:
Fresh documents had no first-submit action, reading active duties opened
confirmation dialogs, and delayed detail or status queries could overwrite
another selection and send its document ID with the wrong duty draft.

Evidence:
SELF real-browser regressions observed the missing action, render-time
dialogs, detail/status races, and a follow-up query after panel unmount.
Five focused Playwright scenarios passed after the fix, together with the
full npm test, npm run build, and git diff --check commands. Receipts:
/tmp/project-member-duties-browser-green-20261013.json and
/tmp/project-member-duties-full-build-green-20261013.json.

Impact:
Offer first submission independently of an existing review round. Ask for
invalidation only on a dirty save click. Guard queries and continuations
with project lifetime, selection, and request sequences; keep the existing
Picker, canonical project routing, controls, and UI tokens.

Refs:
Dev project-member-access Task 4; independent synchronized Frontend P2 review
2026-10-13 10:21:37 +00:00
weichen e7f9081a93 Merge current project picker and permission panels
Why: Current main supplies member pickers and duty catalogs that must keep verified project identity.

Evidence: Frontend 259 tests and build pass after resolving panel conflicts; final canonical set-role dispatch and fixed combo checks follow.

Impact: Preserve upstream panels with canonical project commands and read-versus-work state guards.

Refs: feature/project-member-access; origin/main c294835
2026-10-13 10:02:54 +00:00
weichen c294835f8d Merge pull request 'Rebuild duties and permission pages on catalog, matrix and tri-state' (#81) from feature/rdm-increment-duties-page into main 2026-10-13 09:56:49 +00:00
weichen 880eff74f8 Rebuild duties and permission pages on catalog, matrix and tri-state
Why: P7-B/C——文档职责页升级为「文档清单+选中职责详情」(duty.catalog/get:名称/类型/阶段/在途轮次,当前职责经三个项目成员 Picker 回填并与冻结名单分列;在途轮次失效确认;批量勾选+统一三权走 assign.doc.batch,整批拒绝列原因);权限页升级为「单成员详情+多人矩阵」双视图(中文标签、系统范围动作折叠为技术详情、矩阵单元格悬停解释、行展开三态覆盖——沿用默认/项目内允许/项目内禁止,恢复默认走 perm.unset 实际撤销)。
Evidence: tsc、design-guard 6/6、npm run build 通过;服务端语义由 RDM #346(标签/矩阵)与 #349(unset)测试覆盖;折叠使用 @ui Collapsible 原语。
Impact: RdmDutiesSection/RdmPermMatrixSection 重构;无跨页变更。
Refs: 提案 r1 §4/§5/P7;RDM #346/#349
2026-10-13 09:56:32 +00:00
weichen b7473fe585 Merge pull request 'Wire members section to Picker with explicit role changes' (#80) from feature/rdm-increment-members-picker into main 2026-10-13 09:35:29 +00:00
weichen dfcea5c988 Wire members section to Picker with explicit role changes
Why: P7-A——成员添加从手填账号升级为可搜索人员选择器(@ui Picker:搜索/浏览/键盘/分页),仅提交稳定账号;候选「已加入」标记杜绝隐式身份变更;成员表拆分「专业角色」与「项目身份」(中文标签)两列;身份调整走 rdm.project.member.setRole 显式命令(升/降按钮)。
Evidence: tsc 通过;design-guard 6/6;npm run build 成功;Picker 行为由 UI #53 的 7 用例覆盖。
Impact: RdmMembersSection 重构(member.add/setRole/remove 三命令消费);rdmManagement 增 setRole payload;account.list 适配层 query 透传+旧基座首页本地过滤降级。
Refs: 提案 r1 §3/P7;UI #53;RDM #345
2026-10-13 09:34:52 +00:00
weichen 07a03ac4f6 Preserve project history reads after archive or closure
Why:
Project readiness is a write gate. Applying it to member, permission, and
document status queries hides history that the backend intentionally keeps
readable after archival or closure.

Evidence:
SELF archived and closed read regressions failed before the fix. Full npm
test, npm run build, and git diff --check passed with the amended code.
Receipts: /tmp/project-member-frontend-archived-read-red-20261013.json and
/tmp/project-member-frontend-archived-read-green-20261013.json.

Impact:
Resolve canonical project IDs separately from command readiness. Preserve
known read commands and existing settings tabs for historical projects;
keep mutations and new work blocked with existing disabled controls.
Server authorization remains authoritative.

Refs:
Dev project-member-access Task 4; independent Frontend review P2
2026-10-13 08:41:12 +00:00
weichen d69009e081 Invalidate account continuations across awaited project work
Why:
An identity can change after an operation has passed its first response guard.
Later project loads, cleanup calls, and attachment refreshes must not reopen
private context or return the previous account's result to the new session.

Evidence:
SELF regressions failed before the guards: delayed project opening, task
creation success, completed-card cleanup, and upload artifact-ID return.
SELF npm test, npm run build, and git diff --check passed; full command
receipt: /tmp/project-member-frontend-final-checks-20261013.json.

Impact:
Guard post-await continuations with the same identity epoch. Preserve the
original server-side result and ignore stale client projections; do not undo
the previous account's already completed work.

Refs:
Dev feature-project-member-access implementation plan Task 4
2026-10-13 08:24:06 +00:00
weichen 55a64f40e8 Respect canonical project bindings and isolate account state
Why:
Project members need RDM commands to address the registered domain project.
Failed project states and responses from a previous account must not create
work, report a rejected mutation as successful, or restore private content.

Evidence:
SELF npm test: 32 files and 251 tests passed, including project command,
mutation rejection, archived history, and delayed account response coverage.
SELF npm run build and git diff --check passed. Original regressions were
observed before implementation; root owns composition and independent replay.

Impact:
Keep host IDs for conversation and base settings APIs; send the decorated
domain ID to RDM project commands. Reuse existing sidebar, settings and toast
states. Reset identity projections and invalidate old asynchronous responses.
No deployment, database change, push, merge, or workspace cleanup.

Refs:
Dev feature-project-member-access task card and implementation plan Task 4
2026-10-13 08:20:44 +00:00
weichen b3fa39744d Merge pull request 'Add admin system settings panel for managed model and service config' (#78) from feature/rdm-increment-system-settings into main 2026-10-13 06:40:58 +00:00
weichen 23f96fd921 Merge origin/main into feature/rdm-increment-system-settings 2026-10-13 06:40:37 +00:00
weichen f96a591123 Add admin system settings panel for managed model and service config
Why: P2 前端入口——系统受管项(模型/密钥/Tavily/遥测/运行基础设施)需要唯一的管理员写入面;系统管理新增「模型与服务配置」分区消费 GET/PUT /api/settings/system。
Evidence: vitest design-guard 6/6 通过;npm run build 成功;面板仅渲染 scope=system 项,保存后重 GET 回填,403 如实提示。
Impact: contracts 加 SettingDesc.scope;api 加 get/putSystemSettings;AdminZone 注册新分区;无既有面板变更。
Refs: modules/documents/docs/proposals/2026-10-13-rdm-settings-members-duties-perms-increment.md P2;Backend PR 188
2026-10-13 05:59:57 +00:00
weichen 698fffd929 Merge pull request 'Add login-key browser e2e and harden changepw gate sync' (#77) from feature/login-key-e2e into main 2026-10-13 04:57:57 +00:00
weichen 44b964682c Add login-key browser e2e and harden changepw gate sync
Why: The login recovery contract requires a real-browser acceptance
spec covering admin issue -> key-only redeem -> forced set-password ->
relogin, and the changepw spec needed the same deterministic gate
synchronization.

Evidence: Both specs pass against an isolated auth-on instance
(serve --auth on, binary backend=467718a+frontend=9fc7da2); receipts
be7ae9a2/b3a9e691 bound to Dev HEAD 268ef29 (SELF). changepw-form also
passes (5.9s) alongside login-key (12.2s) on a reused instance,
proving idempotency.

Impact: New e2e/login-key.playwright.ts (skip-guarded like existing
suites); changepw login() waits for the gate heading and success page
before returning to login, fixing a stale-fill race that submitted
empty gate fields.

Refs: Dev docs/svc-deploy/2026-10-13-login-recovery-contract.md; Dev PR #504.
2026-10-13 04:33:35 +00:00
weichen 0655bc37e1 Merge pull request 'Wire login recovery flow: redeem key entry and restricted set-password gate' (#76) from feature/svc-login-recovery-flow into main 2026-10-13 04:04:00 +00:00
weichen 9fc7da2ace Wire login recovery flow: redeem key entry and restricted set-password gate
Why: The 2026-10-13 login contract makes username/password the default
login, replaces the browser ask_ tab with a one-time recovery key
entry, requires the forced set-password gate to drop the current
password field, and routes admin password recovery through admin-issued
login keys instead of direct password resets.

Evidence: vitest 218 passed, npm run build OK, check-knip 43<=43,
design-guard green (SELF, local run in task worktree).

Impact: LoginPage gains a redeem-only key tab (no key persistence);
ChangePasswordGate shows server-confirmed owner plus new/confirm only
and returns to the login page after success (reloginRequired); App
gates on sessionScope=set_password_only; UsersPanel replaces reset
password with issue/revoke login key UI showing plaintext once.

Refs: Dev task feature/svc-login-recovery-flow; backend companion
commit 467718a.
2026-10-13 03:42:23 +00:00
weichen dcd2699933 Merge pull request 'Show an honest rejected state on the RDM roles panel' (#75) from feature/rdm-roles-reject-state into main 2026-10-13 01:35:38 +00:00
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
weichenandCursor d9bb780384 Add an audit overview export that stays outside the readiness gate
Why: auditors need to generate and download a watermarked report from the overview, and that must not appear when the export commands are off or change whether the workspace itself opens.
Evidence: SELF npm test, 138 passed. Playwright download case is in e2e/rdm-audit.playwright.ts and runs under scripts/e2e.sh.
Impact: the overview shows type, generate, draft confirmation, watermark, sha256, expiry, and download only when the three export commands are enabled.
Refs: RDM pull request 301
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 15:38:00 +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
81 changed files with 9107 additions and 617 deletions
+83
View File
@@ -0,0 +1,83 @@
// 防再发守卫:管理员建号路径(UsersPanel)必须持续符合后端待初始化契约。
// 背景:backend 8dfc223 将 POST /api/admin/users 改为严格 {owner, profile} 解码,
// 前端未跟进导致部署后 UI 建号 400(json: unknown field "name")——单测与 mock
// 都没有覆盖这条组合路径,本场景以真实浏览器把「填用户名 → 创建账号 →
// 确认『待初始化』提示与用户表行」钉进组合验收线。
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制 + frontend dist,起 serve(auth on)
// 与 vite preview;种子账号 e2e-auditor 为基座管理员(bootstrap-admin)。
// 环境契约(缺省值与 e2e.sh 一致;PLAYWRIGHT_BASE_URL 未设置时整组跳过):
// PLAYWRIGHT_BASE_URL / E2E_OWNER / E2E_PASSWORD / E2E_PASSWORD2
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, '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 entered = await page.locator('.tsb-user').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (!entered) {
const gated = await page.getByText('修改密码').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-user')).toBeVisible({ timeout: 15_000 })
}
test('管理员建号:UI 请求新契约并给出待初始化提示', async ({ page }) => {
await login(page)
// 账户菜单 → 系统管理…(全屏独立视图)→ 用户管理分区。
await page.locator('.tsb-user').click()
const adminEntry = page.getByText('系统管理…', { exact: true })
await expect(adminEntry).toBeVisible()
await adminEntry.click()
await expect(page.locator('.az')).toBeVisible()
const usersSection = page.getByText('用户管理', { exact: true }).first()
if (await usersSection.count()) await usersSection.click()
// 建号表单:用户名每次运行唯一(重复运行不因 409 假红),显示名可空。
const newOwner = `e2e-ui-${Date.now().toString(36)}`
const userInput = page.getByPlaceholder('用户名(登录名)')
await expect(userInput).toBeVisible()
await userInput.fill(newOwner)
await page.getByPlaceholder('显示名(可空)').fill('组合验收')
// 创建 → 契约断言一:请求体是严格 {owner, profile:{name}},无 password/isAdmin。
const createBody = page.waitForRequest(
req => req.url().endsWith('/api/admin/users') && req.method() === 'POST',
).then(req => req.postDataJSON())
await page.getByRole('button', { name: '创建账号', exact: true }).click()
const body = await createBody
expect(body).toEqual({ owner: newOwner, profile: { name: '组合验收' } })
// 契约断言二:成功提示逐字含「待初始化」与 CLI 发放指引。
await expect(page.locator('.ad-notice')).toHaveText(
`已创建 ${newOwner}(待初始化):请在服务器执行 user initialize-account 发放激活 Key`,
)
// 契约断言三:用户表出现该账号,状态为未设密码(HTTP 建号不能设密码/授权限)。
const row = page.locator('.ad-table tbody tr', { hasText: newOwner })
await expect(row).toBeVisible()
await expect(row).toContainText('未设密码')
await expect(row).not.toContainText('ADMIN')
// 界面说明与后端流程一致(激活 Key 由服务器本地 CLI 发放)。
await expect(page.locator('.ad-create .ad-hint')).toContainText('待初始化')
await expect(page.locator('.ad-create .ad-hint')).toContainText('user initialize-account')
})
+11 -4
View File
@@ -17,14 +17,21 @@ async function login(page: Page) {
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
const gate = page.getByText('修改密码')
// 受限设密门(2026-10-13 合同):heading 唯一定位(标题与按钮同名),
// 门挂载后登录卡已卸载,fill 不再与登录卡竞态。
const gate = page.getByRole('heading', { name: '设置新密码' })
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(0).fill(PASSWORD2)
await pwInputs.nth(1).fill(PASSWORD2)
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
// 设密成功后服务端销毁会话:等成功页再回登录(在途点取消会被会话复验拒绝)。
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '返回登录', exact: true }).click()
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
} else {
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
+112
View File
@@ -0,0 +1,112 @@
// 登录恢复流程真实浏览器验收(2026-10-13 合同,docs/svc-deploy/
// 2026-10-13-login-recovery-contract.md):管理员签发一次性登录 Key →
// 用户在登录页只输 Key 兑换 → 受限设密门(无当前密码框)→ 设密成功回登录页 →
// 新密码登录进工作台;重复兑换同一 Key 被拒。
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
import { test, expect, type Page } from '@playwright/test'
const ADMIN = process.env.E2E_ADMIN || 'e2e-admin'
const ADMIN_BOOT = process.env.E2E_ADMIN_PASSWORD || 'E2eAdmin#2026'
const ADMIN_PW = process.env.E2E_ADMIN_PASSWORD2 || 'E2eAdmin#2026b'
const USER = process.env.E2E_LOGIN_KEY_USER || 'e2e-user'
const USER_PW = process.env.E2E_LOGIN_KEY_PASSWORD2 || 'E2eUser#2026b'
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (auth-on instance required)')
async function passwordLogin(page: Page, owner: string, password: string) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(owner)
await page.getByPlaceholder('初始密码或常用密码').fill(password)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
/** 管理员登录;初始密码(must-change)则先完成受限设密再以新密码重登。
* 初始密码单次有效:已跑过本用例的环境(boot 已被改掉)自动改用新密码。 */
async function adminLogin(page: Page) {
await passwordLogin(page, ADMIN, ADMIN_BOOT)
// 门标题与提交按钮同名(设置新密码):用 heading 角色唯一定位,
// getByText 双命中会触发 strict-mode 抛错被 catch 吞掉、误判 gated=false。
let gate = page.getByRole('heading', { name: '设置新密码' })
let gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (!gated) {
// boot 密码失败(此前运行已完成改密):换正式密码重登。
const loginErr = await page.locator('.lg-err').waitFor({ state: 'visible', timeout: 3_000 }).then(() => true).catch(() => false)
if (!loginErr) throw new Error('admin login neither gated nor errored')
await passwordLogin(page, ADMIN, ADMIN_PW)
gate = page.getByRole('heading', { name: '设置新密码' })
gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
}
if (gated) {
const pw = page.locator('.lg-card input[type="password"]')
await pw.nth(0).fill(ADMIN_PW)
await pw.nth(1).fill(ADMIN_PW)
await page.getByRole('button', { name: '设置新密码', exact: true }).click()
// 等设密事务完成再回登录:提交在途时点取消会登出受限会话、
// 服务端按会话复验拒绝该次设密(产品正确行为,测试必须同步成功页)。
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '返回登录', exact: true }).click()
await passwordLogin(page, ADMIN, ADMIN_PW)
}
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
}
test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新密码登录;重复兑换被拒', async ({ page }) => {
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
test.setTimeout(120_000)
// 1) 管理员进入用户管理,为目标用户生成一次性登录 Key(明文仅显示一次)。
await adminLogin(page)
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
const zone = page.getByRole('dialog', { name: '系统管理' })
await expect(zone).toBeVisible()
// 每个用户行都有同款按钮:限定目标用户所在行。
await zone.locator('tr', { hasText: USER }).getByRole('button', { name: '生成登录 Key' }).click()
await zone.getByRole('button', { name: '生成 Key', exact: true }).click()
const keyInput = zone.locator('input[aria-label="一次性登录 Key(仅显示一次)"]')
await expect(keyInput).toBeVisible({ timeout: 10_000 })
const key = await keyInput.inputValue()
expect(key).toMatch(/^alk_/)
await expect(zone.getByText(/单次有效/)).toBeVisible()
// 2) 管理员退出系统管理与登录态,回到登录页。
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
await zone.getByRole('button', { name: '返回工作台' }).click()
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
// 3) 用户只输 Key 兑换;身份由服务端绑定确定(页面不出现用户名输入框)。
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
await page.getByPlaceholder('alk_…').fill(key)
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
// 4) 受限设密门:服务端确认用户名;无当前密码输入框。
const gate = page.locator('.lg-card')
await expect(gate.getByText(`账号 ${USER}(服务端确认)`)).toBeVisible({ timeout: 10_000 })
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0)
const pw = gate.locator('input[type="password"]')
await pw.nth(0).fill(USER_PW)
await pw.nth(1).fill(USER_PW)
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
// 5) 成功页:提示用新密码登录,唯一出口回登录页。
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '返回登录', exact: true }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible()
// 6) 新密码登录进工作台(旧初始密码已不可用一并验证)。
await passwordLogin(page, USER, USER_PW)
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
// 7) 同一 Key 重复兑换被拒,错误文案指向管理员重签。
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
await page.getByPlaceholder('alk_…').fill(key)
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
await expect(page.getByText('该 Key 已被使用过,请联系管理员重新签发')).toBeVisible({ timeout: 10_000 })
})
+20
View File
@@ -123,4 +123,24 @@ test.describe.serial('U4 项目追溯审计工作区', () => {
await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '',
)
})
test('概览导出:生成后显示水位与校验和,并可下载', async ({ page }) => {
await login(page)
await openAudit(page)
const exportBox = page.locator('.rdm-audit-export')
await expect(exportBox).toBeVisible()
await exportBox.getByLabel('导出类型').selectOption('cgraph-docx')
await exportBox.getByRole('button', { name: '生成', exact: true }).click()
const draft = exportBox.getByRole('button', { name: '生成草稿' })
if (await draft.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true).catch(() => false)) {
await draft.click()
}
await expect(exportBox.getByText(/水位 \d+/)).toBeVisible({ timeout: 15_000 })
await expect(exportBox.getByText(/sha256 [0-9a-f]{64}/)).toBeVisible()
await expect(exportBox.getByText(/有效期/)).toBeVisible()
const download = page.waitForEvent('download')
await exportBox.getByRole('button', { name: '下载' }).click()
const file = await download
expect(file.suggestedFilename()).toMatch(/\.docx$/)
})
})
+171
View File
@@ -0,0 +1,171 @@
// I4 组合验收(Playwright):文档驾驶舱七场景(rollout §3.I4 最小场景集)。
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制(含 I1/I3 命令面)+ frontend dist,
// 种子:审计演示项目(rdm-self + cockpit-e2e 结构装载;两个章节经草稿流落正文;
// 一个章节留空;第二个账号 e2e-reader 不入项目做权限负例)。
// 环境契约与 rdm-audit.playwright.ts 一致;PLAYWRIGHT_BASE_URL 未设置时整组跳过。
import { test, expect, type Page } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
const PASSWORD = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
const READER = process.env.E2E_READER || 'e2e-reader'
const READER_PASSWORD = process.env.E2E_READER_PASSWORD || 'E2eReader#2026'
const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
async function login(page: Page, user: string, password: string) {
await page.goto('/')
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
await page.getByPlaceholder('由管理员分配').fill(user)
await page.getByPlaceholder('初始密码或常用密码').fill(password)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
async function loginOwner(page: Page) {
await login(page, OWNER, PASSWORD)
await expect(page.locator('.tsb-tool', { hasText: '文档驾驶舱' })).toBeVisible({ timeout: 15_000 })
// 登录后身份纪元与能力探针仍可能各跳一次;应用在身份变更时会关闭已开
// 工作区——等落定再点入口,避免 overlay 被身份刷新关掉(竞态实测)。
await page.waitForLoadState('networkidle').catch(() => {})
await page.waitForTimeout(1_500)
}
async function openCockpit(page: Page) {
await page.locator('.tsb-tool', { hasText: '文档驾驶舱' }).click()
await expect(page.locator('.cockpit-project')).toBeVisible({ timeout: 15_000 })
// 默认选中首个项目(种子=审计演示项目)。
await expect(page.locator('.cockpit-stats')).toBeVisible({ timeout: 15_000 })
}
async function openReaderOnDoc(page: Page, title: string) {
await page.locator('.cockpit-tree-title', { hasText: title }).first().click()
await page.getByRole('button', { name: '在阅读器中打开' }).click()
await expect(page.locator('.cockpit-reader')).toBeVisible()
}
test.describe.serial('I4 文档驾驶舱组合验收', () => {
// 场景 1:首次进入项目——统计带、结构树、全景可见(DV-R01/R02)。
test('1 首屏:统计、树、全景可见且统计可判读', async ({ page }) => {
await loginOwner(page)
await openCockpit(page)
// placeholder
await expect(page.locator('.cockpit-stat', { hasText: '完成度' })).toBeVisible()
await expect(page.locator('.cockpit-stat', { hasText: '文档' })).toBeVisible()
// 空 vs 不可算不得互相冒充:有数据项目的空缺计数显示数字。
await expect(page.locator('.cockpit-stat', { hasText: '空缺节点' }).locator('.cockpit-stat-num')).toHaveText(/^\d+$/)
await expect(page.locator('.cockpit-tree-title', { hasText: '验证主体报告' }).first()).toBeVisible()
await expect(page.getByText('文档概览')).toBeVisible()
await expect(page.locator('.cockpit-snap')).toContainText(/快照 wm:\d+/)
})
// 场景 2:文档→章节,全景与阅读器双向定位(DV-R03)。
test('2 定位:树选章节→阅读器正文;返回全景仍同一节点', async ({ page }) => {
await loginOwner(page)
await openCockpit(page)
// 展开目标文档(树按标题排序,branch 作用域防跨文档串扰),点「概述」。
const branch = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
await branch.locator('.cockpit-tree-caret').click()
const sec = branch.locator('.cockpit-tree-title', { hasText: '概述' }).first()
await expect(sec).toBeVisible()
sec.click()
await expect(page.locator('.cockpit-nodehead')).toBeVisible()
// 全景→阅读器:同一 nodeRef(阅读器显示该节标题与正文)。
await page.getByRole('button', { name: '在阅读器中打开' }).click()
await expect(page.locator('.cockpit-reader')).toBeVisible()
await expect(page.locator('.cockpit-nodetitle', { hasText: '概述' })).toBeVisible()
const body = page.locator('.cockpit-reader-para', { hasText: '本报告' }).first()
await expect(body).toBeVisible()
// 阅读器→全景:返回后仍是选中节点(节点头仍在)。
await page.getByRole('button', { name: '← 返回全景' }).click()
await expect(page.locator('.cockpit-nodehead')).toBeVisible()
})
// 场景 3:四面板展开并正确显示计数;受限与空态诚实(DV-R04)。
test('3 面板:来源/历史/影响/冲突计数与空态、截断旗标', async ({ page }) => {
await loginOwner(page)
await openCockpit(page)
const branch = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
await branch.locator('.cockpit-tree-caret').click()
await branch.locator('.cockpit-tree-title', { hasText: '概述' }).first().click()
const panel = page.locator('.cockpit-panel')
await expect(panel).toBeVisible()
// 来源:草稿落版本挂了依据切片(种子经 ingest 提供)。
await panel.getByRole('tab', { name: '内容来源' }).click()
await expect(panel.locator('.cockpit-panel-count')).toContainText(/来源 \d+ 条/)
// 历史:已提交事件(structure + draft 链)。
await panel.getByRole('tab', { name: '修改历史' }).click()
await expect(panel.locator('.cockpit-panel-count')).toContainText('已提交事件')
await expect(panel.locator('.cockpit-panel-row').first()).toBeVisible()
// 影响:一跳;空/计数二态都诚实。
await panel.getByRole('tab', { name: '影响对象' }).click()
await expect(panel.locator('.cockpit-panel-count')).toContainText(/影响 \d+ 项/)
// 冲突:种子无开放碰撞 → 明确「无开放冲突」而非空白。
await panel.getByRole('tab', { name: '冲突与风险' }).click()
await expect(panel.getByText('无开放冲突')).toBeVisible()
// 版本块(I3):当前版本+差异折叠入口可见。
await expect(page.locator('.cockpit-versions-toggle')).toBeVisible()
})
// 场景 4:后台变更后所有投影一致(DV-R05)——测试经 API 采纳新版本,
// 驾驶舱刷新后统计水位/树时间/详情版本块同步前进。
test('4 一致性:API 采纳新版本后,刷新入口与各投影同步', async ({ page }) => {
await loginOwner(page)
await openCockpit(page)
await page.locator('.cockpit-tree-caret').first().click()
await page.locator('.cockpit-tree-title', { hasText: '设计说明' }).first().click()
const snapBefore = await page.locator('.cockpit-snap').innerText()
// 通过应用自身 API 采纳一个新提案(同源会话;与浏览器渲染解耦)。
await page.request.post('/api/commands/rdm.draft.propose', {
data: { params: { projectId: (await page.locator('.cockpit-project option:checked').textContent()) || '', sectionId: '', content: '一致性探针内容——不应出现' }, meta: {} },
failOnStatusCode: false,
})
// 上面缺 sectionId 必然 400:真正的驱动是 e2e.sh 早已留好的第二个提案
// (种子阶段 propose 未采纳的「一致性探针」),这里经面板刷新读取。
await page.getByRole('button', { name: '刷新' }).click()
await expect(page.locator('.cockpit-snap')).not.toHaveText(snapBefore)
// 版本块前进:出现新版本条目。
await expect(page.locator('.cockpit-versions-toggle')).toContainText(/版本 v\d+/)
})
// 场景 5:断线/失败保留旧快照并给出重试(DV-R08)——page.route 掐断命令面。
test('5 失败:命令面断连时保留旧快照并出现重试入口', async ({ page }) => {
await loginOwner(page)
await openCockpit(page)
await expect(page.locator('.cockpit-stats')).toBeVisible()
// 掐断后续命令面:切换项目触发重取 → 失败横幅 + 旧内容保留。
await page.route('**/api/commands/rdm.cockpit.**', route => route.abort())
await page.locator('.cockpit-refresh').click()
await expect(page.locator('.cockpit-banner-err')).toBeVisible()
await expect(page.locator('.cockpit-stats')).toBeVisible() // 旧快照未被清空
await page.unroute('**/api/commands/rdm.cockpit.**')
await page.locator('.cockpit-refresh').click()
await expect(page.locator('.cockpit-banner-err')).toBeHidden()
})
// 场景 6:权限负例——非成员账号看不到项目(无项目诚实空态;不泄露存在性)。
test('6 权限:非成员账号无项目可见', async ({ page }) => {
await login(page, READER, READER_PASSWORD)
await page.locator('.tsb-tool', { hasText: '文档驾驶舱' }).click()
await expect(page.getByText('暂无可见研发项目')).toBeVisible({ timeout: 15_000 })
await expect(page.locator('.cockpit-tree-title', { hasText: '验证主体报告' })).toHaveCount(0)
})
// 场景 7:窄屏、键盘、减少动效(DV-R07)。
test('7 可用性:390 宽无横向溢出、键盘可达、减少动效可用', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.setViewportSize({ width: 390, height: 844 })
await loginOwner(page)
await openCockpit(page)
// 无横向溢出。
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
// 键盘:统计卡/树行可聚焦(tab 序含驾驶舱交互件)。
await page.locator('.cockpit-tree-title').first().focus()
await expect(page.locator('.cockpit-tree-title').first()).toBeFocused()
// 18px 大字(html font-size 注入)不破坏统计带布局。
await page.addStyleTag({ content: 'html{font-size:18px !important}' })
await expect(page.locator('.cockpit-stats')).toBeVisible()
const overflowBig = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflowBig).toBeLessThanOrEqual(8)
})
})
+101
View File
@@ -0,0 +1,101 @@
// 文档职责三视图:确定性 HTTP,不连接真实账号或数据库。
import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
function deferred() {
let resolve!: () => void
const promise = new Promise<void>(ok => { resolve = ok })
return { promise, resolve }
}
async function dutiesPage(page: Page) {
const writes: { command: string; params: Record<string, unknown> }[] = []
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
let generation = 0
const commands = ['rdm.project.member.list', 'rdm.duty.matrix', 'rdm.duty.preview',
'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', 'rdm.duty.legacy.scan', 'rdm.duty.legacy.apply']
await page.route('**/api/**', async route => {
const request = route.request()
const path = new URL(request.url()).pathname
let body: unknown = []
if (path === '/api/health') body = { ok: true }
else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: true }
else if (path === '/api/projects') body = [{ id: 'host-1', name: '职责测试项目', createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }]
else if (path === '/api/settings') body = { schema: [], defaults: {}, user: {}, project: {} }
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
else if (path.startsWith('/api/commands/')) {
const command = decodeURIComponent(path.substring('/api/commands/'.length))
const params = request.postDataJSON().params as Record<string, unknown>
expect(params.projectId).toBe('domain-7')
const hold = holds.get(command)
if (hold) { hold.started.resolve(); await hold.release.promise }
const author = generation === 0 ? 'alice' : 'bob'
if (command === 'rdm.duty.matrix') {
body = { payload: {
canAssign: true,
docs: [{ docId: 'doc-A', code: 'A', title: '文档A', typeKey: 'design', stage: 'BD', dutyVersion: 1, authors: 1, reviewers: 1, approvers: 1, conflicts: 0, legacySectionOverrides: 0 }],
members: ['alice', 'bob', 'carol'].map(owner => ({ owner, role: 'member', valid: true, canAuthor: true, canReview: true, canApprove: true })),
cells: [
{ docId: 'doc-A', owner: 'alice', duty: author === 'alice' ? 'author' : 'none' },
{ docId: 'doc-A', owner: 'bob', duty: author === 'bob' ? 'author' : 'reviewer' },
{ docId: 'doc-A', owner: 'carol', duty: 'approver' },
],
} }
} else if (command === 'rdm.duty.preview') {
writes.push({ command, params })
body = { payload: { ok: true, docs: [{ docId: 'doc-A', title: '文档A', replacedAuthor: 'alice' }] } }
} else if (command === 'rdm.duty.legacy.scan') {
body = { payload: { findings: [{ docId: 'doc-A', title: '文档A', sectionTitle: '范围', kind: 'ambiguous' }] } }
} else {
writes.push({ command, params })
generation += 1
body = { payload: { applied: 1 } }
}
} else if (path.endsWith('/cards')) body = { cards: [] }
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
await page.goto('/')
await page.getByRole('button', { name: '项目设置', exact: true }).click()
await page.getByRole('tab', { name: '文档职责', exact: true }).click()
await expect(page.getByText('本页只分配已有文档的职责,不创建或生成文档。')).toBeVisible()
return { writes, hold(command: string) {
const pending = { started: deferred(), release: deferred() }
holds.set(command, pending)
return pending
} }
}
test('three views share one draft and do not offer review actions', async ({ page }) => {
const fixture = await dutiesPage(page)
await page.getByLabel('选择成员').selectOption('bob')
await page.getByLabel('bob 在 文档A 的职责').selectOption({ label: '编制(将替换alice)' })
await page.getByRole('tab', { name: '按文档' }).click()
await page.getByLabel('选择文档').selectOption('doc-A')
await page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author')
await page.getByRole('tab', { name: '矩阵' }).click()
await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author')
await expect(page.getByRole('button', { name: /提交|审核通过|驳回|终批|生成|新建文档/ })).toHaveCount(0)
await page.getByRole('button', { name: '保存职责' }).click()
await expect.poll(() => fixture.writes.map(item => item.command)).toEqual(['rdm.duty.preview', 'rdm.review.assign.doc.batch'])
})
test('a stale matrix response does not replace the current draft', async ({ page }) => {
const fixture = await dutiesPage(page)
await page.getByLabel('选择成员').selectOption('alice')
const held = fixture.hold('rdm.duty.matrix')
await page.getByLabel('筛选文档名称或编号').fill('文档A')
await held.started.promise
await page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
held.release.resolve()
await expect(page.getByLabel('alice 在 文档A 的职责')).toHaveValue('reviewer')
})
test('a narrow viewport points away from the full matrix', async ({ page }) => {
await dutiesPage(page)
await page.setViewportSize({ width: 390, height: 800 })
await page.getByRole('tab', { name: '矩阵' }).click()
await expect(page.getByText('当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。')).toBeVisible()
})
+107
View File
@@ -0,0 +1,107 @@
// Isolated browser component regression: actual React workspace, deliberately deferred API responses.
// This checks client request ownership, not server authorization or business acceptance.
import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_COMPONENT_BASE_URL, 'requires the isolated Vite component test server')
test.use({ baseURL: process.env.PLAYWRIGHT_COMPONENT_BASE_URL })
async function mount(page: Page, delayed: string) {
const index = await page.request.get('/')
const html = (await index.text()).replace(/<script[^>]+src="\/src\/main\.tsx[^" ]*"[^>]*><\/script>/, '')
// Let Vite prepare ReactDOM before requesting its optimized module directly.
await page.request.get('/src/main.tsx')
await page.route('**/__export_test', route => route.fulfill({ contentType: 'text/html',
body: html }))
await page.goto('/__export_test')
await page.waitForFunction(() => typeof (window as any).$RefreshReg$ === 'function')
await page.evaluate(async delayed => {
const load = (path: string) => import(/* @vite-ignore */ path)
const { default: React } = await load('/node_modules/.vite/deps/react.js')
const { default: ReactDOM } = await load('/node_modules/.vite/deps/react-dom_client.js')
const { RdmAuditWorkspace } = await load('/src/components/RdmAuditWorkspace.tsx')
const { api } = await load('/src/api.ts')
const { auth } = await load('/src/auth.ts')
const { store } = await load('/src/store.ts')
const { RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } = await load('/src/workspaces.ts')
const calls: { command: string; params: any }[] = []
let actor = 'alice'
const pending: (() => void)[] = []
let downloads = 0
URL.createObjectURL = () => { downloads++; return 'blob:export-test' }
URL.revokeObjectURL = () => {}
HTMLAnchorElement.prototype.click = () => {}
api.listPlugins = async () => []
api.listWorkspaces = async () => []
api.listCommands = async () => [...RDM_AUDIT_COMMANDS, ...RDM_EXPORT_COMMANDS].map(id => ({ id, enabled: true }))
api.adminZoneSections = async () => []
api.getSettings = async () => ({ user: {} })
api.rdmCommand = async (command: string, params: any = {}) => {
calls.push({ command, params })
const result = command === 'rdm.inspect.capabilities'
? { actor, crossProject: false, projects: ['A', 'B'].map(projectId => ({ projectId, name: actor + projectId, canAudit: true })) }
: command === 'rdm.inspect.summary'
? { projectId: params.projectId, counts: {}, watermark: 7 }
: command === 'rdm.inspect.export.create'
? { exportId: params.projectId + '-report', watermark: 7, sha256: 'abc', fileName: 'report.docx' }
: command === 'rdm.inspect.export.status'
? { expiresAt: '2099-01-01', watermark: 7, sha256: 'abc' }
: { fileName: 'report.docx', mime: 'application/octet-stream', dataB64: 'QQ==', sha256: 'abc' }
if (command === delayed && params.projectId === 'A') {
await new Promise<void>(resolve => pending.push(resolve))
}
return result
}
await store.refreshPlugins()
const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(React.createElement(RdmAuditWorkspace))
;(window as any).exportTest = { calls, release: () => pending.splice(0).forEach(resolve => resolve()),
downloads: () => downloads, identity: async () => { actor = 'bob'; auth.beginSession(); await store.refreshPlugins() },
unmount: () => root.unmount() }
}, delayed)
await expect(page.locator('.rdm-audit-export')).toBeVisible()
}
async function release(page: Page) {
await page.evaluate(async () => { (window as any).exportTest.release(); await new Promise(resolve => setTimeout(resolve, 0)) })
}
const generate = (page: Page) => page.getByRole('button', { name: '生成', exact: true }).click()
for (const stage of ['create', 'status']) {
test(`ignores old ${stage} response after project A -> B -> A`, async ({ page }) => {
await mount(page, 'rdm.inspect.export.' + stage)
await generate(page)
await expect.poll(() => page.evaluate(stage => (window as any).exportTest.calls
.some((c: any) => c.command === 'rdm.inspect.export.' + stage), stage)).toBe(true)
await page.getByLabel('选择项目').selectOption('B')
await expect(page.locator('.rdm-audit-export')).toBeVisible()
await page.getByLabel('选择项目').selectOption('A')
await expect(page.locator('.rdm-audit-export')).toBeVisible()
await release(page)
await expect(page.getByRole('button', { name: '下载', exact: true })).toHaveCount(0)
if (stage === 'create') expect(await page.evaluate(() => (window as any).exportTest.calls
.filter((c: any) => c.command === 'rdm.inspect.export.status').length)).toBe(0)
await expect(page.getByRole('button', { name: '生成', exact: true })).toBeEnabled()
})
}
for (const change of ['project', 'identity', 'unmount']) {
test(`suppresses delayed download after ${change} change`, async ({ page }) => {
await mount(page, 'rdm.inspect.export.download')
await generate(page)
await page.getByRole('button', { name: '下载', exact: true }).click()
if (change === 'project') {
await page.getByLabel('选择项目').selectOption('B')
await expect(page.locator('.rdm-audit-export')).toBeVisible()
} else if (change === 'identity') {
await page.evaluate(() => (window as any).exportTest.identity())
await expect(page.getByLabel('选择项目').locator('option').first()).toHaveText('bobA')
} else await page.evaluate(() => (window as any).exportTest.unmount())
await release(page)
expect(await page.evaluate(() => (window as any).exportTest.downloads())).toBe(0)
})
}
test('current identity can generate and download normally', async ({ page }) => {
await mount(page, '')
await generate(page)
await page.getByRole('button', { name: '下载', exact: true }).click()
await expect.poll(() => page.evaluate(() => (window as any).exportTest.downloads())).toBe(1)
await expect(page.locator('.rdm-audit-export-result')).toContainText('水位 7')
})
+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([])
})
}
+584
View File
@@ -0,0 +1,584 @@
import { test, expect, type Page } from '@playwright/test'
import type { ActivityCard, Artifact, Conversation, Part, StreamEvent, WorkCardAction } 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,
history?: { events: StreamEvent[]; cardRef?: string; savedParts?: Part[] }, theme: 'light' | 'dark' | 'molv' = 'light') {
let cards = [...initialCards]
let actionCount = 0
let releaseAction: (() => void) | undefined
let holdAction = false
let sent: { text: string; attachmentIds: string[]; taskRef?: { cardId: string; resultVersion: number } } | undefined
const workActions: { cardId: string; actionId: string; payload: WorkCardAction }[] = []
let uploadNumber = 0
const errors: string[] = []
page.on('pageerror', error => errors.push(error.message))
await page.addInitScript(({ fontSize, theme }) => {
localStorage.setItem('as.conv', 'c1')
localStorage.setItem('as.ui', JSON.stringify({ theme, 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, theme })
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 },
...(history ? [{ id: 'm3', conversationId: id, role: 'user', runId: 'run-history', parts: [{ type: 'text', text: '继续核对' }], createdAt: stamp },
{ id: 'm4', conversationId: id, role: 'assistant', runId: 'run-history', parts: history.savedParts ?? [
{ type: 'thinking', process: true, text: '先核对日期' },
{ type: 'text', process: true, text: '开始读取材料' },
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' } },
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok', summary: '读取成功' } },
{ type: 'text', text: '最终回复已完成' },
...(history.cardRef ? [{ type: 'card', card: { cardId: history.cardRef, kind: 'result', data: [] } }] : []),
], createdAt: stamp }] : []),
] : [], runs: history ? [{ id: 'run-history', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'completed', createdAt: stamp, endedAt: stamp }] : [], activeHitl: [], lastSeq: 10 }
} else if (path === '/api/conversations/c1/cards') body = { cards }
else if (path === '/api/conversations/c1/runs/run-history/events') {
const after = Number(url.searchParams.get('after') ?? 0)
body = { events: (history?.events ?? []).filter(event => event.seq > after).slice(0, 500) }
}
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 (/^\/api\/cards\/[^/]+\/actions\/work\./.test(path)) {
const cardId = path.split('/')[3]
const actionId = path.split('/').at(-1)!
const payload = request.postDataJSON() as WorkCardAction
workActions.push({ cardId, actionId, payload })
cards = cards.map(card => {
if (card.id !== cardId || !card.work) return card
const next = { ...card, work: { ...card.work } }
if (actionId === 'work.read') next.work.readVersion = payload.resultVersion
else if (next.work.decision) next.work.decision = { ...next.work.decision,
state: actionId === 'work.defer' ? 'deferred' : actionId === 'work.reopen' ? 'open' : 'submitted',
...(actionId === 'work.submit' ? { selectedIds: payload.selectedIds, freeText: payload.freeText, respondedAt: stamp } : {}),
}
return next
})
body = { card: cards.find(card => card.id === cardId) }
} 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, workActions, 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 })
},
}
}
function optionalWork(id = 'optional-work', mode: 'single' | 'multiple' = 'single'): ActivityCard {
return work(id, 'done', { title: '说明书材料整理', sourceRunId: 'run-history',
work: { resultVersion: 2, readVersion: 0, completedAt: stamp,
result: '# 说明书重点\n\n已整理软件结构、角色权限与操作流程,原文件保留。\n\n'
+ '- **使用角色**:管理员、医生和患者\n- **软件结构**:患者端与医生端连接同一服务端\n- **核心内容**:安装维护、训练操作与安全说明\n\n'
+ '## 可继续整理的内容\n\n可以先阅读摘要了解全貌,也可以单独提取性能参数、设备要求与需要进一步确认的事项。'
+ '整理结果仅依据当前上传的说明书;缺少的材料和不能确认的内容会单独列出,不会补写成已确认的结论。'
+ '如果继续生成交付物,新的结果会更新到独立工作卡,您可以同时讨论其他话题。'
+ '每项结论都应保留材料范围和来源,便于后续复核。\n\n'
+ '## 后续核对\n\n在正式采用整理结果前,建议把产品版本、适用设备、使用人员与预期交付形式分别确认。'
+ '材料中直接写明的内容和整理时发现的问题会分开记录。对没有原文依据的数字、范围或归属,整理结果会保持待确认状态。'
+ '需要比较其他版本时,可补充相应文件,并说明希望重点比较的部分。\n\n最后一条:需要确认使用场景。',
decision: { mode, state: 'open', options: [{ id: 'summary', label: '生成摘要' }, { id: 'facts', label: '提取参数' }] },
},
})
}
test('keeps optional choices nonblocking and supports defer, reopen, free input and saved answers', async ({ page }) => {
const state = await fixture(page, [optionalWork('optional-work', 'multiple')])
const inline = page.locator('[data-conversation-work] [data-card-id="optional-work"]')
await expect(inline.getByRole('checkbox', { name: '生成摘要', exact: true })).toBeVisible()
await inline.getByRole('button', { name: '先不选择', exact: true }).click()
await expect(inline.getByText('已暂缓选择,您可以继续对话')).toBeVisible()
await expect(inline.getByRole('checkbox')).toHaveCount(0)
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('先聊一下本周的安排')
await page.getByRole('button', { name: '发送', exact: true }).click()
await expect.poll(() => state.sent?.text).toBe('先聊一下本周的安排')
expect(state.sent?.taskRef).toBeUndefined()
// Receiving the POST is earlier than rendering its legitimate foreground reply.
await expect(page.locator('.msg.assistant[data-msg-id="live:run1"]')).toBeVisible()
const assistantCount = await page.locator('.msg.assistant').count()
await inline.getByRole('button', { name: '继续选择', exact: true }).click()
await inline.getByRole('checkbox', { name: '生成摘要', exact: true }).check()
await inline.getByRole('checkbox', { name: '提取参数', exact: true }).check()
await inline.getByRole('textbox', { name: '补充要求或直接输入需求' }).fill('保留原文中的来源和限制')
await inline.getByRole('button', { name: '开始处理', exact: true }).click()
await expect(inline.getByText('已提交需求', { exact: true })).toBeVisible()
await expect(inline.getByText('保留原文中的来源和限制', { exact: true })).toBeVisible()
await expect(inline.getByRole('textbox')).toHaveCount(0)
expect(state.workActions.filter(action => action.actionId === 'work.submit')).toEqual([{
cardId: 'optional-work', actionId: 'work.submit', payload: { resultVersion: 2, selectedIds: ['summary', 'facts'], freeText: '保留原文中的来源和限制' },
}])
await expect(page.locator('.msg.assistant')).toHaveCount(assistantCount)
expect(state.errors).toEqual([])
})
test('opens full results in one place, preserves unread until explicit action and references a version in the composer', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1050 })
const state = await fixture(page, [optionalWork()])
await expect(page.locator('.work-result-notice')).toContainText('1 项工作有新结果')
await page.locator('.work-result-notice').click()
const card = page.locator('.right-panel [data-card-id="optional-work"]')
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
await expect(card.getByRole('radio', { name: '生成摘要', exact: true })).toBeVisible()
if (process.env.WORKBENCH_FLOW_SCREENSHOT) await page.screenshot({ path: process.env.WORKBENCH_FLOW_SCREENSHOT, fullPage: true })
await card.getByRole('button', { name: '查看完整结果', exact: true }).click()
await expect(card.getByText('最后一条:需要确认使用场景。', { exact: false })).toBeVisible()
expect(state.workActions.filter(action => action.actionId === 'work.read')).toHaveLength(0)
await card.getByRole('button', { name: '基于此结果继续', exact: true }).click()
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toContainText('第 2 版结果')
await expect.poll(() => state.workActions.filter(action => action.actionId === 'work.read').length).toBe(1)
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('解释一下需要确认的事项')
await page.getByRole('button', { name: '发送', exact: true }).click()
await expect.poll(() => state.sent?.taskRef).toEqual({ cardId: 'optional-work', resultVersion: 2 })
expect(state.sent?.text).toBe('解释一下需要确认的事项')
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toHaveCount(0)
await expect(page.locator('.msg.user time[datetime]')).toHaveCount(2)
await expect(page.locator('.msg.assistant time[datetime]')).toHaveCount(2)
expect(state.errors).toEqual([])
})
test('retains text, uploads and the result reference when mixed input is not supported', async ({ page }) => {
const state = await fixture(page, [optionalWork()])
await page.locator('.work-result-notice').click()
await page.getByRole('button', { name: '基于此结果继续', exact: true }).click()
await page.getByLabel('添加材料', { exact: true }).setInputFiles({ name: '补充材料.txt', mimeType: 'text/plain', buffer: Buffer.from('补充要求') })
const input = page.getByRole('textbox', { name: '工作目标或消息', exact: true })
await input.fill('结合补充材料解释重点')
await page.getByRole('button', { name: '发送', exact: true }).click()
await expect(input).toHaveValue('结合补充材料解释重点')
await expect(page.locator('.attach-chip')).toContainText('补充材料.txt')
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toBeVisible()
expect(state.sent).toBeUndefined()
})
test('keeps middle and right scrolling independent while retaining full execution output', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 800 })
const events: StreamEvent[] = [
{ seq: 1, type: 'thinking.delta', payload: { text: '已保存的核对思路' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
{ seq: 2, type: 'tool.call', payload: { callId: 'call-record', name: 'fs_read_file', arguments: '{"path":"说明书"}' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
{ seq: 3, type: 'tool.output', payload: { callId: 'call-record', text: '完整的文档读取记录' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
{ seq: 4, type: 'tool.result', payload: { callId: 'call-record', status: 'ok' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
]
const cards = [optionalWork(), ...Array.from({ length: 15 }, (_, index) => work(`active-${index}`, 'active', { title: `其他工作 ${index + 1}` }))]
const state = await fixture(page, cards, 14, { events, savedParts: [{ type: 'text', text: '继续讨论当前主题。\n\n'.repeat(100) }] })
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const right = page.locator('.right-panel [data-work-scroll]')
const chat = page.locator('.chat-body')
await right.evaluate(element => { element.scrollTop = 180 })
const rightTop = await right.evaluate(element => element.scrollTop)
await chat.evaluate(element => { element.scrollTop = 80; element.dispatchEvent(new Event('scroll', { bubbles: true })) })
expect(await right.evaluate(element => element.scrollTop)).toBe(rightTop)
const chatTop = await chat.evaluate(element => element.scrollTop)
await right.evaluate(element => { element.scrollTop = 240; element.dispatchEvent(new Event('scroll', { bubbles: true })) })
expect(await chat.evaluate(element => element.scrollTop)).toBe(chatTop)
await page.locator('.right-panel [data-card-id="optional-work"]').getByRole('button', { name: '查看完整执行记录', exact: true }).click()
const record = page.getByRole('region', { name: '说明书材料整理的执行记录' })
await expect(record.getByText('已保存的核对思路', { exact: true })).toBeVisible()
await record.getByRole('button', { name: /查看记录/ }).click()
await expect(record.getByText('完整的文档读取记录', { exact: true })).toBeVisible()
await expect(page.locator('.work-execution-record')).toHaveCount(1)
await record.getByRole('button', { name: '返回工作', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id="optional-work"]')).toBeVisible()
expect(state.errors).toEqual([])
})
test('folds read task history newest first and retains submitted choices after reload', async ({ page }) => {
const older = optionalWork('older')
const latest = optionalWork('latest')
older.title = '旧材料整理'; latest.title = '新材料整理'
older.work = { ...older.work!, readVersion: 2, decision: { mode: 'single', state: 'submitted', selectedIds: ['summary'], freeText: '保留参数表', respondedAt: stamp,
options: [{ id: 'summary', label: '生成摘要' }] } }
latest.work = { ...latest.work!, readVersion: 2, decision: { mode: 'single', state: 'deferred' } }
latest.updatedAt = '2026-10-13T10:00:00Z'
const state = await fixture(page, [older, latest])
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(0)
await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(2)
expect(await page.locator('.right-panel [data-card-id]').evaluateAll(elements => elements.map(element => element.getAttribute('data-card-id')))).toEqual(['latest', 'older'])
await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible()
await page.reload()
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click()
await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible()
await expect(page.locator('.right-panel [data-card-id="older"]').getByRole('textbox')).toHaveCount(0)
expect(state.errors).toEqual([])
})
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('places the full work card only in the desktop work pane', 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]')).toHaveCount(0)
await expect(page.locator('.companion-topbar').getByRole('button', { name: '打开工作' })).toHaveCount(0)
await expect(page.locator('.right-panel').getByRole('button', { name: /工作/ }).first()).toBeVisible()
if (process.env.WORK_COMPANION_SCREENSHOT) await page.screenshot({ path: process.env.WORK_COMPANION_SCREENSHOT, fullPage: true })
expect(state.errors).toEqual([])
})
test('uses a focused reading column and a compact authored-message shape', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 })
const state = await fixture(page)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const conversation = page.locator('.chat-body .msg').first()
const authored = page.locator('.chat-body .msg.user').first()
const answer = page.locator('.chat-body .msg.assistant').first()
const [conversationBox, authoredBox, answerBox] = await Promise.all([
conversation.boundingBox(), authored.boundingBox(), answer.boundingBox(),
])
expect(conversationBox).not.toBeNull()
expect(authoredBox).not.toBeNull()
expect(answerBox).not.toBeNull()
expect(conversationBox!.width).toBeLessThanOrEqual(840)
expect(authoredBox!.width).toBeLessThanOrEqual(680)
expect(authoredBox!.x + authoredBox!.width).toBeCloseTo(answerBox!.x + answerBox!.width, 0)
const surfaces = await page.evaluate(() => {
const pick = (selector: string) => {
const element = document.querySelector(selector)
if (!element) throw new Error(`missing ${selector}`)
const style = getComputedStyle(element)
return {
background: style.backgroundColor,
color: style.color,
borderRadius: Number.parseFloat(style.borderRadius),
paddingLeft: Number.parseFloat(style.paddingLeft),
}
}
return {
chrome: pick('.companion-topbar'),
context: pick('.left-nav'),
conversation: pick('.main'),
answer: pick('.msg.assistant'),
authored: pick('.msg.user .bubble'),
}
})
const rgb = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0]
const distance = (a: string, b: string) => rgb(a).reduce((sum, value, index) => sum + Math.abs(value - rgb(b)[index]), 0)
expect(distance(surfaces.chrome.background, surfaces.conversation.background)).toBeGreaterThan(300)
expect(distance(surfaces.context.background, surfaces.conversation.background)).toBeGreaterThan(300)
expect(distance(surfaces.authored.background, surfaces.answer.background)).toBeGreaterThan(300)
expect(surfaces.answer.borderRadius).toBeGreaterThanOrEqual(12)
expect(surfaces.answer.paddingLeft).toBeGreaterThanOrEqual(20)
const activeCard = page.locator('.right-panel .acard.status-active')
await expect(activeCard).toBeVisible()
const cardStyle = await activeCard.evaluate(element => {
const style = getComputedStyle(element)
return { background: style.backgroundColor, borderLeftWidth: style.borderLeftWidth }
})
const panelBackground = await page.locator('.right-panel').evaluate(element => getComputedStyle(element).backgroundColor)
expect(cardStyle.background).not.toBe(panelBackground)
expect(Number.parseFloat(cardStyle.borderLeftWidth)).toBeGreaterThanOrEqual(3)
expect(state.errors).toEqual([])
})
for (const theme of ['light', 'dark', 'molv'] as const) {
test(`keeps the workbench frame legible in ${theme}`, async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 })
const state = await fixture(page, [progress], 14, undefined, theme)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const surfaces = await page.evaluate(() => ['.companion-topbar', '.left-nav', '.msg.user .bubble', '.msg.assistant'].map(selector => {
const element = document.querySelector(selector)
if (!element) throw new Error(`missing ${selector}`)
const style = getComputedStyle(element)
return { selector, foreground: style.color, background: style.backgroundColor }
}))
const channels = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0]
const luminance = (value: string) => {
const linear = channels(value).map(channel => {
const normalized = channel / 255
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
})
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
}
for (const surface of surfaces) {
const lighter = Math.max(luminance(surface.foreground), luminance(surface.background))
const darker = Math.min(luminance(surface.foreground), luminance(surface.background))
expect((lighter + 0.05) / (darker + 0.05), `${surface.selector} contrast in ${theme}`).toBeGreaterThanOrEqual(4.5)
}
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('keeps one confirmation action location when the work pane is visible', async ({ page }) => {
const state = await fixture(page, [confirmation])
await page.getByRole('button', { name: '打开工作', exact: true }).click()
state.hold()
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
await page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true }).click()
await expect(page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true })).toBeDisabled()
await expect(page.locator('.chat-body').getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
expect(state.actionCount).toBe(1)
state.release()
await expect(page.getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
expect(state.errors).toEqual([])
})
test('keeps each run process folded and reloadable after the final reply', async ({ page }) => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent =>
({ seq, type, payload, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 })
const events = [
event(1, 'thinking.delta', { index: 0, text: '先核对日期' }),
event(2, 'message.delta', { index: 0, text: '开始读取材料' }),
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
event(4, 'tool.output', { callId: 'call-1', text: '完整工具输出' }),
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
event(6, 'run.phase', { phase: 'finalizing' }),
event(7, 'message.delta', { index: 1, text: '最终回复已完成' }),
]
const state = await fixture(page, [], 14, { events })
const message = page.locator('[data-msg-id="m4"]')
await expect(message.getByText('最终回复已完成')).toBeVisible()
await expect(message.getByText('开始读取材料')).toHaveCount(0)
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('先核对日期')).toBeVisible()
await expect(message.getByText('开始读取材料')).toBeVisible()
await message.locator('.tool-block-head').click()
await expect(message.getByText('完整工具输出')).toBeVisible()
await page.reload()
const restored = page.locator('[data-msg-id="m4"]')
await expect(restored.getByText('最终回复已完成')).toBeVisible()
await restored.getByRole('button', { name: /执行记录/ }).click()
await expect(restored.getByText('先核对日期')).toBeVisible()
await restored.locator('.tool-block-head').click()
await expect(restored.getByText('完整工具输出')).toBeVisible()
expect(state.errors).toEqual([])
})
test('replay enriches saved notes and keeps collaboration progress after completion', async ({ page }) => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
const events = [
event(1, 'message.delta', { index: 0, text: '<think>已解析的思考</think>' }),
event(2, 'message.delta', { index: 1, text: '开始读取' }),
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
event(4, 'tool.output', { callId: 'call-1', text: '完整输出留存' }),
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
event(6, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(7, 'run.phase', { phase: 'finalizing' }),
event(8, 'message.delta', { index: 1, text: '最终回复已完成' }),
event(9, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
event(10, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
]
const savedParts: Part[] = [
{ type: 'text', process: true, text: '模型在输出前失败,正在重试' },
{ type: 'thinking', process: true, text: '已解析的思考' },
{ type: 'text', process: true, text: '开始读取材料' },
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell' } },
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } },
{ type: 'text', text: '最终回复已完成' },
]
await fixture(page, [], 14, { events, savedParts })
const message = page.locator('[data-msg-id="m4"]')
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('模型在输出前失败,正在重试')).toBeVisible()
await expect(message.getByText('已解析的思考')).toBeVisible()
await expect(message.getByText('开始读取材料')).toBeVisible()
await expect(message.getByText('开始读取', { exact: true })).toHaveCount(0)
await expect(message.getByText('协作进展:正在核对附录')).toBeVisible()
await expect(message.getByText('协作结束:附录已核对')).toBeVisible()
await expect(message.getByText('<think>已解析的思考</think>')).toHaveCount(0)
await message.locator('.tool-block-head').click()
await expect(message.getByText('完整输出留存')).toBeVisible()
})
test('keeps a child completion note when the final reply uses the same status words', async ({ page }) => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
await fixture(page, [], 14, { events: [
event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(2, 'run.phase', { phase: 'finalizing' }),
event(3, 'message.delta', { index: 0, text: '已完成' }),
event(4, 'agent.result', { agentRun: 'child', status: 'completed', summary: '已完成' }, 'child'),
], savedParts: [{ type: 'text', process: true, text: '记录已完成' }, { type: 'text', text: '已完成' }] })
const message = page.locator('[data-msg-id="m4"]')
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('协作结束:已完成')).toBeVisible()
})
test('locates a completed card even when work is filtered to project pending items', async ({ page }) => {
const result = work('result1', 'done', { kind: 'result', title: '材料核对结果',
body: [{ type: 'markdown', markdown: '完整结论在这里' }] })
await fixture(page, [result], 14, { events: [], cardRef: 'result1' })
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const workPane = page.locator('.right-panel')
await workPane.locator('.inbox-scope').getByRole('button', { name: '项目' }).click()
await workPane.locator('.inbox-scope').getByRole('button', { name: '待处理' }).click()
await expect(workPane.getByText('完整结论在这里')).toHaveCount(0)
await page.locator('[data-msg-id="m4"] .card-anchor-locate').click()
await expect(workPane.getByText('完整结论在这里')).toBeVisible()
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '本会话' })).toHaveClass(/active/)
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '全部' })).toHaveClass(/active/)
})
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([])
})
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
"exports": 43
"exports": 51
}
+109 -57
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,38 +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).catch(() => undefined); 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 回登录页,改走密码登录完成改密。
// 已持有有效会话 cookie(或密钥)时直接放行。设密门依据 sessionScope 判定
//(2026-10-13 合同):恢复 Key 兑换与 must-change 首登都建立
// set_password_only 受限会话,仅看 mustChangePassword 会漏掉已有密码的
// 恢复会话。改密接口只认会话登录:API key 凭证进改密门是死路(提交必
// 401),key 持有者清 key 回登录页,改走密码登录完成改密。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) {
const gated = me?.sessionScope === 'set_password_only' || me?.mustChangePassword
if (gated) {
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
}
@@ -71,10 +114,14 @@ export function App() {
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
store.refreshMe()
store.refreshProjects()
store.refreshProfiles()
store.refreshPlugins()
let active = true
store.refreshMe().then(() => {
if (!active) return
void store.refreshProjects()
void store.refreshProfiles()
void store.refreshPlugins()
})
return () => { active = false }
}, [authState])
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
@@ -82,10 +129,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,
@@ -115,15 +162,29 @@ 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')}
onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} />
}
if (authState === 'mustchange') {
// 受限会话设密成功后服务端销毁全部会话并返回 reloginRequired=true:
// 唯一后继动作是回密码登录页,不得因历史 onDone 误放行进工作台。
return <ChangePasswordGate
owner={mustChangeOwner}
onDone={async () => { await store.refreshMe().catch(() => undefined); setAuthState('ok') }}
onDone={() => { api.logout().catch(() => undefined); setAuthState('login') }}
onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} />
}
if (authState !== 'ok') {
@@ -132,22 +193,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 => (
@@ -155,31 +214,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 />
+111 -18
View File
@@ -1,10 +1,11 @@
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
import { auth } from './auth'
import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
ActivityCard,
ActivityCard, SurfaceMeta, SurfaceSnapshot, WorkCardAction,
} from './contracts'
const BASE = ''
@@ -14,11 +15,47 @@ const BASE = ''
* 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
async function json<T>(res: Response): Promise<T> {
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
* current_required 必须校验当前密码。 */
export type PasswordChangeMode = 'grant_setup' | 'current_required'
/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */
export interface RecoveryProposal {
id: string; userId: string; owner: string
status: 'pending' | 'confirmed' | 'completed' | 'revoked' | 'expired'
version: number; generation: number; createdBy: string
createdAt: string; expiresAt: string
}
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
export interface AccountRecord {
userId: string; owner: string; profile: AccountProfile; profileVersion: number
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean
initializationState?: string; initializationGeneration?: number
}
export interface ExecEnvView {
configured: boolean
root?: string
python?: string
base?: string
layers?: string[]
requests?: { id: string; conversationId: string; owner: string; spec: string; reason: string; status: string; layerId: string; detail: string; approver: string }[]
bindings?: { conversationId: string; baseId: string; extensions: string[] | null; isolated: string }[]
}
async function checked(res: Response): Promise<Response> {
if (!res.ok) {
const text = await res.text()
throw new Error(`${res.status}: ${text}`)
}
return res
}
async function json<T>(res: Response): Promise<T> {
await checked(res)
return res.json() as Promise<T>
}
@@ -26,7 +63,7 @@ export const api = {
listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)),
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }),
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
@@ -35,25 +72,67 @@ 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)),
/** 会话内页面目录:扁平 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<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; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(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; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: 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()}`)
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
auth.beginSession()
return me
}),
logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }),
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
auth.beginSession()
return me
}),
logout: () => {
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
auth.clear()
return request
},
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
}),
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)),
createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
resetAdminUserPassword: (owner: string, password: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
// 建号只创建待初始化账号(无密码、无权限位);初始凭证由服务器本地 CLI 发放激活 Key。
createAdminUser: (owner: string, name: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name } }) }).then(r => json<{ owner: string; initializationState: string }>(r)),
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
// ---- 管理员:恢复提案(属主确认制,替换已 410 的直接签发 Key 入口)----
createAdminRecoveryProposal: (userId: string, expectedStateVersion: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, idempotencyKey }) }).then(r => json<RecoveryProposal>(r)),
listAdminRecoveryProposals: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
adminRevokeRecoveryProposal: (userId: string, id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<RecoveryProposal>(r)),
// ---- 本人:资料与恢复提案确认(须完整密码会话)----
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
listMyRecoveryProposals: () => auth.fetch(`${BASE}/api/me/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
getMyRecoveryProposal: (id: string) => auth.fetch(`${BASE}/api/me/recovery-proposals/${encodeURIComponent(id)}`).then(r => json<RecoveryProposal>(r)),
confirmMyRecoveryProposal: (id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
rotateMyRecoveryGrant: (id: string, expectedVersion: number, expectedGeneration: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/rotate-grant`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion, expectedGeneration }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
adminExecEnvGC: () => auth.fetch(`${BASE}/api/admin/exec-env/gc`, { method: 'POST' }).then(r => json<{ removed: string[] | null }>(r)),
adminExecEnvWheel: (file: File, conversationId: string, reason: string) => {
const body = new FormData()
body.set('wheel', file)
body.set('conversationId', conversationId)
body.set('reason', reason)
return auth.fetch(`${BASE}/api/admin/exec-env/wheels`, { method: 'POST', body }).then(r => json<{ file: string; outcome: { status: string; requestId?: string; detail?: string } }>(r))
},
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
@@ -61,8 +140,13 @@ export const api = {
conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r =>
json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) =>
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId }) })
runEventsPage: (convId: string, runId: string, after: number, signal?: AbortSignal) =>
auth.fetch(`${BASE}/api/conversations/${encodeURIComponent(convId)}/runs/${encodeURIComponent(runId)}/events?after=${after}&limit=500`, { signal })
.then(r => json<{ events: StreamEvent[] }>(r)),
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string, taskRef?: { cardId: string; resultVersion: number }) =>
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId,
...(taskRef ? { taskRef: { cardId: taskRef.cardId, resultVersion: taskRef.resultVersion } } : {}) }) })
.then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ message: Message; run?: Run; command?: string; reply?: string; conversation?: Conversation }>
@@ -76,8 +160,8 @@ export const api = {
listCards: (conversationId: string) =>
auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
cardAction: (cardId: string, actionId: string, freeText?: string) =>
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ freeText }) })
cardAction: (cardId: string, actionId: string, payload?: string | WorkCardAction) =>
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(typeof payload === 'object' ? payload : { freeText: payload }) })
.then(r => json<{ card: ActivityCard; receipt?: Message }>(r)),
cardPin: (cardId: string, pinned: boolean) =>
auth.fetch(`${BASE}/api/cards/${cardId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }).then(r => json<{ card: ActivityCard }>(r)),
@@ -114,7 +198,11 @@ export const api = {
getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + projectId : ''}`).then(r =>
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown> }>(r)),
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
putProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
putProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked),
// P2 系统管理面:受管项(models/tavily/telemetry/运行基础设施)的唯一写入面。
getSystemSettings: () => auth.fetch(`${BASE}/api/settings/system`).then(r =>
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; system: Record<string, unknown> }>(r)),
putSystemSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/system`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
@@ -139,6 +227,11 @@ export const api = {
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
rdmProjectCommand: async <T = any>(project: Project, id: string, params: Record<string, unknown> = {}): Promise<T> => {
const error = rdmProjectCommandError(project, id)
if (error) throw new Error(error)
return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) })
},
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' },
+14 -3
View File
@@ -17,11 +17,22 @@ function pickStorage(): KV {
}
const storage = pickStorage()
const identityListeners = new Set<() => void>()
function identityChanged() { identityListeners.forEach(listener => listener()) }
export const auth = {
apiKey(): string { return storage.getItem(KEY) ?? '' },
setKey(key: string) { storage.setItem(KEY, key.trim()) },
clear() { storage.removeItem(KEY) },
setKey(key: string) {
const next = key.trim()
if (next === this.apiKey()) return
storage.setItem(KEY, next)
identityChanged()
},
clear() { storage.removeItem(KEY); identityChanged() },
onIdentityChange(listener: () => void) {
identityListeners.add(listener)
return () => { identityListeners.delete(listener) }
},
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
beginSession() { this.clear() },
@@ -33,7 +44,7 @@ export const auth = {
headers.set('Authorization', `Bearer ${key}`)
return fetch(input, { ...init, headers })
},
// 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。
// 退出登录:立即清客户端投影并回登录门;调用者先经 api.logout 注销服务端会话。
logout() {
this.clear()
try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ }
+184
View File
@@ -0,0 +1,184 @@
.work-companion-chat .chat-header {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
min-height: 64px;
padding: 12px 24px;
background: var(--bg-panel);
}
.work-companion-chat .chat-title { font-size: var(--fs-ui-xl); font-weight: 650; letter-spacing: -0.025em; }
.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 {
display: flex;
flex-direction: column;
align-items: center;
padding: 36px clamp(20px, 6vw, 96px) 48px;
scroll-padding-block: 36px;
}
.work-companion-chat .chat-body > * {
width: min(100%, 840px);
margin-left: auto;
margin-right: auto;
}
.work-companion-chat .chat-body > .msg { margin-bottom: 32px; }
.work-companion-chat .chat-body > .msg.user {
width: min(80%, 680px);
margin-right: max(0px, calc((100% - 840px) / 2));
margin-left: auto;
}
.work-companion-chat .chat-body > .msg.assistant {
padding: 20px 24px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--bg-raised);
box-shadow: var(--shadow-md);
}
.work-companion-chat .msg-role {
margin-bottom: 10px;
color: var(--text-dim);
font-size: var(--fs-ui-caption);
font-weight: 600;
}
.work-companion-chat .msg.user .msg-role { justify-content: flex-end; }
.message-timestamp { color: var(--text-dim); font-size: var(--fs-ui-sm); font-weight: 400; white-space: nowrap; }
.work-companion-chat .msg.assistant .message-timestamp { margin-left: auto; }
.work-result-notice, .composer-work-reference { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; box-sizing: border-box; padding: 8px 12px; margin-bottom: 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-raised); color: var(--text-dim); font-size: var(--fs-ui-caption); text-align: left; }
.work-result-notice { cursor: pointer; }
.work-result-notice:hover { background: var(--bg-hover); color: var(--text); }
.work-result-notice > span:last-child { margin-left: auto; color: var(--accent-text); }
.composer-work-reference > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.work-execution-record { min-width: 0; padding: 16px; color: var(--text); background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); }
.work-record-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.work-record-heading strong { min-width: 0; overflow-wrap: anywhere; font-size: var(--fs-ui-base); }
.work-record-heading .btn { flex-shrink: 0; font-size: var(--fs-ui-caption); }
.work-execution-record .tl { margin-top: 16px; }
.work-execution-record .part-text { overflow-wrap: anywhere; }
.work-companion-chat .msg.assistant .role-mark {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-foreground);
}
.work-companion-chat .msg.assistant .part-text { color: var(--text); font-size: var(--fs-ui-base); line-height: 1.8; }
.work-companion-chat .input-bar {
padding: 12px clamp(20px, 6vw, 96px) 20px;
border-top: 1px solid var(--border-soft);
background: var(--bg-panel);
}
.work-companion-chat .input-bar > * { width: min(100%, 840px); margin-left: auto; margin-right: auto; }
.work-companion-chat .input-row {
flex-direction: column;
align-items: stretch;
padding: 14px;
border-color: var(--border-strong);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
}
.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; color: var(--text-dim); }
.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: 320px; height: 100%; text-align: center; line-height: 1.7; }
.companion-empty .es-mark { width: 52px; height: 52px; margin-bottom: 8px; background: var(--bg-panel); box-shadow: var(--shadow-md); }
.companion-empty-title { color: var(--text); font-size: var(--fs-ui-xl); font-weight: 650; letter-spacing: -0.02em; }
.companion-examples { display: grid; gap: 8px; width: min(100%, 32rem); margin-top: 12px; }
.companion-example { padding: 14px 16px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); color: var(--text-dim); font: inherit; text-align: left; cursor: pointer; }
.companion-example:hover { color: var(--text); background: var(--bg-raised); border-color: var(--border-strong); }
.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-size: var(--fs-ui-lg); font-weight: 650; }
.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; }
.conversation-action-anchor { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; width: 100%; margin-top: 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel); color: var(--text); text-align: left; font-size: var(--fs-ui-caption); cursor: pointer; }
.conversation-action-anchor > span:last-child { color: var(--accent-text); }
.conversation-action-anchor:hover { background: var(--bg-hover); }
.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; }
.work-companion-chat .chat-body { padding: 24px 16px 32px; }
.work-companion-chat .chat-body > .msg.user { width: 92%; margin-right: 0; }
.work-companion-chat .input-bar { padding: 10px 12px 14px; }
.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 {
padding: 14px 18px;
background: var(--primary);
border: 1px solid var(--primary);
border-radius: var(--radius-lg) var(--radius-lg) var(--radius-xs) var(--radius-lg);
color: var(--primary-foreground);
line-height: 1.7;
box-shadow: var(--shadow-md);
}
.work-companion-chat .process-collapse {
margin: 12px 0 16px;
border-color: var(--border);
background: var(--bg-panel);
}
.work-companion-chat .process-collapse .process-collapse-summary { padding: 9px 14px; color: var(--text-dim); }
.work-companion-chat .process-collapse .process-collapse-summary:hover { background: var(--bg-hover); color: var(--text); }
.work-companion-chat .process-collapse .body { padding: 8px 14px 14px; border-top: 1px solid var(--border-soft); }
.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;
opacity: 0;
}
.work-companion-chat .msg:hover .companion-message-actions,
.work-companion-chat .msg:focus-within .companion-message-actions { opacity: 1; }
.work-companion-chat .msg-tools { gap: 4px; }
@media (hover: none) {
.companion-message-actions { opacity: 1; }
}
+225
View File
@@ -0,0 +1,225 @@
/* 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,
.companion-app .companion-topbar {
--text: var(--workbench-chrome-text);
--text-dim: var(--workbench-chrome-dim);
--text-faint: var(--workbench-chrome-faint);
--bg-hover: var(--workbench-chrome-hover);
--bg-active: var(--workbench-chrome-active);
--border: var(--workbench-chrome-active);
--border-soft: var(--workbench-chrome-hover);
--border-strong: var(--workbench-chrome-dim);
background: var(--workbench-chrome);
color: var(--workbench-chrome-text);
border-color: var(--workbench-chrome-hover);
}
.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: 52px;
gap: 12px;
padding: 8px 16px;
box-shadow: var(--shadow-md);
}
.topbar-identity { display: flex; align-items: center; gap: 8px; min-width: 0; }
.companion-topbar .topbar-name { font-size: var(--fs-ui-lg); font-weight: 650; letter-spacing: -0.02em; white-space: nowrap; }
.companion-app .right-panel .tabs {
min-height: 52px;
align-items: flex-end;
gap: 16px;
padding: 0 20px;
background: var(--bg-panel);
}
.companion-app .right-panel .tab {
min-height: 52px;
padding: 0 2px;
color: var(--text-dim);
font-size: var(--fs-ui-base);
font-weight: 600;
}
.companion-app .right-panel .tab:hover { color: var(--text); }
.companion-app .right-panel .tab-body { padding: 20px; }
.companion-app .right-panel .inbox-zone-label { margin: 20px 0 8px; color: var(--text); font-size: var(--fs-ui-caption); font-weight: 650; }
.companion-app .right-panel .acard { margin: 12px 0; border-color: var(--border); padding: 18px; }
.companion-app .right-panel .acard.status-active {
border-left: 3px solid var(--work-focus-border);
background: var(--work-focus-surface);
box-shadow: var(--shadow-md);
}
.companion-app .right-panel .acard-title { color: var(--text); font-size: var(--fs-ui-lg); line-height: 1.4; }
.companion-app .right-panel .acard-status {
display: inline-flex;
width: fit-content;
margin-top: 8px;
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--bg-active);
color: var(--text);
font-weight: 600;
}
.companion-app .right-panel .acard-body { margin-top: 16px; }
.companion-app .right-panel .acard-summary-row { grid-template-columns: minmax(64px, auto) minmax(0, 1fr); gap: 12px; margin: 12px 0; }
.companion-app .right-panel .acard-summary-row > span,
.companion-app .right-panel .acard-summary-label { color: var(--text-dim); font-weight: 600; }
.companion-app .right-panel .acard-summary-row p,
.companion-app .right-panel .acard-findings li { color: var(--text); font-size: var(--fs-ui-base); }
.companion-topbar .brand-mark {
background: var(--workbench-chrome-text);
color: var(--workbench-chrome);
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(--workbench-chrome-dim);
font-size: var(--fs-ui-caption);
cursor: pointer;
white-space: nowrap;
}
.shell-nav-button:hover,
.shell-return:hover { background: var(--workbench-chrome-hover); color: var(--workbench-chrome-text); }
.shell-nav-button[aria-pressed='true'] { background: var(--workbench-chrome-active); color: var(--workbench-chrome-text); }
.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(--workbench-chrome-active);
color: var(--workbench-chrome-text);
font-size: var(--fs-ui-xs);
font-variant-numeric: tabular-nums;
}
.companion-app .tsb-brand { gap: 8px; padding: 16px 14px 12px; border-bottom: 1px solid var(--border-soft); }
.companion-app .tsb-projrow { min-height: 40px; height: auto; border-color: transparent; background: none; padding: 6px 10px; }
.companion-app .tsb-projrow:hover { background: var(--bg-hover); }
.companion-app .tsb-projrow-name { flex: 1; font-size: var(--fs-ui-base); font-weight: 650; }
.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: 12px 14px; gap: 10px; }
.companion-app .tsb-newtask {
min-height: 40px;
height: auto;
background: var(--workbench-chrome-text);
color: var(--workbench-chrome);
box-shadow: none;
transition: none;
}
.companion-app .tsb-newtask:hover { background: var(--workbench-chrome-text); }
.companion-app .tsb-newtask-main { justify-content: center; padding: 8px 12px; }
.companion-app .tsb-newtask-caret { width: 36px; flex: none; border-left-color: var(--workbench-chrome-inverse-border); }
.companion-app .tsb-newtask-caret:hover { background: var(--workbench-chrome-inverse-hover); }
.companion-app .tsb-search { min-height: 38px; height: auto; padding: 8px 12px; background: var(--workbench-chrome-hover); border-color: var(--workbench-chrome-hover); }
.companion-app .tsb-search:focus-within { background: var(--workbench-chrome-active); border-color: var(--workbench-chrome-dim); }
.companion-app .tsb-orgrow { padding: 4px 16px 8px; }
.companion-app .tsb-seclabel { color: var(--text-dim); font-size: var(--fs-ui-sm); font-weight: 600; }
.companion-app .tsb-scroll { padding: 0 8px 12px; }
.companion-app .tsb-task { min-height: 42px; height: auto; gap: 6px; padding: 0 10px; transition: none; }
.companion-app .tsb-task.active { background: var(--bg-active); color: var(--text); }
.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-t { font-size: var(--fs-ui-base); }
.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: 10px 12px; gap: 4px; background: var(--workbench-chrome-hover); }
.companion-app .tsb-tool { min-height: 38px; padding: 7px 10px; }
.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-footer { padding: 10px 12px; }
.companion-app .tsb-user { min-height: 46px; height: auto; }
.companion-app .right-panel .inbox-scope {
gap: 4px;
margin: 14px 20px 0;
padding: 4px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: var(--bg);
}
.companion-app .right-panel .inbox-scope button {
flex: 1;
min-width: 0;
border: none;
border-radius: var(--radius-sm);
padding: 5px 10px;
opacity: 1;
color: var(--text-dim);
font-size: var(--fs-ui-sm);
white-space: nowrap;
}
.companion-app .right-panel .inbox-scope button.active { background: var(--bg-raised); box-shadow: var(--shadow-md); color: var(--text); }
.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 12px; }
.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; }
+10 -2
View File
@@ -7,8 +7,10 @@
import { useState } from 'react'
import { store, useStore } from '../store'
import { EmptyState, Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
import { ExecEnvPanel } from './ExecEnvPanel'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import { RdmRolesPanel } from './adminRdmSections'
import type { AdminZoneSection } from '../api'
interface AdminSectionDef {
@@ -27,8 +29,13 @@ 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 /> },
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => <SystemSettingsPanel /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', render: () => <ExecEnvPanel /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
]
@@ -57,13 +64,14 @@ function DeclaredSection({ section }: { section: AdminZoneSection }) {
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')
const isAdmin = !!me?.isAdmin
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
if (!isAdmin && declared.length === 0) return null
const sections: AdminSectionDef[] = [
...(isAdmin ? SECTIONS : []),
...(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 || '插件声明的管理分区(区内数据能力由插件命令面判定)',
+10 -6
View File
@@ -7,6 +7,7 @@ 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)
@@ -42,20 +43,23 @@ export function ArtifactsPanel() {
{surfaces.length > 0 && (
<div className="ap-surfaces" data-ap-surfaces>
<div className="ap-section-title">生成页面</div>
{surfaces.map(sv => (
<div key={String(sv.identity.surfaceId)} className="artifact-row" data-surface-row={String(sv.identity.surfaceId)}
onClick={() => store.openSurface(String(sv.identity.surfaceId))} title={`打开页面 ${sv.spec?.title ?? ''}`}>
{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">{sv.spec?.title || '(未命名页面)'}</span>
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity.pluginId}</span>
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity?.pluginId}</span>
</span>
</div>
))}
)
})}
</div>
)}
{artifacts.length === 0
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
? <div className="empty-note">暂无文件,可添加材料或在对话中生成文档</div>
: visible.length === 0
? <div className="empty-note">没有匹配「{q}」的文件</div>
: groups.map(g => g.versions.length === 1
+254
View File
@@ -0,0 +1,254 @@
// 文档驾驶舱 I3:阅读器(连续正文 + 版本摘要/差异折叠)与右侧依据/影响面板。
// 消费 I1/I3 命令面 rdm.cockpit.node(+versions)/node.history(Dev 仓 docs/superpowers
// specs 2026-10-16 view-spec §5–§6)。不新增第四栏:右栏是选中节点的同一依据面,
// 全景与阅读器共享 selectedNodeRef;面板刷新原位进行,不抢占滚动位置(DV-R05/§5)。
// 折叠只用 @ui Collapsible;颜色/字号只消费 @ui/tokens.css。
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { Collapsible, EmptyState } from '@ui'
import {
assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary,
PANEL_TABS, type CockpitContentBlock, type CockpitHistorySummary,
type CockpitMeta, type CockpitNodeRef, type CockpitTreeNode,
type CockpitVersionSummary, type Density, type PanelTabId,
} from '../documentCockpit'
export interface ReaderSelection { ref: CockpitNodeRef; parentDocId?: string }
interface NodeDetailShape {
meta: CockpitMeta
node: CockpitTreeNode
body?: CockpitContentBlock[]
children: CockpitTreeNode[]
sources: { items: any[] }
history: { items: CockpitHistorySummary[]; hasMore: boolean; nextCursor?: string }
impacts: { items: any[] }
conflicts: { items: any[] }
versions?: CockpitVersionSummary[]
}
function useNodeDetail(pid: string, sel?: CockpitNodeRef) {
const [detail, setDetail] = useState<NodeDetailShape | null>(null)
const [err, setErr] = useState('')
const gen = useRef(0)
useEffect(() => {
const g = ++gen.current
setDetail(null); setErr('')
if (!sel) return
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: sel.kind, id: sel.id })
.then(d => { if (g === gen.current) setDetail(d) })
.catch(e => { if (g === gen.current) setErr(e.message) })
return () => { gen.current++ }
}, [pid, sel?.kind, sel?.id])
return { detail, err }
}
/** 版本块:当前版本 + 前一版本摘要;完整差异(行级标记)折叠。受限只给元数据。 */
export function VersionBlock({ versions, density }: { versions?: CockpitVersionSummary[]; density: Density }) {
if (!versions || versions.length === 0) return null
const { current, previous, redacted } = versionSummary(versions)
if (redacted || !current || current.content === undefined) {
return <div className="empty-note">版本受限(仅元数据可见)</div>
}
const prev = previous && previous.content !== undefined ? previous : undefined
return (
<Collapsible
id={`cockpit-versions-${current.version}`}
trigger={<span className="cockpit-versions-toggle">版本 {current.version}{previous ? `(前一版 ${previous.version})` : '(首版)'}与完整差异</span>}
>
<div className={`cockpit-versions cockpit-density-${density}`}>
{prev ? (
<>
<div className="cockpit-versions-head">当前 {current.version} · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
<pre className="cockpit-diff">
{diffLines(current.content || '', prev.content || '').map((l, i) => (
<div key={i} className={`cockpit-diff-${l.flag === '=' ? 'same' : l.flag === '+' ? 'add' : 'del'}`}>{l.flag} {l.text}</div>
))}
</pre>
<div className="cockpit-versions-head">前一版 {prev.version} · {prev.actor || '未知'} · {new Date(prev.at).toLocaleString('zh-CN')}</div>
</>
) : (
<div className="cockpit-versions-head">首版 · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
)}
</div>
</Collapsible>
)
}
/** 阅读器中栏:选中章节=该节连续正文+版本块;选中文档=全章节连续装配(每节详情按需拉取)。 */
export function CockpitReader({ pid, selection, sections, density, onBack }: {
pid: string
selection: ReaderSelection
sections: CockpitTreeNode[]
density: Density
onBack: () => void
}) {
const { detail, err } = useNodeDetail(pid, selection.ref)
const isSection = selection.ref.kind === 'section'
const [sectionDetails, setSectionDetails] = useState<Record<string, NodeDetailShape | undefined>>({})
const gen = useRef(0)
// 文档阅读器:按树顺序装配全部章节详情(上限 20;失败章节给受限占位不拖垮整篇)。
useEffect(() => {
if (isSection || selection.ref.kind !== 'document') return
const g = ++gen.current
const list = sections.slice(0, 20)
setSectionDetails({})
for (const sec of list) {
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: 'section', id: sec.ref.id })
.then(d => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: d })) })
.catch(() => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: undefined })) })
}
return () => { gen.current++ }
}, [pid, isSection, selection.ref.id, sections])
const anchorRef = useRef<HTMLDivElement | null>(null)
useEffect(() => { anchorRef.current?.scrollIntoView({ block: 'start' }) }, [selection.ref.id])
if (err) return <EmptyState icon="alert">阅读器加载失败:{err}</EmptyState>
return (
<div className={`cockpit-reader cockpit-density-${density}`}>
<div className="cockpit-nodehead" ref={anchorRef}>
<button type="button" className="cockpit-back" onClick={onBack}>← 返回全景</button>
<span className="cockpit-nodetitle">{detail?.node?.title || (isSection ? '章节' : '文档')}</span>
</div>
{isSection && detail && (
<>
{renderBody(detail.body, detail.meta)}
<VersionBlock versions={detail.versions} density={density} />
</>
)}
{!isSection && (
<>
{sections.length === 0 && <div className="empty-note">该文档暂无章节</div>}
{assembleReader(sections, sectionDetails).map(sec => (
<section key={sec.ref.id} id={`sec-${sec.ref.id}`} className="cockpit-reader-section">
<div className="cockpit-reader-sechead">
<span className="cockpit-reader-sectitle">{sec.title}</span>
{sec.blocks[0]?.kind === 'restricted'
? <span className="cockpit-badge">内容受限({sec.blocks[0].reason || '权限'})</span>
: sec.contentState === 'empty' && <span className="cockpit-badge">空</span>}
</div>
{sec.blocks.map((b, i) => b.kind === 'text'
? <p key={i} className="cockpit-reader-para">{b.text}</p>
: <p key={i} className="empty-note">内容受限——标题与状态可见,正文不展示</p>)}
<VersionBlock versions={sec.versions} density={density} />
</section>
))}
</>
)}
</div>
)
}
function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta) {
if (!body || body.length === 0) return <div className="empty-note">暂无正文</div>
if (body[0].kind === 'restricted') {
return <div className="empty-note">内容受限({body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
}
return (
<>
{body.map((b, i) => <p key={i} className="cockpit-reader-para">{b.text}</p>)}
{meta.truncated && <div className="cockpit-truncated">详情已截断:{meta.truncationReasons?.join(';')}</div>}
</>
)
}
/** 右栏:依据/历史/影响/冲突四标签(同一选中节点;刷新原位,不抢占滚动)。 */
export function CockpitPanel({ pid, selection, tab, onTab, density }: {
pid: string
selection: ReaderSelection
tab: PanelTabId
onTab: (t: PanelTabId) => void
density: Density
}) {
const { detail, err } = useNodeDetail(pid, selection.ref)
const [historyItems, setHistoryItems] = useState<CockpitHistorySummary[]>([])
const [historyMore, setHistoryMore] = useState(false)
const [historyCursor, setHistoryCursor] = useState<string | undefined>(undefined)
useEffect(() => {
setHistoryItems([]); setHistoryMore(false); setHistoryCursor(undefined)
}, [selection.ref.kind, selection.ref.id])
useEffect(() => {
if (!detail?.history) return
// 严格合并:只接受 seq 更新的条目,乱序/重复丢弃;不重置已渲染内容。
setHistoryItems(prev => mergeHistoryStrict(prev, detail.history.items).items)
setHistoryMore(detail.history.hasMore)
setHistoryCursor(detail.history.nextCursor)
}, [detail])
const loadMoreHistory = useCallback(() => {
if (!historyCursor) return
api.rdmCommand<any>('rdm.cockpit.node.history', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id, cursor: historyCursor, limit: 20 })
.then(page => {
setHistoryItems(prev => mergeHistoryStrict(prev, page.items).items)
setHistoryMore(page.hasMore)
setHistoryCursor(page.nextCursor)
})
.catch(() => { /* 保留已渲染历史;可再次点击重试 */ })
}, [pid, selection.ref.kind, selection.ref.id, historyCursor])
const node = detail?.node
return (
<div className={`cockpit-panel cockpit-density-${density}`}>
<div className="cockpit-panel-tabs" role="tablist" aria-label="依据与影响">
{PANEL_TABS.map(t => (
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id}
className={`cockpit-panel-tab${tab === t.id ? ' cockpit-panel-tab-active' : ''}`}
onClick={() => onTab(t.id)}>
{t.label}
</button>
))}
</div>
{err && <div className="empty-note">依据面板加载失败:{err}</div>}
{!err && !detail && <div className="empty-note">加载依据…</div>}
{detail && tab === 'sources' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">来源 {node?.state.sourceCount ?? detail.sources.items.length} 条</div>
{detail.sources.items.length === 0 && <div className="empty-note">无来源——章节正文未挂依据切片</div>}
{detail.sources.items.map((s: any) => (
<div key={s.ref.id} className="cockpit-panel-row">
<span className="cockpit-panel-kind">{s.ref.kind}</span>
<span className="cockpit-panel-label">{s.label}</span>
{s.confidence && <span className="cockpit-badge">置信度 {s.confidence}</span>}
{s.redactedReason
? <span className="cockpit-badge">受限({s.redactedReason})</span>
: s.verification && s.verification !== 'not_applicable' && <span className="cockpit-badge">{s.verification}</span>}
</div>
))}
</div>
)}
{detail && tab === 'history' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">已提交事件(最新在前)</div>
{historyItems.length === 0 && <div className="empty-note">暂无历史</div>}
{historyItems.map(h => <div key={h.seq} className="cockpit-panel-row">{historyLine(h)}</div>)}
{historyMore && <button type="button" className="cockpit-tree-more" onClick={loadMoreHistory}>加载更早历史</button>}
</div>
)}
{detail && tab === 'impacts' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">影响 {node?.state.impactCount ?? detail.impacts.items.length} 项(一跳)</div>
{detail.impacts.items.length === 0 && <div className="empty-note">无一跳影响对象</div>}
{detail.impacts.items.map((r: any, i: number) => (
<div key={`${r.ref.kind}-${r.ref.id}-${i}`} className="cockpit-panel-row">
<span className="cockpit-panel-kind">{r.direction === 'incoming' ? '←' : '→'}</span>
<span className="cockpit-panel-label">{r.ref.kind}:{r.ref.id}</span>
<span className="cockpit-badge">{r.edgeType}</span>
<span className="cockpit-badge">{r.edgeState}</span>
</div>
))}
</div>
)}
{detail && tab === 'conflicts' && (
<div className="cockpit-panel-body">
<div className="cockpit-panel-count">冲突 {node?.state.conflictCount ?? 0} 项</div>
{detail.conflicts.items.length === 0 && <div className="empty-note">无开放冲突</div>}
{detail.conflicts.items.map(c => (
<div key={c.id} className="cockpit-panel-row cockpit-panel-conflict">
<span className="cockpit-badge">{c.state === 'open' ? '开放' : c.state}</span>
<div className="cockpit-conflict-side">甲方:{c.left.visibility === 'full' ? c.left.claim : `受限(${c.left.redactedReason || '权限'})`}</div>
<div className="cockpit-conflict-side">乙方:{c.right.visibility === 'full' ? c.right.claim : `受限(${c.right.redactedReason || '权限'})`}</div>
<div className="cockpit-changetime">{new Date(c.detectedAt).toLocaleString('zh-CN')}</div>
</div>
))}
</div>
)}
</div>
)
}
+238 -135
View File
@@ -5,14 +5,17 @@ import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpe
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../api'
import { store, useStore, activeRun, isTerminal, type LiveState, type ContextObs } from '../store'
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
import { liveToMessage, livePhaseLabel, showProcessInline } from '../live'
import { liveToMessage, livePhaseLabel, runEventsToTimeline, showProcessInline } from '../live'
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
import { docBridge } from './Workbench'
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
import { splitTimeline, mergeProcessTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
import { ConversationWork } from './ConversationWork'
import { MessageTimestamp } from './MessageTimestamp'
import { loadRunEvents } from '../runEvents'
import '../companion-conversation.css'
function hasFiles(dt: DataTransfer | null): boolean {
return !!dt && Array.from(dt.types).includes('Files')
@@ -23,14 +26,13 @@ function hasFiles(dt: DataTransfer | null): boolean {
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
switch (part.type) {
case 'text':
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': {
@@ -46,12 +48,12 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
case 'plan':
return null // 计划在右栏 Plan 标签呈现
case 'card': {
// 卡流锚点(D39):cardId 非空 = submit_card 引用部件——中栏自足(可展开全文)+ 跳收件箱。
// 右栏工作可见时保留定位入口;详细正文只在右栏。关闭右栏时中栏可展开全文。
if (part.card?.cardId) {
const ref = part.card.cardId
const card = findCard(ref)
const bodyChars = card?.body?.reduce((n, node) => n + (node.markdown?.length ?? 0), 0)
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={card}
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={workPaneOwnsCards || card?.work ? undefined : card}
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
}
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
@@ -105,18 +107,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 +179,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>
)
@@ -205,21 +199,42 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
const run = useStore(s => (msg.runId ? s.runs.find(r => r.id === msg.runId) : undefined))
const childStamp = useStore(s => msg.runId ? s.runs.filter(r => r.parentRunId === msg.runId)
.map(r => `${r.id}:${r.status}:${r.endedAt ?? ''}`).join('|') : '')
const parts = msg.parts ?? []
useLiveClock(!!live)
const finished = !live && !!run && run.status !== 'running' && run.status !== 'pending' && run.status !== 'waiting_hitl'
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 [replayed, setReplayed] = useState<Part[] | null>(null)
const [processError, setProcessError] = useState(false)
useEffect(() => { setReplayed(null); setProcessError(false) }, [childStamp])
useEffect(() => {
if (!open) { setReplayed(null); setProcessError(false) }
}, [open])
useEffect(() => {
if (!open || live || !msg.runId || !convId || replayed || processError) return
const controller = new AbortController()
let active = true
void (async () => {
try {
const events = await loadRunEvents(convId, msg.runId!, controller.signal)
if (active) setReplayed(runEventsToTimeline(events, msg.runId))
} catch {
if (active) setProcessError(true)
}
})()
return () => { active = false; controller.abort() }
}, [open, live, msg.runId, convId, replayed, processError])
const extras = live ? [] : citedArtifactCards(split.final)
const cards = live ? [] : dedupeDeliverables(split.final)
const lastText = (() => {
@@ -228,14 +243,15 @@ 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 processParts = replayed === null ? split.timeline : mergeProcessTimeline(split.timeline, replayed, split.final)
const toolCount = processParts.filter(p => p.type === 'tool_call').length
const headline = live ? '执行记录' : `执行记录 · ${processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)}`
const timeline = (
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
<ProcessTimeline parts={processParts} 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)
}
@@ -244,16 +260,21 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
<div className="msg-role">
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
<span>助手</span>
<MessageTimestamp value={msg.createdAt || run?.createdAt} />
{live && <LiveBar live={live} />}
</div>
{split.timeline.length > 0 && (
{(split.timeline.length > 0 || !!msg.runId) && (
<div className="process-collapse">
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span className={live ? 'mk-sweep' : ''}>{headline}</span>
</>}>
<div className="body">{timeline}</div>
<div className="body">
{replayed === null && !live && !processError && <div className="process-load-note">正在读取完整记录…</div>}
{processError && <div className="process-load-note">完整记录暂不可用,以下为已保存的摘要</div>}
{processParts.length > 0 ? timeline : replayed && <div className="process-load-note">这轮没有可回看的过程记录</div>}
</div>
</Collapsible>
</div>
)}
@@ -268,7 +289,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 +304,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 +378,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 +418,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>
)}
@@ -409,18 +454,19 @@ function OutboxPanel() {
if (outbox.length === 0) return null
return (
<Collapsible id="composer-outbox" defaultOpen
trigger={<span>排队 {outbox.length}</span>}
trigger={<span>待发送 {outbox.length}</span>}
triggerClassName="outbox-trigger">
<ul className="outbox-list">
{outbox.map(item => (
<li key={item.id} className="outbox-item">
{item.deliveryError && <span className="outbox-text" role="status">{item.deliveryError}</span>}
{editing === item.id ? (
<input className="search-input outbox-edit" value={draft} onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { store.updateOutbox(item.id, draft); setEditing(null) }
}} />
) : (
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}</span>
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}{item.taskRef ? ` · 基于「${item.taskRef.title}」` : ''}</span>
)}
{editing === item.id ? (
<button className="btn ghost" onClick={() => { store.updateOutbox(item.id, draft); setEditing(null) }}>保存</button>
@@ -428,6 +474,7 @@ function OutboxPanel() {
<button className="btn ghost" onClick={() => { setEditing(item.id); setDraft(item.text) }}>修改</button>
)}
<button className="btn ghost" onClick={() => store.sendOutboxNow(item.id)}>发送</button>
{item.taskRef && <button className="btn ghost" onClick={() => store.clearOutboxReference(item.id)}>移除引用</button>}
<button className="icon-btn" title="移出队列" onClick={() => store.removeOutbox(item.id)}><Icon name="x" size={12} /></button>
</li>
))}
@@ -442,7 +489,7 @@ export function NoticeMessage({ msg }: { msg: Message }) {
return (
<div className="msg notice" data-msg-id={msg.id}>
<Collapsible id={`notice:${msg.id}`} triggerClassName="notice-summary"
trigger={<span className="notice-label">通知 · {preview}</span>}>
trigger={<><span className="notice-label">通知 · {preview}</span><MessageTimestamp value={msg.createdAt} /></>}>
<div className="notice-body">{text}</div>
</Collapsible>
</div>
@@ -450,6 +497,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 +516,27 @@ 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 workReference = useStore(s => s.workReference)
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
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]))
@@ -484,77 +548,106 @@ export function Conversation() {
})
const busy = isShellBusy(phase)
const showStop = phase === 'running'
// 慢通道在跑的后台子代理:前台回合结束后仍在执行,完成后经通知回到对话汇报。
// Background work stays in its own card and does not insert another chat turn.
const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length
const unreadResults = cards.filter(card => card.work?.result && card.work.resultVersion > card.work.readVersion)
useEffect(() => {
if (!stickRef.current) return
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
setText(''); setAttachmentIds([]); setAttachmentNames([])
if (busy) {
store.enqueueOutbox(t, ids, names)
const reference = workReference ?? undefined
if (reference && ids.length > 0) {
store.toast('这条消息已引用工作结果,请先移除引用或附件再发送')
return
}
await store.sendMessage(t, ids)
setText(''); setAttachmentIds([]); setAttachmentNames([])
store.clearWorkReference()
if (busy) {
store.enqueueOutbox(t, ids, names, reference)
return
}
await store.sendMessage(t, ids, undefined, reference)
}
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}
@@ -565,13 +658,6 @@ export function Conversation() {
const stick = dist < 80
stickRef.current = stick
setShowJump(prev => (prev === !stick ? prev : !stick))
const ratio = scrollRatio(src)
if (ratio === null) return
const dst = document.querySelector('[data-scroll-sync="inbox"]')
if (!dst) return
const top = scrollTopForRatio(dst, ratio)
if (top === null) return
dst.scrollTop = top
}}>
{foldRunErrors(messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent')).map(m => {
if (m.role === 'user') {
@@ -581,16 +667,20 @@ export function Conversation() {
<div className={`msg user${m.excluded ? ' is-excluded' : ''}`} key={m.id} data-msg-id={m.id}>
<div className="msg-role">
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
<MessageTimestamp value={m.createdAt} />
</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),
onLocate: card => store.openInbox(card.id),
}} />
))}
{anchored.map(c => workPaneOwnsCards
? <button type="button" key={c.id} className="conversation-action-anchor" onClick={() => store.openInbox(c.id)}>
<span>{c.title}</span><span>在工作栏处理 ↗</span>
</button>
: <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),
}} />
)}
</div>
)
}
@@ -600,10 +690,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 +712,66 @@ export function Conversation() {
<div className="input-bar">
<OutboxPanel />
{unreadResults.length > 0 && <button type="button" className="work-result-notice" onClick={() => store.openInbox(unreadResults[0].id)}>
<Icon name="check" size={14} /><span>{unreadResults.length} 项工作有新结果</span><span>查看工作 ↗</span>
</button>}
{workReference && <div className="composer-work-reference" role="group" aria-label="本条消息引用的工作结果">
<span>基于「{workReference.title}」的第 {workReference.resultVersion} 版结果</span>
<button type="button" className="icon-btn" aria-label="移除工作结果引用" onClick={() => store.clearWorkReference()}><Icon name="x" size={12} /></button>
</div>}
{!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 +800,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 {
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useMemo, useState } from 'react'
import { ActivityCardView, Collapsible, activitySummary, groupActivityCards, pendingCount, type ActivityCard } from '@ui'
import { store, useStore } from '../store'
import { docBridge } from './Workbench'
import type { WorkCardAction } from '../contracts'
import { WorkExecutionRecord } from './WorkExecutionRecord'
/** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */
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 rightTab = useStore(s => s.ui.rightTab)
const record = useStore(s => s.workRecord)
const detailHere = rightCollapsed || rightTab !== 'work'
const [summaryOpen, setSummaryOpen] = useState(detailHere)
useEffect(() => setSummaryOpen(detailHere), [detailHere])
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' || card.work?.decision?.state === 'open' || 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 results = groupActivityCards(remaining).find(group => group.key === 'results')?.cards ?? []
const focused = remaining.find(card => card.pinned && card.status === 'active')
?? remaining.find(card => card.status === 'active')
?? results[0]
return { current, required, focused, history: remaining.filter(card => card !== focused).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) }
}, [cards, messages, conversationId, projectId])
if (!detailHere) return null
if (record) return <div className="conversation-work" data-conversation-work><WorkExecutionRecord /></div>
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, payload?: WorkCardAction) => { void store.cardAction(card, actionId, payload) },
onOpenRecord: (card: ActivityCard) => store.openWorkRecord(card),
onReference: (card: ActivityCard) => store.referenceWork(card),
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>
)
}
+361
View File
@@ -0,0 +1,361 @@
// 文档驾驶舱 I2(rollout I2):顶部统计带 + 左侧结构树 + 中央结构全景。
// 消费 I1 只读命令面 rdm.cockpit.*(Dev 仓 docs/superpowers/specs 2026-10-16
// view-spec §1–§4;四项取舍冻结见 rollout §7——首屏只显示章节概览与统计,
// 段落/正文摘要在选中与阅读器内按权限展示,本增量不含阅读器(I3))。
// 异步所有者:generation + contextKey(项目+筛选);不匹配的旧响应直接丢弃。
// 令牌驱动(DESIGN.md):颜色/字号只消费 @ui/tokens.css;折叠用 Collapsible。
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { EmptyState, SectionLabel } from '@ui'
import { classifyRdmError } from '../rdmErrors'
import {
contextKey, loadPrefs, nodeSummaryLine, sameSnapshot, savePrefs,
selectionSurvives, statDisplay, STAT_FILTERS, toggleFilter, treeBadges,
type CockpitMeta, type CockpitNodeRef, type CockpitOverview,
type CockpitTreeNode, type CockpitTreePage,
type Density, type PanelTabId, type ViewMode,
} from '../documentCockpit'
import { CockpitPanel, CockpitReader } from './CockpitReader'
interface ProjectView { id: string; name: string; canAudit?: boolean }
function CockpitErrorState({ raw }: { raw: string }) {
const c = classifyRdmError(raw)
return <EmptyState icon="alert">{c.message}</EmptyState>
}
interface Selection { ref: CockpitNodeRef; parentDocId?: string }
export function DocumentCockpit() {
const identity = useStore(() => store.getIdentityEpoch())
const instanceId = useRef(Math.random().toString(36).slice(2, 8))
const [projects, setProjects] = useState<ProjectView[] | null>(null)
const [pid, setPid] = useState('')
const [filter, setFilter] = useState('')
const [overview, setOverview] = useState<CockpitOverview | null>(null)
const [treePages, setTreePages] = useState<Record<string, { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }>>({})
const [selected, setSelected] = useState<Selection | null>(null)
const [error, setError] = useState('')
const [loadErr, setLoadErr] = useState('')
const [viewMode, setViewMode] = useState<ViewMode>('panorama')
const [density, setDensity] = useState<Density>('compact')
const [panelTab, setPanelTab] = useState<PanelTabId>('sources')
const gen = useRef(0)
const reload = useCallback((projectId: string, f: string) => {
if (!projectId) return
const g = ++gen.current
const ctx = contextKey(projectId, f)
setLoadErr('')
api.rdmCommand<CockpitOverview>('rdm.cockpit.overview', { projectId })
.then(ov => {
if (g !== gen.current || ctx !== contextKey(pid, filter)) return // 旧响应:项目/筛选已切换,丢弃
setOverview(ov)
setTreePages({}) // 树页随新快照整体重建
setSelected(sel => {
const kept = selectionSurvives(sel?.ref || null, (ov.documents || []).map(d => d.ref.id), sel?.parentDocId)
return kept ? { ref: kept, parentDocId: sel?.parentDocId } : null
})
})
.catch(e => { if (g === gen.current && ctx === contextKey(pid, filter)) setLoadErr(e.message) })
}, [pid, filter])
// 项目清单(rdm.project.list:成员可见项目,canAudit 与否不影响文档读取入口)。
useEffect(() => {
let c = false
console.log('[cockpit-dbg] EFFECT start inst=' + instanceId.current, 'identity=', identity)
setProjects(null); setPid(''); setOverview(null); setSelected(null); setFilter(''); setError('')
api.rdmCommand<{ projects?: ProjectView[] } | ProjectView[]>('rdm.project.list')
.then(list => {
const rows = Array.isArray(list) ? list : ((list as any)?.projects ?? [])
console.log('[cockpit-dbg] FETCH resolved inst=' + instanceId.current, 'cancelled=', c, 'rows=', rows.length)
if (!c) return
// rdm.project.list 返回 { projects: [...] };容忍裸数组形态。
setProjects(rows)
console.log('[cockpit-dbg] setProjects done inst=' + instanceId.current, 'rows=', rows.length)
})
.catch(e => { if (!c) setError(e.message) })
return () => { c = true }
}, [identity])
// 项目/筛选变化 → 重新取概览(统计与根文档来自同一快照)。
useEffect(() => {
if (!pid) return
reload(pid, filter)
}, [pid, filter, reload])
// 个人视图偏好(设计 §7.3):按 登录世代+项目 隔离,仅本地;恢复最近视图。
useEffect(() => {
if (!pid) return
const prefs = loadPrefs(`epoch-${identity}`, pid)
setViewMode(prefs.lastView)
setDensity(prefs.density)
setPanelTab(prefs.rightPanelTab)
}, [identity, pid])
const applyPrefs = useCallback((patch: { lastView?: ViewMode; density?: Density; rightPanelTab?: PanelTabId }) => {
if (!pid) return
setViewMode(v => patch.lastView ?? v)
setDensity(d => patch.density ?? d)
setPanelTab(t => patch.rightPanelTab ?? t)
const prefs = loadPrefs(`epoch-${identity}`, pid)
savePrefs(`epoch-${identity}`, pid, { ...prefs, ...patch })
}, [identity, pid])
// 展开文档:按需加载章节子页(键集分页;旧项目/旧筛选的响应丢弃;
// 加载失败只影响该分支——行内保留重试,不拖垮其它面板)。
const loadChildren = useCallback((docId: string, cursor?: string) => {
if (!pid) return
const g = gen.current
const ctx = contextKey(pid, filter)
api.rdmCommand<CockpitTreePage>('rdm.cockpit.tree', { projectId: pid, parent: docId, cursor, limit: 50, filter })
.then(page => {
if (g !== gen.current || ctx !== contextKey(pid, filter)) return
setTreePages(prev => {
const old = cursor ? prev[docId] : { items: [] as CockpitTreeNode[], hasMore: false }
const items = cursor ? [...(old?.items || []), ...page.items] : page.items
return { ...prev, [docId]: { items, nextCursor: page.nextCursor, hasMore: page.hasMore, meta: page.meta } }
})
})
.catch(() => { /* 行内重试入口承担 */ })
}, [pid, filter])
console.log('[cockpit-dbg] RENDER inst=' + instanceId.current, 'identity=', identity, 'projects=', projects === null ? 'null' : projects.length, 'error=', error)
if (error) return <CockpitErrorState raw={error} />
if (!projects) return <div className="empty-note">加载中…</div>
if (projects.length === 0) return <EmptyState icon="folder">暂无可见研发项目——项目成员身份由项目负责人授予</EmptyState>
const docIds = (overview?.documents || []).map(d => d.ref.id)
const splitNotice = !!overview && Object.values(treePages).some(p => !sameSnapshot(overview.meta, p.meta))
const selectDoc = (id: string) => setSelected({ ref: { kind: 'document', id } })
const selectSection = (docId: string, sec: CockpitTreeNode) => setSelected({ ref: sec.ref, parentDocId: docId })
return (
<div className="cockpit">
<div className="cockpit-header">
<select className="ui-input cockpit-project" value={pid} onChange={e => { setPid(e.target.value); setSelected(null); setFilter('') }} aria-label="选择项目">
{projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
{filter && <button type="button" className="cockpit-filter-chip" onClick={() => setFilter('')}>
筛选:{STAT_FILTERS.find(s => s.filter === filter)?.label || filter} ✕
</button>}
<span className="cockpit-snap">{overview ? `快照 ${overview.meta.snapshotRevision}` : '加载中…'}</span>
<div className="cockpit-viewtoggle" role="group" aria-label="视图切换">
<button type="button" className={`cockpit-viewbtn${viewMode === 'panorama' ? ' cockpit-viewbtn-active' : ''}`} onClick={() => applyPrefs({ lastView: 'panorama' })}>全景</button>
<button type="button" className={`cockpit-viewbtn${viewMode === 'reader' ? ' cockpit-viewbtn-active' : ''}`} onClick={() => applyPrefs({ lastView: 'reader' })}>阅读器</button>
<button type="button" className="cockpit-viewbtn" onClick={() => applyPrefs({ density: density === 'compact' ? 'full' : 'compact' })} title="切换密度">{density === 'compact' ? '紧凑' : '宽松'}</button>
</div>
<button type="button" className="cockpit-refresh" onClick={() => reload(pid, filter)} disabled={!pid}>刷新</button>
</div>
{splitNotice && (
<div className="cockpit-banner" role="status">统计与树快照不一致——请刷新以对齐最新快照</div>
)}
{loadErr && <div className="cockpit-banner cockpit-banner-err" role="alert">概览读取失败:{loadErr}(保留上一快照,可重试)</div>}
{overview && (
<div className="cockpit-stats" role="group" aria-label="项目统计">
<div className="cockpit-stat">
<span className="cockpit-stat-num">{statDisplay(overview.stats, 'completion').value}</span>
<span className="cockpit-stat-label">完成度{statDisplay(overview.stats, 'completion').hint ? `(${statDisplay(overview.stats, 'completion').hint})` : ''}</span>
</div>
<div className="cockpit-stat"><span className="cockpit-stat-num">{statDisplay(overview.stats, 'documents').value}</span><span className="cockpit-stat-label">文档</span></div>
{STAT_FILTERS.map(s => {
const d = statDisplay(overview.stats, s.key)
const active = filter === s.filter
return (
<button key={s.key} type="button" className={`cockpit-stat${active ? ' cockpit-stat-active' : ''}`}
onClick={() => setFilter(f => toggleFilter(f, s.filter))}
title={d.hint ? `${s.label}:${d.hint}` : `筛选:${s.label}`}>
<span className="cockpit-stat-num">{d.value}</span>
<span className="cockpit-stat-label">{s.label}{d.hint ? `(${d.hint})` : ''}</span>
</button>
)
})}
<div className="cockpit-stat cockpit-stat-unavail"><span className="cockpit-stat-num">—</span><span className="cockpit-stat-label">待确认(未纳入投影)</span></div>
<div className="cockpit-stat"><span className="cockpit-stat-num">{overview.stats.updatedAt ? new Date(overview.stats.updatedAt).toLocaleString('zh-CN') : '—'}</span><span className="cockpit-stat-label">最近更新</span></div>
</div>
)}
<div className={`cockpit-columns${selected ? ' cockpit-columns-3' : ''}`}>
<div className="cockpit-tree" role="tree" aria-label="文档结构树">
<SectionLabel>结构树</SectionLabel>
{!overview && <div className="empty-note">加载结构…</div>}
{overview && docIds.length === 0 && <div className="empty-note">暂无受控文档</div>}
{overview && overview.meta.truncated && (
<div className="cockpit-truncated">文档清单已截断({overview.meta.truncationReasons?.join(';') || '超出上限'})</div>
)}
{overview && overview.documents.map(d => (
<TreeRow key={d.ref.id} node={d} depth={0}
expanded={treePages[d.ref.id] !== undefined}
childPage={treePages[d.ref.id]}
selectedId={selected?.ref.id}
onSelectNode={n => (n.ref.kind === 'document' ? selectDoc(n.ref.id) : selectSection(d.ref.id, n))}
onExpand={() => { if (treePages[d.ref.id] === undefined) loadChildren(d.ref.id) }}
onLoadMore={() => { const p = treePages[d.ref.id]; if (p?.nextCursor) loadChildren(d.ref.id, p.nextCursor) }}
onRetryChild={() => { const p = treePages[d.ref.id]; loadChildren(d.ref.id, p?.nextCursor) }}
/>
))}
</div>
<div className="cockpit-center">
{!overview && <div className="empty-note">加载概览…</div>}
{overview && !selected && (
<PanoramaHome overview={overview} onSelect={selectDoc} />
)}
{overview && selected && viewMode === 'panorama' && (
<PanoramaNode pid={pid} selection={selected} overview={overview}
childPage={selected.ref.kind === 'document' ? treePages[selected.ref.id] : undefined}
onClear={() => setSelected(null)}
onOpenReader={() => applyPrefs({ lastView: 'reader' })} />
)}
{overview && selected && viewMode === 'reader' && (
<CockpitReader pid={pid} selection={selected}
sections={selected.ref.kind === 'document' ? (treePages[selected.ref.id]?.items || []) : []}
density={density} onBack={() => applyPrefs({ lastView: 'panorama' })} />
)}
</div>
{overview && selected && (
<div className="cockpit-right">
<CockpitPanel pid={pid} selection={selected} tab={panelTab} onTab={t => applyPrefs({ rightPanelTab: t })} density={density} />
</div>
)}
</div>
</div>
)
}
function TreeRow({ node, depth, expanded, childPage, selectedId, onSelectNode, onExpand, onLoadMore, onRetryChild }: {
node: CockpitTreeNode; depth: number
expanded: boolean
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
selectedId?: string
onSelectNode: (n: CockpitTreeNode) => void
onExpand: () => void
onLoadMore: () => void
onRetryChild: () => void
}) {
const badges = treeBadges(node)
const selected = selectedId === node.ref.id
return (
<div className="cockpit-tree-branch">
<div className={`cockpit-tree-row${selected ? ' cockpit-tree-row-active' : ''}`} style={{ paddingLeft: depth * 14 }} role="treeitem" aria-selected={selected}>
{node.ref.kind === 'document' && (
<button type="button" className="cockpit-tree-caret" aria-label={expanded ? '折叠' : '展开'}
aria-expanded={expanded} onClick={e => { e.stopPropagation(); if (!expanded) onExpand() }}>
{expanded ? '▾' : '▸'}
</button>
)}
<button type="button" className="cockpit-tree-title" onClick={() => onSelectNode(node)}>{node.title}</button>
{badges.map(b => <span key={b} className="cockpit-badge">{b}</span>)}
{node.lastChangedAt && <span className="cockpit-tree-time">{new Date(node.lastChangedAt).toLocaleDateString('zh-CN')}</span>}
</div>
{expanded && !childPage && (
<button type="button" className="cockpit-tree-more" style={{ paddingLeft: (depth + 1) * 14 }} onClick={onRetryChild}>章节加载失败,重试</button>
)}
{expanded && childPage && (
<div>
{childPage.items.length === 0 && <div className="cockpit-tree-empty" style={{ paddingLeft: (depth + 1) * 14 }}>无章节</div>}
{childPage.items.map(sec => (
<TreeRow key={sec.ref.id} node={sec} depth={depth + 1} expanded={false} childPage={undefined}
selectedId={selectedId} onSelectNode={onSelectNode}
onExpand={onExpand} onLoadMore={onLoadMore} onRetryChild={onRetryChild} />
))}
{childPage.hasMore && childPage.items.length > 0 && (
<button type="button" className="cockpit-tree-more" style={{ paddingLeft: (depth + 1) * 14 }} onClick={onLoadMore}>加载更多章节</button>
)}
</div>
)}
</div>
)
}
function PanoramaHome({ overview, onSelect }: { overview: CockpitOverview; onSelect: (id: string) => void }) {
return (
<div>
<SectionLabel>{overview.project.title} · 文档概览</SectionLabel>
{overview.documents.length === 0 && <EmptyState icon="folder">暂无受控文档——文档由模板实例化或导入后进入驾驶舱</EmptyState>}
<div className="cockpit-doclist">
{overview.documents.map(d => (
<button key={d.ref.id} type="button" className="cockpit-docrow" onClick={() => onSelect(d.ref.id)}>
<span className="cockpit-docrow-title">{d.title}</span>
<span className="cockpit-docrow-meta">{nodeSummaryLine(d)}</span>
</button>
))}
</div>
<SectionLabel>最近变化</SectionLabel>
{overview.recentChanges.length === 0 && <div className="empty-note">暂无可见变化</div>}
<div className="cockpit-changes">
{overview.recentChanges.map((c, i) => (
<div key={`${c.node.id}-${i}`} className="cockpit-changerow">
<span className="cockpit-changesum">{c.summary} · {c.title}{c.actor ? ` · ${c.actor}` : ''}</span>
<span className="cockpit-changetime">{new Date(c.changedAt).toLocaleString('zh-CN')}</span>
</div>
))}
</div>
</div>
)
}
function PanoramaNode({ pid, selection, overview, childPage, onClear, onOpenReader }: {
pid: string; selection: Selection; overview: CockpitOverview
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
onClear: () => void
onOpenReader: () => void
}) {
const [detail, setDetail] = useState<any>(null)
const [err, setErr] = useState('')
const gen = useRef(0)
useEffect(() => {
const g = ++gen.current
setDetail(null); setErr('')
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id })
.then(d => { if (g === gen.current) setDetail(d) })
.catch(e => { if (g === gen.current) setErr(e.message) })
return () => { gen.current++ }
}, [pid, selection.ref.kind, selection.ref.id])
const node = (detail?.node || null) as CockpitTreeNode | null
const title = node?.title || selection.ref.id
return (
<div>
<div className="cockpit-nodehead">
<button type="button" className="cockpit-back" onClick={onClear}>← 返回概览</button>
<span className="cockpit-nodetitle">{title}</span>
{node && <span className="cockpit-nodesum">{nodeSummaryLine(node)}</span>}
<button type="button" className="cockpit-filter-chip" onClick={onOpenReader}>在阅读器中打开</button>
</div>
{err && <CockpitErrorState raw={err} />}
{detail?.body?.length === 1 && detail.body[0].kind === 'restricted' && (
<div className="empty-note">内容受限({detail.body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
)}
{detail && detail.meta && !sameSnapshot(detail.meta, overview.meta) && (
<div className="cockpit-truncated">节点详情与统计快照不同({detail.meta.snapshotRevision} vs {overview.meta.snapshotRevision})——刷新对齐</div>
)}
{node && node.ref.kind === 'document' && (
<>
<SectionLabel>章节状态</SectionLabel>
{!childPage && <div className="empty-note">章节按需加载——在左侧树展开</div>}
{childPage && childPage.items.length === 0 && <div className="empty-note">无章节</div>}
{childPage && childPage.items.length > 0 && (
<table className="cockpit-childtable">
<thead><tr><th>章节</th><th>内容</th><th>核实</th><th>来源</th><th>冲突</th><th>最近更新</th></tr></thead>
<tbody>
{childPage.items.map(sec => (
<tr key={sec.ref.id}>
<td>{sec.title}</td>
<td>{{ complete: '完整', partial: '部分', empty: '空', inaccessible: '受限', unavailable: '不可读' }[sec.state.content] || sec.state.content}</td>
<td>{sec.state.verification === 'not_applicable' ? '—' : sec.state.verification}</td>
<td>{sec.state.sourceCount}</td>
<td>{sec.state.conflictCount}</td>
<td>{sec.lastChangedAt ? new Date(sec.lastChangedAt).toLocaleDateString('zh-CN') : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
{node && node.ref.kind === 'section' && (
<div className="empty-note">点「在阅读器中打开」查看连续正文与版本差异;右侧为该节点的依据与影响面板</div>
)}
{detail?.meta?.truncated && (
<div className="cockpit-truncated">详情已截断:{detail.meta.truncationReasons?.join(';')}</div>
)}
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { api, type ExecEnvView } from '../api'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup } from '@ui'
export function ExecEnvPanel() {
const [view, setView] = useState<ExecEnvView | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [conv, setConv] = useState('')
const [reason, setReason] = useState('')
const [wheel, setWheel] = useState<File | null>(null)
const refresh = async () => {
try { setView(await api.adminExecEnv()) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const run = async (work: () => Promise<string>) => {
setErr(''); setNotice(''); setBusy(true)
try { setNotice(await work()); await refresh() } catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
if (!view) return <EmptyState icon="package">加载 Python 环境…</EmptyState>
if (!view.configured) return <EmptyState icon="package">这台服务没有配置分层 Python 环境根</EmptyState>
return (
<div>
<SettingsGroup title="当前环境">
<SettingRow label="环境根" desc={view.root || '未设置'}><span /></SettingRow>
<SettingRow label="解释器" desc={view.python || '尚未安装托管解释器'}><span /></SettingRow>
<SettingRow label="基础层" desc={view.base || '尚未建立'}><span /></SettingRow>
<SettingRow label="层目录" desc={(view.layers ?? []).join('、') || '无'}>
<Btn variant="ghost" icon="undo" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvRebase()
return `基础层已切换到 ${out.base}。已有会话仍用原来的绑定。`
})}>重建基础层</Btn>
</SettingRow>
</SettingsGroup>
<SettingsGroup title="申请">
{(view.requests ?? []).length === 0 && <EmptyState icon="inbox">还没有扩展包申请</EmptyState>}
{(view.requests ?? []).map(req => (
<SettingRow key={req.id} label={req.spec || req.id} desc={`${req.status} · ${req.conversationId} · ${req.detail}`}>
{req.status === 'pending_approval' && (
<span className="ad-row">
<Btn variant="primary" icon="check" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, true, false)
return out.detail || out.status
})}>批准</Btn>
<Btn variant="ghost" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, true, true)
return out.detail || '已用隔离层'
})}>隔离层</Btn>
<Btn variant="ghost" icon="x" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvDecision(req.id, false, false)
return out.detail || '已拒绝'
})}>拒绝</Btn>
</span>
)}
</SettingRow>
))}
</SettingsGroup>
<SettingsGroup title="会话绑定">
{(view.bindings ?? []).map(b => (
<SettingRow key={b.conversationId} label={b.conversationId} desc={b.isolated ? `隔离 ${b.isolated}` : (b.extensions ?? []).join('、') || '只用基础层'}>
<span />
</SettingRow>
))}
<SettingRow label="回收" desc="删掉 7 天内没有会话引用、也不是当前基础层的目录">
<Btn variant="ghost" icon="trash" disabled={busy} onClick={() => run(async () => {
const out = await api.adminExecEnvGC()
const n = out.removed?.length ?? 0
return n === 0 ? '没有可回收的层' : `已回收 ${n} 层`
})}>回收未引用层</Btn>
</SettingRow>
</SettingsGroup>
<SettingsGroup title="上传 wheel">
<SettingRow label="会话" desc="这个 wheel 只挂到指定会话,并进入审批">
<input className="lg-input" value={conv} onChange={e => setConv(e.target.value)} placeholder="会话 ID" />
</SettingRow>
<SettingRow label="理由" desc="写给审批记录">
<input className="lg-input" value={reason} onChange={e => setReason(e.target.value)} />
</SettingRow>
<SettingRow label="文件" desc="只接受预编译 wheel,不执行里面的脚本">
<input type="file" accept=".whl" onChange={e => setWheel(e.target.files?.[0] ?? null)} />
</SettingRow>
<div className="ad-row">
<Btn variant="primary" icon="download" disabled={busy || !wheel || !conv.trim()} onClick={() => run(async () => {
const out = await api.adminExecEnvWheel(wheel as File, conv.trim(), reason.trim())
return `${out.file}:${out.outcome.status}`
})}>提交审批</Btn>
</div>
</SettingsGroup>
{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>
)
}
+95 -53
View File
@@ -1,9 +1,10 @@
// 登录与改密(管理员发放制,R2):
// LoginPage —— 用户名+密码(主)/ API 密钥(兼容程序化客户端)双入口;
// ChangePasswordGate —— 初始/重置密码后的首登强制改密(不完成不放行)。
import { useState } from 'react'
import { auth } from '../auth'
import { api } from '../api'
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(恢复/激活,只输 Key);
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
// grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
import { useEffect, useState } from 'react'
import { api, type PasswordChangeMode } from '../api'
import { Icon, BrandMark } from '@ui'
function Err({ msg }: { msg: string }) {
@@ -28,9 +29,11 @@ function BrandPanel() {
)
}
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。 */
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。
* 长期 API 密钥(ask_)不再作为浏览器日常登录入口——程序化客户端直接带
* Authorization 头;浏览器保存长期密钥属于持久凭据暴露面。 */
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
const [tab, setTab] = useState<'password' | 'apikey'>('password')
const [tab, setTab] = useState<'password' | 'loginkey'>('password')
const [owner, setOwner] = useState('')
const [password, setPassword] = useState('')
const [key, setKey] = useState('')
@@ -41,10 +44,9 @@ 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) {
// 初始/重置密码:先完成强制改密再放行。
// 受限会话(must-change 首登)或正常会话都由后端 sessionScope 声明;
// 受限会话先进设密门,完成前不放行。
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
onMustChange(me.owner)
return
}
@@ -56,24 +58,16 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
const submitKey = async () => {
setErr(''); setBusy(true)
auth.setKey(key)
try {
const r = await auth.fetch('/api/health')
if (r.ok) {
// 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录):
// 不放行进死路改密门,引导改走账号密码登录。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) {
auth.clear()
setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密')
return
}
onAuthed(); return
// 只提交 Key:目标账户由服务端绑定记录确定,客户端不能指定身份。
const me = await api.redeemLoginKey(key.trim())
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
onMustChange(me.owner)
return
}
throw new Error(String(r.status))
} catch {
auth.clear()
setErr('密钥无效或已撤销,请重试')
onAuthed()
} catch (e) {
setErr(humanRedeemError(String(e)))
} finally { setBusy(false) }
}
@@ -84,10 +78,10 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">登录</h2>
<p className="lg-sub">账号由管理员发放;首次登录需修改初始密码</p>
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员签发一次性登录 Key</p>
<div className="lg-tabs" role="tablist">
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
<button type="button" role="tab" aria-selected={tab === 'apikey'} className={`lg-tab ${tab === 'apikey' ? 'on' : ''}`} onClick={() => setTab('apikey')}>API 密钥</button>
<button type="button" role="tab" aria-selected={tab === 'loginkey'} className={`lg-tab ${tab === 'loginkey' ? 'on' : ''}`} onClick={() => setTab('loginkey')}>一次性登录 Key</button>
</div>
{tab === 'password' ? (
<>
@@ -100,11 +94,11 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
</>
) : (
<>
<label className="lg-label">API 访问密钥</label>
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="ask_…" />
<label className="lg-label">一次性登录 Key</label>
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="alk_…" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '验证中…' : '进入'}</button>
<p className="lg-hint">密钥由服务端创建:<code>agent-system apikey create --owner &lt;name&gt;</code>。程序化客户端也可直接带 Authorization 头。</p>
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '兑换中…' : '兑换并设置新密码'}</button>
<p className="lg-hint">忘记密码时由管理员发起恢复提案,并由你在已登录设备的「设置 → 账号」中本人确认后获得;新账号使用管理员交付的激活 Key。单次有效、限时兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
</>
)}
</form>
@@ -113,22 +107,47 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
)
}
/** 强制改密门:初始密码/管理员重置后的首次登录必须完成改密才可进入系统。 */
/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)免当前密码。 */
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
return mode === 'current_required'
}
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
const [mode, setMode] = useState<PasswordChangeMode | null>(null)
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
useEffect(() => {
let active = true
api.me().then(me => {
if (!active) return
if (me.owner !== owner || me.sessionScope !== 'set_password_only' ||
(me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) {
setErr('当前设密凭据不再有效,请重新登录;恢复场景请先核对「设置 → 账号」中的恢复提案状态。')
return
}
setMode(me.passwordChangeMode)
}).catch(() => { if (active) setErr('无法确认当前设密方式,请重新登录。') })
return () => { active = false }
}, [owner])
const submit = async () => {
setErr('')
if (!mode) { setErr('设密方式尚未由服务端确认'); return }
if (requiresCurrentPassword(mode) && !current) { setErr('请填写当前密码'); return }
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
if (next.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.changePassword(current, next)
onDone()
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
setDone(true)
} catch (e) {
setErr(humanChangePasswordError(String(e)))
} finally { setBusy(false) }
@@ -138,20 +157,33 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
<div className="lg-wrap">
<BrandPanel />
<div className="lg-formside">
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">修改密码</h2>
<p className="lg-sub">账号 <b>{owner}</b> 使用初始/重置密码首次登录,须设置新密码后继续</p>
<label className="lg-label">当前密码(初始/重置密码)</label>
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus autoComplete="current-password" />
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoComplete="new-password" />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '设置新密码并进入'}</button>
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>返回登录</button>}
</form>
{done ? (
<form className="lg-card" onSubmit={e => { e.preventDefault(); onDone() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">密码已更新</h2>
<p className="lg-sub">请使用新密码重新登录账号 <b>{owner}</b></p>
<button className="lg-submit" type="submit">返回登录</button>
</form>
) : (
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">设置新密码</h2>
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
{mode === 'current_required' && <>
<label className="lg-label" htmlFor="forced-current">当前密码</label>
<input id="forced-current" className="lg-input" type="password" value={current}
onChange={e => setCurrent(e.target.value)} autoComplete="current-password" />
</>}
{mode === 'grant_setup' && <p className="lg-hint">身份经可信激活/恢复能力确认,此次无需输入当前密码。</p>}
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !mode || (requiresCurrentPassword(mode) && !current) || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>退出并返回登录</button>}
</form>
)}
</div>
</div>
)
@@ -165,13 +197,23 @@ export function humanLoginError(raw: string): string {
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key),
* 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
export function humanRedeemError(raw: string): string {
if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发'
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
* 不能沿用登录的「用户名或密码不正确」一刀切。
* 后端文案是现行 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('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('401')) return '认证失败,请重新登录'
+8
View File
@@ -0,0 +1,8 @@
/** Stable server time, with the full local date available to keyboard and assistive readers. */
export function MessageTimestamp({ value }: { value?: string }) {
const date = value ? new Date(value) : null
if (!date || !Number.isFinite(date.getTime())) return null
const full = date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
const label = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
return <time className="message-timestamp" dateTime={date.toISOString()} title={full} aria-label={full}>{label}</time>
}
+142
View File
@@ -0,0 +1,142 @@
// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。
// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话
//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应
// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。
import { useEffect, useRef, useState } from 'react'
import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api'
import { useStore } from '../store'
import { Collapsible, Icon, SettingsGroup } from '@ui'
import { recoveryStatusLabel } from '../recovery'
const fields: { key: keyof AccountProfile; label: string }[] = [
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
]
export function MyAccountSettings() {
const me = useStore(s => s.me)
const [account, setAccount] = useState<AccountRecord | null>(null)
const [draft, setDraft] = useState<AccountProfile | null>(null)
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
const [grant, setGrant] = useState<{ proposalId: string; value: string } | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const sequence = useRef(0)
useEffect(() => {
if (!me?.auth) return
const seq = ++sequence.current
Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => {
if (seq !== sequence.current) return
setAccount(profile); setDraft(profile.profile); setProposals(statuses)
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
return () => { sequence.current++ }
}, [me?.owner, me?.auth])
if (!me?.auth) return null
const save = async () => {
if (!account || !draft) return
const patch: Partial<AccountProfile> = {}
for (const field of fields) if (draft[field.key] !== account.profile[field.key]) patch[field.key] = draft[field.key]
if (Object.keys(patch).length === 0) { setNotice('资料没有变化'); return }
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.patchMeProfile(account.profileVersion, patch)
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存;资料不会成为恢复身份的证明')
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
finally { setBusy(false) }
}
const refreshProposal = async (id: string) => {
const updated = await api.getMyRecoveryProposal(id)
setProposals(current => current?.map(item => item.id === id ? updated : item) ?? [updated])
return updated
}
const confirm = async (proposal: RecoveryProposal) => {
setBusy(true); setErr(''); setNotice(''); setGrant(null)
try {
const result = await api.confirmMyRecoveryProposal(proposal.id, proposal.version)
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
if (!result.grant) {
setErr('提案已确认,但一次性明文不可读回。若仍保有原本人会话,可显式重新生成设密能力。')
return
}
setGrant({ proposalId: proposal.id, value: result.grant })
setNotice('本人确认成功。设密能力只在此响应可用,请立即兑换并设置新密码。')
} catch (error) {
const current = await refreshProposal(proposal.id).catch(() => null)
setErr(current?.status === 'confirmed'
? '确认结果待核验:提案已确认,但明文不能读回。若仍保有原本人会话,可显式重新生成设密能力。'
: `确认未完成或结果未知:${String(error)}。请核对当前提案状态。`)
} finally { setBusy(false) }
}
const rotate = async (proposal: RecoveryProposal) => {
setBusy(true); setErr(''); setNotice(''); setGrant(null)
try {
const result = await api.rotateMyRecoveryGrant(proposal.id, proposal.version, proposal.generation)
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
if (!result.grant) {
setErr('重新生成已提交,但一次性明文不可读回。请核对当前提案状态,再由本人显式操作。')
return
}
setGrant({ proposalId: proposal.id, value: result.grant })
setNotice('已重新生成设密能力;旧能力与派生受限会话已失效。')
} catch (error) {
await refreshProposal(proposal.id).catch(() => null)
setErr(`重新生成结果待核验:${String(error)}。请核对提案状态,再由本人显式操作。`)
} finally { setBusy(false) }
}
const redeem = async () => {
if (!grant) return
setBusy(true); setErr(''); setNotice('')
try {
await api.redeemLoginKey(grant.value)
location.reload() // 新 HttpOnly 受限会话由应用启动门送到设密页
} catch (error) {
const session = await api.me().catch(() => null)
if (session?.sessionScope === 'set_password_only' && session.passwordChangeMode === 'grant_setup') {
location.reload()
return
}
await refreshProposal(grant.proposalId).catch(() => null)
setGrant(null)
setErr(`兑换结果待核验:${String(error)}。不会自动重复兑换;若仍保有原本人会话,请显式重新生成能力。`)
} finally { setBusy(false) }
}
return <SettingsGroup title="我的账号与恢复">
{account && draft ? <>
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空,也不会作为本人恢复证明。</p>
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
<input id={`my-profile-${field.key}`} className="lg-input" value={draft[field.key]}
onChange={event => setDraft({ ...draft, [field.key]: event.target.value })} />
</div>)}
<button className="lg-submit ad-create-btn" disabled={busy} onClick={save}>保存我的资料</button></div>
</Collapsible>
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
<p className="ad-hint">忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 Key 兑换设密;历史未知来源账号无法在线证明。</p>
{(proposals ?? []).map(proposal => <div className="ad-create" key={proposal.id}>
<div className="ad-row"><span className="ad-mono">{proposal.id}</span><b>{recoveryStatusLabel[proposal.status]}</b>
<span className="ad-dim">到期 {new Date(proposal.expiresAt).toLocaleString()}</span></div>
<div className="ad-row">
{proposal.status === 'pending' && <button className="ad-mini" disabled={busy} onClick={() => confirm(proposal)}>由本人确认</button>}
{proposal.status === 'confirmed' && <button className="ad-mini" disabled={busy} onClick={() => rotate(proposal)}>显式重新生成设密能力</button>}
</div>
</div>)}
{proposals !== null && proposals.length === 0 && <p className="ad-hint">当前没有恢复提案。</p>}
{grant && <div className="ad-reset">
<p className="ad-hint">设密能力仅保存在当前页面内存,离开后不可读回;不自动复制到剪贴板。</p>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={redeem}>立即兑换并设置新密码</button>
</div>}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
</SettingsGroup>
}
+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} onAction={id => store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), id, {})} 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)
} }} />
+11 -17
View File
@@ -1,10 +1,9 @@
// 工作时间线:思考、对用户的解说、同类工具合并成一行。运行中展开;
// 终轮结束后由调用方套进「已工作 X」折叠。
// 执行记录按工作动作分组;展开状态由调用方控制。
import type { ReactNode } from 'react'
import { Collapsible, 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[]
@@ -17,18 +16,10 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
<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>
)
return row.text && <div className="tl-thinking" key={`${idKey}:th:${i}`}>
<span className="tl-thinking-label">思考过程</span>
<div className="tl-thinking-body">{row.text}</div>
</div>
}
if (row.kind === 'text') {
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
@@ -51,6 +42,11 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
<span className="tl-tools-name">{it.headline}</span>
{it.status === 'error' && <span className="status-error">失败</span>}
{it.summary && <span className="tl-tools-sum">{it.summary}</span>}
{(it.arguments || it.output) && <Collapsible id={`tool-record:${idKey}:${it.id}:${j}`}
triggerClassName="tl-tool-detail" trigger={<span>查看记录 ▸</span>}>
{it.arguments && <pre className="tool-preview">{it.arguments}</pre>}
{it.output && <pre className="tool-preview">{it.output}</pre>}
</Collapsible>}
</li>
))}
</ul>
@@ -70,7 +66,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 +83,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>
)
}
+48 -14
View File
@@ -7,25 +7,42 @@ 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'
import { rdmProjectReadError } from '../rdmManagement'
import { projectActionError, projectWorkError } from '../projectmeta'
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 owner = useStore(s => s.me?.owner)
const isAdmin = useStore(s => s.me?.isAdmin ?? false)
// P1-B 裁定:宿主覆盖设置(含模型与密钥)仅项目负责人/系统管理员可查看与修改;
// RDM 页签(成员/文档职责/项目权限)不在此列——数据面由 RDM 权限自行收敛,
// 授权的职责管理者仍可进入。
const projectPrivileged = !!project && (project.owner === owner || isAdmin)
const [tab, setTab] = useState('project')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [draft, setDraft] = useState<Record<string, any>>({})
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!projectId) return
setLoaded(false)
let active = true
setLoaded(false); setSaved(false); setError(''); setDraft({}); setSchema([])
api.getSettings(projectId).then(s => {
if (!active) return
setSchema(s.schema)
setDraft(flatten(s.project))
setLoaded(true)
}).catch(() => undefined)
}, [projectId])
}).catch(() => { if (active) setError('项目设置未能加载,请关闭后重新打开或联系项目负责人') })
return () => { active = false }
}, [projectId, owner])
const groups = useMemo(() => {
const g: Record<string, SettingDesc[]> = {}
@@ -34,34 +51,51 @@ export function ProjectSettingsModal() {
}, [schema])
if (!projectId) return null
const rdmError = rdmProjectReadError(project)
const writeError = projectWorkError(project)
const rdmTab = tab === 'members' || tab === 'duties' || tab === 'matrix'
const save = async () => {
if (!projectId) return
if (writeError) { store.toast(writeError); return }
try {
await api.putProjectSettings(projectId, unflatten(draft))
setSaved(true)
setTimeout(() => setSaved(false), 1500)
} catch (e) {
store.toast(`项目设置保存失败:${e}`)
store.toast(`项目设置保存失败:${projectActionError(e)}`)
}
}
return (
<SettingsShell
title={`项目设置 · ${project?.name ?? projectId}`}
active="project"
onSelect={() => undefined}
active={tab}
onSelect={setTab}
onClose={() => store.openProjectSettings(null)}
sections={[{ key: 'project', label: '本项目', icon: 'folder' }]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
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-text)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
>
<div>
{!loaded ? (
<EmptyState icon="settings">加载中…</EmptyState>
) : (
{rdmTab && rdmError ? <EmptyState icon="folder">{rdmError}</EmptyState> : project && <>
{tab === 'members' && <RdmMembersSection key={project.id} project={project} />}
{tab === 'duties' && <RdmDutiesSection key={project.id} project={project} />}
{tab === 'matrix' && <RdmPermMatrixSection key={project.id} project={project} />}
</>}
{tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !loaded ? (
<EmptyState icon="settings">{error || '加载中…'}</EmptyState>
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !projectPrivileged ? (
<EmptyState icon="settings">项目覆盖设置(含模型与密钥)仅项目负责人或系统管理员可查看与修改。</EmptyState>
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? (
<>
<div className="empty-note" style={{ marginBottom: 10 }}>
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
{writeError ?? '此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。'}
</div>
{Object.entries(groups).map(([g, descs]) => (
<div className="settings-group" key={g}>
@@ -77,9 +111,9 @@ export function ProjectSettingsModal() {
))}
</div>
))}
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
<Btn variant="primary" icon="check" disabled={!!writeError} onClick={save}>保存项目设置</Btn>
</>
)}
) : null}
</div>
</SettingsShell>
)
+141 -6
View File
@@ -5,6 +5,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
import { classifyRdmError } from '../rdmErrors'
import { store, useStore } from '../store'
import { exportNeedsDraft } from '../workspaces'
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
@@ -45,22 +47,30 @@ function RdmErrorState({ raw }: { raw: string }) {
}
export function RdmAuditWorkspace() {
const identity = useStore(() => store.getIdentityEpoch())
const [caps, setCaps] = useState<Capabilities | null>(null)
const [capsIdentity, setCapsIdentity] = useState(-1)
const [pid, setPid] = useState('')
const [tab, setTab] = useState<TabId>('summary')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
setCaps(null); setPid(''); setError('')
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) })
.then(c => {
if (cancelled || identity !== store.getIdentityEpoch()) return
setCaps(c); setCapsIdentity(identity)
if (c.projects.length > 0) setPid(c.projects[0].projectId)
})
.catch(e => { if (!cancelled && identity === store.getIdentityEpoch()) setError(e.message) })
return () => { cancelled = true }
}, [])
}, [identity])
const project = caps?.projects.find(p => p.projectId === pid)
const exportOn = useStore(s => s.rdmExportAvailable)
if (error) return <RdmErrorState raw={error} />
if (!caps) return <div className="empty-note">加载中…</div>
if (!caps || capsIdentity !== identity) return <div className="empty-note">加载中…</div>
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
return (
@@ -82,7 +92,7 @@ export function RdmAuditWorkspace() {
))}
</div>
<div className="rdm-audit-body">
{tab === 'summary' && <SummaryTab pid={pid} />}
{tab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
{tab === 'materials' && <MaterialsTab pid={pid} />}
{tab === 'trace' && <TraceTab pid={pid} />}
{tab === 'timeline' && <TimelineTab pid={pid} />}
@@ -93,7 +103,7 @@ export function RdmAuditWorkspace() {
// ---- 概览 ----
function SummaryTab({ pid }: { pid: string }) {
function SummaryTab({ pid, exportOn }: { pid: string; exportOn: boolean }) {
const [data, setData] = useState<SummaryData | null>(null)
const [err, setErr] = useState('')
useEffect(() => {
@@ -133,6 +143,131 @@ function SummaryTab({ pid }: { pid: string }) {
{data.coverage && data.coverage.length > 0 && (
<CoverageList cov={data.coverage} />
)}
{exportOn && <ExportSection pid={pid} />}
</div>
)
}
// ---- 概览导出(三条命令都启用才挂上;不参与七命令就绪门) ----
interface ExportCreated {
exportId: string
fileName: string
watermark: number
sha256: string
draft?: boolean
}
interface ExportStatus {
expiresAt: string
sha256: string
watermark: number
}
interface ExportDownloaded {
fileName: string
mime: string
dataB64: string
sha256: string
}
function saveExportFile(fileName: string, mime: string, dataB64: string) {
const bin = atob(dataB64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
const blob = new Blob([bytes], { type: mime || 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}
function ExportSection({ pid }: { pid: string }) {
const identity = useStore(() => store.getIdentityEpoch())
const generation = useRef(0)
const [kind, setKind] = useState<'cgraph-docx' | 'cgraph-matrix'>('cgraph-docx')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [needDraft, setNeedDraft] = useState(false)
const [artifact, setArtifact] = useState<(ExportCreated & { expiresAt: string }) | null>(null)
useEffect(() => {
generation.current++
setArtifact(null)
setErr('')
setNeedDraft(false)
setBusy(false)
return () => { generation.current++ }
}, [pid, identity])
const generate = async (draft: boolean) => {
const request = ++generation.current
const current = () => request === generation.current && identity === store.getIdentityEpoch()
setBusy(true)
setErr('')
setNeedDraft(false)
try {
const created = await api.rdmCommand<ExportCreated>('rdm.inspect.export.create', { projectId: pid, kind, draft })
if (!current()) return
const st = await api.rdmCommand<ExportStatus>('rdm.inspect.export.status', { projectId: pid, exportId: created.exportId })
if (!current()) return
setArtifact({ ...created, expiresAt: st.expiresAt })
} catch (e) {
if (!current()) return
const msg = e instanceof Error ? e.message : String(e)
if (!draft && exportNeedsDraft(msg)) setNeedDraft(true)
else setErr(msg)
} finally {
if (current()) setBusy(false)
}
}
const download = async () => {
if (!artifact) return
const request = ++generation.current
const current = () => request === generation.current && identity === store.getIdentityEpoch()
setBusy(true)
setErr('')
try {
const dl = await api.rdmCommand<ExportDownloaded>('rdm.inspect.export.download', { projectId: pid, exportId: artifact.exportId })
if (!current()) return
saveExportFile(dl.fileName, dl.mime, dl.dataB64)
} catch (e) {
if (current()) setErr(e instanceof Error ? e.message : String(e))
} finally {
if (current()) setBusy(false)
}
}
return (
<div className="rdm-audit-export">
<SectionLabel>导出</SectionLabel>
<div className="rdm-audit-export-row">
<label className="rdm-audit-export-kind">
类型
<select className="ui-input" aria-label="导出类型" value={kind} onChange={e => setKind(e.target.value as 'cgraph-docx' | 'cgraph-matrix')}>
<option value="cgraph-docx">合规追溯报告</option>
<option value="cgraph-matrix">追溯矩阵</option>
</select>
</label>
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(false) }}>生成</button>
</div>
{needDraft && (
<div className="rdm-audit-export-draft">
<p>正式报告不完整。可以生成带缺口标注的草稿。</p>
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(true) }}>生成草稿</button>
</div>
)}
{err && <div className="rdm-audit-export-err">{err}</div>}
{artifact && (
<div className="rdm-audit-export-result">
<div>水位 {artifact.watermark}</div>
<div>sha256 {artifact.sha256}</div>
<div>有效期 {artifact.expiresAt ? new Date(artifact.expiresAt).toLocaleString('zh-CN') : '—'}</div>
{artifact.draft && <div>草稿</div>}
<button type="button" className="btn v-ghost" disabled={busy} onClick={() => { void download() }}>下载</button>
</div>
)}
</div>
)
}
+6 -3
View File
@@ -9,8 +9,9 @@ import type { ThemeChoice } from '../theme'
import type { SettingDesc } from '../contracts'
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
import { BorrowPanels } from './BorrowPanels'
import { MyAccountSettings } from './MyAccountSettings'
type Section = 'settings'
type Section = 'settings' | 'account'
// 分组显示名(项目设置面板共用)。
export const groupNames: Record<string, string> = {
@@ -19,7 +20,7 @@ export const groupNames: Record<string, string> = {
}
export function SettingsModal() {
const [section] = useState<Section>('settings')
const [section, setSection] = useState<Section>('settings')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [, setUser] = useState<Record<string, any>>({})
const [draft, setDraft] = useState<Record<string, any>>({})
@@ -54,10 +55,11 @@ export function SettingsModal() {
return (
<SettingsShell
title="设置" active={section} onSelect={() => undefined}
title="设置" active={section} onSelect={key => setSection(key as Section)}
onClose={() => store.openSettings(false)}
sections={[
{ key: 'settings', label: '常规', icon: 'settings' },
{ key: 'account', label: '账号', icon: 'user' },
]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
actions={section === 'settings' && schema.length > 0 ? (
@@ -108,6 +110,7 @@ export function SettingsModal() {
)
})}
{section === 'settings' && <BorrowPanels />}
{section === 'account' && <MyAccountSettings />}
</SettingsShell>
)
}
+47 -35
View File
@@ -7,7 +7,7 @@ import { api } from '../api'
import { Icon } from '@ui'
import type { Conversation } from '../contracts'
import { relativeTime, filterTasks } from '../tasklist'
import { projectStage } from '../projectmeta'
import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta'
import { buildWorkspaceEntries } from '../workspaces'
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
@@ -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,7 +36,16 @@ 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)
@@ -48,6 +57,7 @@ export function TaskSidebar() {
const serverWorkspaces = useStore(s => s.serverWorkspaces)
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable)
const theme = useStore(s => s.ui.theme)
const pendingBadge = useStore(s =>
s.cards.filter(c => c.status === 'action_required').length +
@@ -58,23 +68,20 @@ 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) => {
try {
await store.createTask(projectId)
onConversationOpen?.()
} catch { /* store owns the actionable failure toast */ }
}
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable, rdmCockpit: rdmCockpitAvailable })
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
const currentProject = projects.find(p => p.id === currentProjectId)
const stage = projectStage(currentProject?.settings)
const stage = projectStateLabel(currentProject?.settings) ?? projectStage(currentProject?.settings)
const workError = currentProject ? projectWorkError(currentProject) : null
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
@@ -97,16 +104,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>
)
@@ -121,22 +133,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>
@@ -155,8 +166,10 @@ export function TaskSidebar() {
onChange={e => setNewProjectName(e.target.value)}
onKeyDown={async e => {
if (e.key === 'Enter' && newProjectName.trim()) {
await store.createProject(newProjectName.trim())
setCreatingProject(false); setNewProjectName('')
try {
await store.createProject(newProjectName.trim())
setCreatingProject(false); setNewProjectName('')
} catch { /* keep the input available for a corrected name */ }
}
if (e.key === 'Escape') setCreatingProject(false)
}}
@@ -172,14 +185,14 @@ export function TaskSidebar() {
)}
{rest.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">这个项目还没有会话——点击「新建任务」开始</div>
<div className="tsb-empty">{workError ?? '从「新建对话」开始,描述你想完成的工作。'}</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>}
@@ -191,7 +204,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>
@@ -211,14 +223,14 @@ 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>
{/* 弹出菜单们 */}
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
{projects.map(p => {
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
const pstage = projectStage(p.settings)
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
return (
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
{p.name}
@@ -232,11 +244,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>
@@ -262,7 +274,7 @@ 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>
+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>
</>}
{(compact || ui.rightCollapsed) && <><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>
)
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useMemo, useState } from 'react'
import { SafeMarkdown } from '@ui'
import type { StreamEvent } from '../contracts'
import { loadRunEvents } from '../runEvents'
import { store, useStore } from '../store'
import { runEventsToTimeline } from '../live'
import { mergeProcessTimeline } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
import { MessageTimestamp } from './MessageTimestamp'
/** The work pane and inline fallback share one selection. Only this detail host
* reads that run's authorized durable events; opening a record never adds chat. */
export function WorkExecutionRecord() {
const selected = useStore(s => s.workRecord)
const conversationId = useStore(s => s.currentConvId)
const messages = useStore(s => s.messages)
const run = useStore(s => s.runs.find(r => r.id === selected?.runId))
const [result, setResult] = useState<{ runId: string; events: StreamEvent[] } | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!selected || selected.conversationId !== conversationId) return
const controller = new AbortController()
let active = true
setResult(null); setFailed(false)
void (async () => {
try {
const events = await loadRunEvents(selected.conversationId, selected.runId, controller.signal)
if (active) setResult({ runId: selected.runId, events })
} catch { if (active) setFailed(true) }
})()
return () => { active = false; controller.abort() }
}, [selected?.runId, selected?.conversationId, conversationId, run?.status, run?.endedAt])
const parts = useMemo(() => {
if (!selected || result?.runId !== selected.runId) return []
const saved = messages.filter(message => message.runId === selected.runId && message.role === 'assistant')
.flatMap(message => message.parts).map(part => ({ ...part, process: true }))
return mergeProcessTimeline(saved, runEventsToTimeline(result.events, selected.runId, true), [])
}, [messages, result, selected?.runId])
if (!selected || selected.conversationId !== conversationId) return null
return <section className="work-execution-record" aria-label={`${selected.title}的执行记录`}>
<div className="work-record-heading"><strong>{selected.title} · 执行记录</strong>
<button type="button" className="btn ghost" onClick={() => store.closeWorkRecord()}>返回工作</button></div>
<MessageTimestamp value={run?.createdAt} />
{failed ? <p role="status">完整记录暂不可用,请稍后重新打开</p>
: !result ? <p role="status">正在读取已保存的记录…</p>
: parts.length === 0 ? <p>这项工作没有已保存的过程输出</p>
: <ProcessTimeline parts={parts} live={false} idKey={`work-record:${selected.runId}`}
renderPart={part => <SafeMarkdown className="part-text" markdown={part.text ?? ''} breaks />} />}
</section>
}
+28 -9
View File
@@ -2,12 +2,14 @@
// 工作 = 当前任务卡 + 收件箱(待处理事项、后台任务卡、执行记录);
// 文件 = 文件列表(搜索/上传)与多标签预览(预览、编辑、下载)——预览不再是独立第四栏。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import type { Run, WorkCardAction } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
import { Icon, SurfaceView } from '@ui'
import { previewTabsKey } from '../previewtabs'
import { surfaceKey } from '../surfaceKey'
import { ArtifactsPanel } from './ArtifactsPanel'
import { PreviewPane } from './PreviewPane'
import { WorkExecutionRecord } from './WorkExecutionRecord'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
@@ -20,7 +22,10 @@ export const docBridge: DocBridge = {
}
const inboxCallbacks = {
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
onAction: (c: ActivityCard, id: string, payload?: WorkCardAction) => store.cardAction(c, id, payload),
onOpenRecord: (c: ActivityCard) => store.openWorkRecord(c),
onReference: (c: ActivityCard) => store.referenceWork(c),
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 +56,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)
@@ -60,6 +67,7 @@ export function Workbench() {
const filter = useStore(s => s.ui.inboxFilter)
const visible = inboxScope === 'project' ? projectCards : cards
const pending = pendingCount(visible)
const unread = visible.filter(card => card.work && card.work.resultVersion > card.work.readVersion).length
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
const switchTab = (tab: 'work' | 'files') =>
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
@@ -67,7 +75,7 @@ export function Workbench() {
<>
<div className="tabs">
<button className={`tab ${rightTab === 'work' ? 'active' : ''}`} onClick={() => switchTab('work')}
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}</button>
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}{unread > 0 ? ` · ${unread} 项新结果` : ''}</button>
<button className={`tab ${rightTab === 'files' ? 'active' : ''}`} onClick={() => switchTab('files')}
title="文件搜索、预览、编辑与下载">文件</button>
<span className="spacer" style={{ flex: 1 }} />
@@ -91,6 +99,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 +112,13 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
projectCards: ActivityCard[]
cards: ActivityCard[]
}) {
const record = useStore(s => s.workRecord)
const showCurrentTask = !hasActiveProgressCard(cards)
return (
<>
<div className="tab-body" data-scroll-sync="inbox">
<CurrentTaskCard plan={plan} todos={todos} />
<div className="tab-body" data-work-scroll>
{record ? <WorkExecutionRecord /> : <>
{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')}>
@@ -116,6 +132,7 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
? <ProjectInbox cards={projectCards} filter={filter} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
<WorkspaceSurfaces />
</>}
</div>
</>
)
@@ -180,9 +197,11 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
function WorkspaceSurfaces() {
const surfaces = useStore(s => s.surfaces)
const pin = useStore(s => s.ui.workspaceSurfacePin)
if (surfaces.length === 0) return null
const shown = pin ? surfaces.find(x => String(x.identity.surfaceId) === pin) : surfaces[surfaces.length - 1]
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">
@@ -190,10 +209,10 @@ function WorkspaceSurfaces() {
<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 : String(shown.identity.surfaceId) })}>
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : shownId })}>
{pin ? '📍' : '📌'}
</button>
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(String(shown.identity.surfaceId))}>
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(shownId)}>
↗
</button>
</div>
+7 -1
View File
@@ -8,12 +8,13 @@ import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import { DocumentCockpit } from './DocumentCockpit'
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。
* 追溯审计按命令面能力显隐(增量 2):store.rdmAuditAvailable 为 true 才入列。 */
export function workspaceEntries(): WorkspaceEntry[] {
const s = store.getState()
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable })
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable, rdmCockpit: s.rdmCockpitAvailable })
}
export function Workspaces() {
@@ -36,6 +37,11 @@ export function Workspaces() {
<RdmAuditWorkspace />
</OverlayHost>
}
if (active === 'rdm-cockpit') {
return <OverlayHost title="文档驾驶舱" icon="📋" shortcut={entry?.shortcut} sayHint="打开文档驾驶舱" onClose={close}>
<DocumentCockpit />
</OverlayHost>
}
if (active.startsWith('plugin:')) {
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
+476
View File
@@ -0,0 +1,476 @@
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 /
// 文档职责」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
import { useEffect, useState, useRef, useCallback } from 'react'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
import { useStore } from '../store'
import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui'
import {
ROLE_ACTIONS,
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
memberAddPayload, memberRemovePayload, memberSetRolePayload,
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>
)
}
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
interface MemberRow {
owner: string; role: string; addedBy: string; addedAt: string
profRole?: { roleId: string; name: string } | null
}
export function RdmMembersSection({ project }: { project: Project }) {
const projectId = project.id
const writeError = projectWorkError(project)
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
const [rows, setRows] = useState<MemberRow[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [picked, setPicked] = useState<string[]>([])
const [role, setRole] = useState<'member' | 'owner'>('member')
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
const rowsRef = useRef<MemberRow[] | null>(null)
rowsRef.current = rows
/** 人员选择器取数适配(P7-A):rdm.account.list 目录(member.manage 生效者)。
* query 透传后端(基座升级 AccountSearcher 后全量生效);旧基座忽略 query 时
* 对首页做本地包含匹配降级(翻页为浏览模式,不过滤)。 */
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
const out = await cmd<{ accounts?: { owner: string; name?: string; valid: boolean }[]; next?: string }>(
'rdm.account.list', { projectId, cursor, limit, query })
const joined = new Set((rowsRef.current ?? []).map(m => m.owner))
let items = (out.accounts ?? []).filter(a => a.valid).map(a => ({
id: a.owner,
title: a.name || a.owner,
subtitle: a.owner,
badge: joined.has(a.owner) ? '已加入' : undefined,
}))
if (query !== '' && cursor === '') {
const q = query.toLowerCase()
items = items.filter(it => it.id.toLowerCase().includes(q) || it.title.toLowerCase().includes(q))
}
return { items, nextCursor: out.next || undefined }
}, [project])
const refresh = async () => {
try {
const out = await cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
setRows(out.members ?? [])
} catch (e) { setErr(String(e)) }
}
useEffect(() => { setPicked([]); refresh() }, [project])
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) }
}
const pickedId = picked[0] ?? ''
return (
<div>
{writeError && <p className="ad-hint">{writeError}</p>}
<div className="ad-create">
<div className="ad-row">
<Picker
aria-label="选择人员"
selected={picked}
onSelect={setPicked}
fetchPage={fetchPage}
placeholder="输入账号或姓名,或浏览候选…"
/>
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</option>
<option value="owner">项目负责人</option>
</select>
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !pickedId}
onClick={() => run(`已添加 ${pickedId}`, async () => {
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedId, role))
setPicked([])
})}>添加成员</button>
</div>
<p className="ad-hint">候选来自系统账号目录(姓名为空时显示账号);「已加入」标记避免重复添加造成隐式变更——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
</div>
<table className="ad-table">
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
<tbody>
{(rows ?? []).map(m => (
<tr key={m.owner}>
<td><span className="ad-mono">{m.owner}</span>{m.profRole?.name ? <span className="ad-dim"> · {m.profRole.name}</span> : null}</td>
<td className="ad-dim">{m.profRole?.name || '—'}</td>
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</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 || !!writeError}
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
cmd('rdm.project.member.set-role', memberSetRolePayload(projectId, m.owner, m.role === 'owner' ? 'member' : 'owner')))}>
{m.role === 'owner' ? '降为成员' : '升为负责人'}
</button>{' '}
<button className="ad-mini" disabled={busy || !!writeError}
onClick={() => run(`已移除 ${m.owner}`,
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
</td>
</tr>
))}
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无成员</td></tr>}
{rows === null && <tr><td colSpan={6} 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>
)
}
export { RdmDutiesSection } from './duties/RdmDutiesSection'
// ---- 项目设置:项目权限(P7-C:单成员详情标签化 + 多人矩阵 + 三态覆盖;
// 与实际判定同源——UI 不重算,全部读服务端解释) ----
interface MatrixAction {
action: string; label?: string; scope?: string
allowed: boolean; source: string; reason?: string
}
interface MatrixOut {
projectId: string; member: string; role: string
profRole: { roleId: string; name: string } | null
actions: MatrixAction[]
}
interface MatrixRowOut {
member: string; role: string
profRole: { roleId: string; name: string } | null
actions: MatrixAction[]
}
interface MatrixBatchOut { projectId: string; rows: MatrixRowOut[] }
function actionTitle(a: MatrixAction) { return a.label || a.action }
/** 动作行:中文标签为主,内部标识进 title 技术详情(方案 §5)。 */
function ActionDetailTable({ actions, onOverride, canManage }: {
actions: MatrixAction[]
onOverride?: (action: string, mode: 'allow' | 'deny' | 'default') => void
canManage?: boolean
}) {
const project = actions.filter(a => a.scope !== 'system')
const system = actions.filter(a => a.scope === 'system')
const overrideOf = (a: MatrixAction): 'allow' | 'deny' | 'default' =>
a.source.includes('override') || a.source === 'project' ? (a.allowed ? 'allow' : 'deny') : 'default'
return (
<>
<table className="ad-table">
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因(拒绝时)</th>{canManage ? <th>项目覆盖</th> : null}</tr></thead>
<tbody>
{project.map(a => (
<tr key={a.action}>
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</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>
{canManage ? (
<td>
<select className="lg-input" value={overrideOf(a)} style={{ minWidth: 120 }}
onChange={e => onOverride?.(a.action, e.target.value as 'allow' | 'deny' | 'default')}>
<option value="default">沿用角色默认</option>
<option value="allow">项目内允许</option>
<option value="deny">项目内禁止</option>
</select>
</td>
) : null}
</tr>
))}
</tbody>
</table>
{system.length > 0 && (
<Collapsible trigger={`系统范围动作(${system.length} 项,不在项目内配置)`}>
<table className="ad-table">
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因</th></tr></thead>
<tbody>
{system.map(a => (
<tr key={a.action}>
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</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>
</Collapsible>
)}
</>
)
}
export function RdmPermMatrixSection({ project }: { project: Project }) {
const projectId = project.id
const writeError = projectWorkError(project)
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
const me = useStore(s => s.me)
const [view, setView] = useState<'member' | 'matrix'>('member')
const [member, setMember] = useState('')
const [members, setMembers] = useState<MemberRow[] | null>(null)
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
const [batch, setBatch] = useState<MatrixBatchOut | null>(null)
const [expand, setExpand] = useState('')
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const loadMembers = () =>
cmd<{ 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)))
useEffect(() => { loadMembers() }, [project, me?.owner]) // eslint-disable-line react-hooks/exhaustive-deps
const query = async (m: string) => {
if (!m) return
setErr(''); setBusy(true); setMatrix(null)
try {
setMatrix(await cmd<MatrixOut>('rdm.perm.check', permCheckPayload(projectId, m)))
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const queryBatch = async () => {
setErr(''); setBusy(true); setBatch(null)
try {
setBatch(await cmd<MatrixBatchOut>('rdm.perm.matrix', { projectId }))
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
useEffect(() => { if (view === 'matrix') queryBatch() }, [view]) // eslint-disable-line react-hooks/exhaustive-deps
/** 三态覆盖写回:default=实际撤销(perm.unset);allow/deny=perm.set。 */
const override = async (m: string, action: string, mode: 'allow' | 'deny' | 'default') => {
if (writeError) { setErr(writeError); return }
setErr(''); setNotice(''); setBusy(true)
try {
if (mode === 'default') {
await cmd('rdm.perm.unset', { projectId, member: m, action })
setNotice(`已恢复默认(撤销 ${action} 的项目覆盖)`)
} else {
await cmd('rdm.perm.set', { projectId, member: m, action, allow: mode === 'allow' })
setNotice(mode === 'allow' ? `已在项目内允许 ${action}` : `已在项目内禁止 ${action}`)
}
await query(m)
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const canManage = !writeError && !!matrix && matrix.role !== undefined
return (
<div>
{writeError && <p className="ad-hint">{writeError}</p>}
<div className="ad-create">
<div className="ad-row">
<select className="lg-input" value={view} onChange={e => setView(e.target.value as 'member' | 'matrix')}>
<option value="member">按成员查看(默认自省)</option>
<option value="matrix">权限矩阵(成员 × 动作,须管理权)</option>
</select>
{view === 'member' && (
<>
<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>
{view === 'member' && matrix && (
<>
<div className="ad-row">
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目身份 <b>{matrix.role === 'owner' ? '项目负责人' : matrix.role === 'member' ? '项目成员' : matrix.role}</b>
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b></>}
</span>
</div>
<ActionDetailTable actions={matrix.actions ?? []}
canManage={canManage}
onOverride={(action, mode) => override(matrix.member, action, mode)} />
</>
)}
{view === 'member' && !matrix && !err && members !== null && <p className="ad-hint">选择成员后展示各动作的有效权限、来源与第一拒绝原因。</p>}
{view === 'matrix' && batch && (
<table className="ad-table" style={{ overflowX: 'auto', display: 'block' }}>
<thead><tr>
<th>成员</th>
{(batch.rows[0]?.actions ?? []).filter(a => a.scope !== 'system').map(a => (
<th key={a.action} title={`内部标识 ${a.action}`}>{actionTitle(a)}</th>
))}
</tr></thead>
<tbody>
{batch.rows.map(row => (
<tr key={row.member}>
<td>
<button className="ad-mini" onClick={() => setExpand(expand === row.member ? '' : row.member)}>
{row.member}
</button>
<span className="ad-dim"> · {row.role === 'owner' ? '负责人' : '成员'}</span>
</td>
{row.actions.filter(a => a.scope !== 'system').map(a => (
<td key={a.action} title={`${a.label ?? a.action}:${a.allowed ? '允许' : '拒绝'}(${describeSource(a.source)}${a.reason ? ';' + a.reason : ''})`}>
{a.allowed ? <span className="ad-chip ad-chip-admin">✓</span> : <span className="ad-chip ad-chip-warn">✗</span>}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
{view === 'matrix' && batch && expand && (
<div className="settings-group" style={{ marginTop: 12 }}>
<h4>{expand} · 单成员详情与三态覆盖</h4>
{(() => {
const row = batch.rows.find(r => r.member === expand)
if (!row) return null
return <ActionDetailTable actions={row.actions} canManage={!writeError}
onOverride={(action, mode) => override(expand, action, mode)} />
})()}
</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>
)
}
+138 -35
View File
@@ -2,15 +2,20 @@
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
import { useEffect, useState } from 'react'
import { api } from '../api'
import { api, type RecoveryProposal } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile } from '../contracts'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
import { SettingInput, flatten, unflatten } from './SettingsModal'
import type { SettingDesc } from '../contracts'
import { docBridge } from './Workbench'
import { randomPassword } from '../passwords'
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery'
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制)----
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制):建号仅创建待初始化
// 账号(HTTP 契约不含密码与管理员位),初始凭证由服务器本地 CLI 发放激活 Key;
// 恢复走属主确认制提案:管理员提案 → 用户在「设置 → 账号」本人确认 → 一次性 Key
// 只在属主会话内出现(直接签发 Key 的 HTTP 入口已被 410 替换)。----
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
export function UsersPanel() {
const me = useStore(s => s.me)
@@ -19,39 +24,51 @@ export function UsersPanel() {
const [notice, setNotice] = useState('')
const [owner, setOwner] = useState('')
const [name, setName] = useState('')
const [pw, setPw] = useState('')
const [asAdmin, setAsAdmin] = useState(false)
const [busy, setBusy] = useState(false)
const [resetFor, setResetFor] = useState<string | null>(null)
const [resetPw, setResetPw] = useState('')
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
const [propFor, setPropFor] = useState<AdminUserRow | null>(null)
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
}
const refreshProposals = async (userId: string) => {
try { setProposals(await api.listAdminRecoveryProposals(userId)) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const create = async () => {
setErr(''); setNotice('')
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
setBusy(true)
try {
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
setOwner(''); setName(''); setPw('')
const out = await api.createAdminUser(owner.trim(), name.trim())
setNotice(`已创建 ${out.owner}(待初始化):请在服务器执行 user initialize-account 发放激活 Key`)
setOwner(''); setName('')
await refresh()
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const reset = async () => {
if (!resetFor) return
setErr('')
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
const openProposals = async (u: AdminUserRow) => {
setErr(''); setNotice(''); setPropFor(u); setProposals(null)
await refreshProposals(u.userId)
}
const propose = async () => {
if (!propFor) return
setErr(''); setNotice(''); setBusy(true)
try {
await api.resetAdminUserPassword(resetFor, resetPw)
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
setResetFor(null); setResetPw('')
await refresh()
await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID())
setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`)
await refreshProposals(propFor.userId)
} catch (e) { setErr(recoveryProposalErrorMessage(String(e))) } finally { setBusy(false) }
}
const revokeProposal = async (p: RecoveryProposal) => {
if (!propFor) return
setErr(''); setBusy(true)
try {
await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version)
await refreshProposals(propFor.userId)
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
@@ -62,25 +79,40 @@ export function UsersPanel() {
<div className="ad-row">
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
</div>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
</div>
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码。</p>
<p className="ad-hint">账号创建后处于待初始化状态:在服务器执行 user initialize-account 发放一次性激活 Key,用户经兑换入口设置密码后登录</p>
</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>}
{resetFor ? (
{propFor ? (
<div className="ad-reset">
<span>重置 <b>{resetFor}</b> 的密码:</span>
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
<button className="lg-submit ad-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
<button type="button" className="ad-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
<div className="ad-row">
<span><b>{propFor.owner}</b> 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key)</span>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={propose}>发起恢复提案</button>
<button type="button" className="ad-mini" onClick={() => { setPropFor(null); setProposals(null) }}>关闭</button>
</div>
<p className="ad-hint">前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。</p>
{proposals !== null && proposals.length > 0 && (
<table className="ad-table">
<thead><tr><th>提案</th><th>状态</th><th>到期时间</th><th>发起人</th><th></th></tr></thead>
<tbody>
{proposals.map(p => (
<tr key={p.id}>
<td className="ad-mono">{p.id}</td>
<td>{recoveryStatusLabel[p.status]}{recoveryProposalHint(p.status) && <span className="ad-dim"> · {recoveryProposalHint(p.status)}</span>}</td>
<td className="ad-dim">{new Date(p.expiresAt).toLocaleString()}</td>
<td className="ad-dim">{p.createdBy}</td>
<td>{(p.status === 'pending' || p.status === 'confirmed') && (
<button className="ad-mini" disabled={busy} onClick={() => revokeProposal(p)}>撤销</button>
)}</td>
</tr>
))}
</tbody>
</table>
)}
{proposals !== null && proposals.length === 0 && <p className="ad-hint">该用户当前没有恢复提案。</p>}
</div>
) : (
<table className="ad-table">
@@ -94,7 +126,7 @@ export function UsersPanel() {
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (
<button className="ad-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
)}</td>
</tr>
))}
@@ -240,3 +272,74 @@ export function PluginWorkspacesPanel() {
</div>
)
}
// ---- 模型与服务配置(P2 系统受管项的唯一写入面)----
/**
* SystemSettingsPanel:消费 GET/PUT /api/settings/system。
* - 仅渲染 schema 中 scope=system 的受管项(模型、密钥、Tavily、遥测、运行基础设施);
* 非受管项属于用户/项目偏好,不在本面板出现。
* - 保存后重新 GET 以服务端结果回填(保存成功 ≠ 本地草稿即真相);掩码占位符
* 原样传回,由服务端视为未修改。
* - 权限不足(非管理员)由分区门控与 API 403 双层拦截,403 时如实提示。
*/
export function SystemSettingsPanel() {
const [schema, setSchema] = useState<SettingDesc[] | null>(null)
const [draft, setDraft] = useState<Record<string, any>>({})
const [loaded, setLoaded] = useState(false)
const [forbidden, setForbidden] = useState(false)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const load = async () => {
setErr(''); setForbidden(false)
try {
const out = await api.getSystemSettings()
setSchema(out.schema.filter(d => d.scope === 'system'))
setDraft(flatten(out.system))
setLoaded(true)
} catch (e) {
if (String(e).includes('403')) { setForbidden(true); setLoaded(true) }
else setErr(String(e))
}
}
useEffect(() => { load() }, [])
const groups: Record<string, SettingDesc[]> = {}
for (const d of schema ?? []) (groups[d.group] ??= []).push(d)
const save = async () => {
setErr(''); setNotice(''); setBusy(true)
try {
await api.putSystemSettings(unflatten(draft))
setNotice('已保存;按各项标注的生效时机生效(需重启的项在重启后生效)')
await load()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
if (!loaded) return <EmptyState icon="settings">加载系统设置…</EmptyState>
if (forbidden) return <EmptyState icon="shield">系统设置仅管理员可配置</EmptyState>
return (
<div>
<p className="ad-hint" style={{ marginBottom: 14 }}>
模型、密钥与运行基础设施为系统受管配置:仅此面板(管理员)可写,用户与项目设置中的同名项不再生效。
</p>
{Object.entries(groups).map(([group, descs]) => (
<SettingsGroup key={group} title={group === 'models' ? '模型' : group === 'tools' ? '工具服务' : group === 'telemetry' ? '遥测' : group === 'runtime' ? '运行基础设施' : group}>
{descs.map(d => (
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
<SettingInput desc={d} value={draft[d.key]} onChange={v => setDraft(prev => ({ ...prev, [d.key]: v }))} />
</SettingRow>
))}
</SettingsGroup>
))}
<div className="ad-row" style={{ marginTop: 12 }}>
<Btn variant="primary" icon="check" disabled={busy} onClick={save}>保存系统配置</Btn>
</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>
)
}
+253
View File
@@ -0,0 +1,253 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../../api'
import type { Project } from '../../contracts'
import { projectWorkError } from '../../projectmeta'
import { Btn, Collapsible, EmptyState, Icon } from '@ui'
import { dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload } from '../../rdmManagement'
import {
absorbServerDocs, dirtyDocs, editCell, savePayload,
type CellDuty, type DutyDocDraft, type DutyMember,
} from '../../duties/draft'
import { DutyByDocumentView, DutyByMemberView, DutyChangePreview, DutyMatrixView } from '../../duties/views'
type ViewName = 'member' | 'document' | 'matrix'
interface MatrixDoc {
docId: string
title: string
code?: string
typeKey: string
stage?: string
dutyVersion: number
activeRound?: number
legacySectionOverrides?: number
}
interface MatrixOut {
canAssign: boolean
docs: MatrixDoc[]
members: DutyMember[]
cells: { docId: string; owner: string; duty: string }[]
nextDocCursor?: string
nextMemberCursor?: string
}
interface PreviewOut {
ok: boolean
docs: {
docId: string
title?: string
replacedAuthor?: string
replacedApprover?: string
needsConfirm?: boolean
reasons?: string[]
}[]
}
interface LegacyOut {
findings: { docId: string; title: string; sectionTitle: string; kind: string }[]
}
export function RdmDutiesSection({ project }: { project: Project }) {
const projectId = project.id
const writeError = projectWorkError(project)
const [view, setView] = useState<ViewName>('member')
const [docs, setDocs] = useState<DutyDocDraft[]>([])
const [members, setMembers] = useState<DutyMember[]>([])
const [member, setMember] = useState('')
const [docId, setDocId] = useState('')
const [query, setQuery] = useState('')
const [stage, setStage] = useState('')
const [typeKey, setTypeKey] = useState('')
const [assignment, setAssignment] = useState('')
const [docCursor, setDocCursor] = useState('')
const [nextDoc, setNextDoc] = useState('')
const [canAssign, setCanAssign] = useState(false)
const [narrow, setNarrow] = useState(false)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [preview, setPreview] = useState<PreviewOut['docs']>([])
const [confirm, setConfirm] = useState(false)
const [legacy, setLegacy] = useState<LegacyOut['findings']>([])
const [legacyOpen, setLegacyOpen] = useState(false)
const seq = useRef(0)
const op = useRef(0)
const docsRef = useRef(docs)
docsRef.current = docs
const load = async (cursor: string, request = ++seq.current, replaceDirty = false) => {
const seenMembers: DutyMember[] = []
const seenCells: MatrixOut['cells'] = []
let pageDocs: MatrixDoc[] = []
let memberCursor = ''
let nextDocument = ''
let assignable = false
try {
do {
const page = await api.rdmProjectCommand<MatrixOut>(project, 'rdm.duty.matrix', dutyMatrixPayload(projectId, {
view: 'matrix', query, stage, typeKey, assignment, docCursor: cursor, memberCursor, docLimit: 10, memberLimit: 20,
}))
if (request !== seq.current) return
if (pageDocs.length === 0) pageDocs = page.docs ?? []
seenMembers.push(...(page.members ?? []))
seenCells.push(...(page.cells ?? []))
assignable = page.canAssign
nextDocument = page.nextDocCursor ?? ''
memberCursor = page.nextMemberCursor ?? ''
} while (memberCursor)
if (request !== seq.current) return
setMembers(seenMembers)
setCanAssign(assignable)
setNextDoc(nextDocument)
setDocs(current => absorbServerDocs(current, pageDocs, seenCells, replaceDirty))
} catch (error) {
if (request !== seq.current) return
setErr(String(error))
}
}
useEffect(() => {
const media = window.matchMedia('(max-width: 720px)')
const apply = () => setNarrow(media.matches)
apply()
media.addEventListener('change', apply)
return () => media.removeEventListener('change', apply)
}, [])
useEffect(() => {
void load(docCursor)
return () => { seq.current += 1 }
}, [project, query, stage, typeKey, assignment, docCursor])
const onCell = (id: string, owner: string, duty: CellDuty) => {
setPreview([])
setConfirm(false)
setDocs(current => editCell(current, id, owner, duty))
}
const visibleDocs = docs.filter(doc => {
const text = `${doc.title} ${doc.code} ${doc.docId}`.toLowerCase()
if (query && !text.includes(query.trim().toLowerCase())) return false
if (stage && doc.stage !== stage) return false
if (typeKey && doc.typeKey !== typeKey) return false
return true
})
const save = async () => {
const payload = savePayload(docsRef.current, confirm)
if (payload.issues.length > 0) {
setErr(payload.issues.join(';'))
return
}
const request = ++seq.current
const ticket = ++op.current
setBusy(true); setErr(''); setNotice('')
let sent = false
try {
const looked = await api.rdmProjectCommand<PreviewOut>(project, 'rdm.duty.preview', dutyPreviewPayload(projectId, payload.docs))
if (request !== seq.current) return
setPreview(looked.docs ?? [])
if (!looked.ok) {
setErr('预览未通过,未写入')
return
}
if ((looked.docs ?? []).some(item => item.needsConfirm) && !confirm) {
setErr('在途审签将失效。确认后再保存。')
return
}
sent = true
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm).docs))
if (request !== seq.current) return
setNotice('已保存文档职责')
setPreview([])
setConfirm(false)
setDocs(current => current.map(doc => ({ ...doc, dirty: false })))
await load(docCursor, request)
} catch (error) {
if (request !== seq.current) return
if (!sent) {
setErr(String(error))
return
}
setErr(`保存结果未确认:${error}`)
setConfirm(false)
await load(docCursor, request, true)
} finally {
if (ticket === op.current) setBusy(false)
}
}
const scanLegacy = async (request = ++seq.current) => {
try {
const out = await api.rdmProjectCommand<LegacyOut>(project, 'rdm.duty.legacy.scan', dutyLegacyPayload(projectId))
if (request !== seq.current) return
setLegacy(out.findings ?? [])
} catch (error) {
if (request !== seq.current) return
setErr(String(error))
}
}
const applyIdentical = async () => {
const request = ++seq.current
const ticket = ++op.current
setBusy(true)
try {
await api.rdmProjectCommand(project, 'rdm.duty.legacy.apply', dutyLegacyPayload(projectId))
if (request !== seq.current) return
setNotice('已归并无歧义的章节职责')
await scanLegacy(request)
await load(docCursor, request)
} catch (error) {
if (request !== seq.current) return
setErr(String(error))
} finally {
if (ticket === op.current) setBusy(false)
}
}
const disabled = busy || !!writeError || !canAssign
const ambiguous = legacy.filter(item => item.kind !== 'identical')
return (
<div>
<p className="ad-hint">本页只分配已有文档的职责,不创建或生成文档。</p>
{writeError && <p className="ad-hint">{writeError}</p>}
<div className="ad-row" role="tablist" aria-label="职责视图">
<button className="ad-mini" role="tab" aria-selected={view === 'member'} onClick={() => setView('member')}>按人员</button>
<button className="ad-mini" role="tab" aria-selected={view === 'document'} onClick={() => setView('document')}>按文档</button>
<button className="ad-mini" role="tab" aria-selected={view === 'matrix'} onClick={() => setView('matrix')}>矩阵</button>
</div>
<div className="ad-row">
<input aria-label="筛选文档名称或编号" value={query} placeholder="名称或编号" disabled={busy} onChange={event => { setDocCursor(''); setQuery(event.target.value) }} />
<input aria-label="筛选类型" value={typeKey} placeholder="类型" disabled={busy} onChange={event => { setDocCursor(''); setTypeKey(event.target.value) }} />
<input aria-label="筛选阶段" value={stage} placeholder="阶段" disabled={busy} onChange={event => { setDocCursor(''); setStage(event.target.value) }} />
<select aria-label="筛选当前职责" value={assignment} disabled={busy} onChange={event => { setDocCursor(''); setAssignment(event.target.value) }}>
<option value="">全部</option>
<option value="assigned">已分配</option>
<option value="unassigned">未分配</option>
</select>
</div>
{view === 'member' && <DutyByMemberView member={member} members={members} docs={visibleDocs} disabled={disabled} onMember={setMember} onCell={onCell} />}
{view === 'document' && <DutyByDocumentView docId={docId} docs={visibleDocs} members={members} disabled={disabled} onDoc={setDocId} onCell={onCell} />}
{view === 'matrix' && <DutyMatrixView docs={visibleDocs} members={members} disabled={disabled} narrow={narrow} onCell={onCell} />}
<div className="ad-row">
{docCursor && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor('')}>文档首页</button>}
{nextDoc && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor(nextDoc)}>下一页文档</button>}
<Btn variant="primary" icon="check" disabled={disabled || dirtyDocs(docs).length === 0} onClick={() => { void save() }}>保存职责</Btn>
</div>
<DutyChangePreview impacts={preview} confirm={confirm} onConfirm={setConfirm} />
<Collapsible id="duty-legacy" open={legacyOpen} onToggle={setLegacyOpen} trigger="旧章节职责">
<button className="ad-mini" disabled={busy} onClick={() => { void scanLegacy() }}>扫描</button>
<button className="ad-mini" disabled={disabled} onClick={() => { void applyIdentical() }}>归并无歧义覆盖</button>
{legacy.length === 0 ? <p className="ad-hint">尚未扫描,或没有章节级覆盖。</p> : (
<ul>
{legacy.map(item => <li key={`${item.docId}-${item.sectionTitle}`}>{item.title} / {item.sectionTitle}:{item.kind === 'identical' ? '与文档职责一致' : '有歧义,需逐文档确认'}</li>)}
</ul>
)}
{ambiguous.length > 0 && <p className="ad-hint">有歧义的文档不会自动覆盖。请在上面的视图里逐份确认后再保存。</p>}
</Collapsible>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{docs.length === 0 && !err && <EmptyState icon="inbox">暂无文档</EmptyState>}
</div>
)
}
+17 -1
View File
@@ -151,7 +151,21 @@ export interface ActivityCard {
sourceRunId?: ID; anchorMessageId?: ID;
kind: CardKind; title: string; status: CardStatus; tone?: string;
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
pinned?: boolean; createdAt: string; updatedAt: string;
pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard;
}
export interface WorkCard {
result?: string; resultVersion: number; readVersion: number; completedAt?: string;
decision?: WorkDecision;
}
export interface WorkDecision {
runId?: ID;
mode: 'single' | 'multiple'; state: 'open' | 'deferred' | 'dispatching' | 'submitted';
options?: { id: string; label: string }[];
selectedIds?: string[]; freeText?: string; respondedAt?: string;
}
export interface WorkCardAction {
resultVersion: number; selectedIds?: string[]; freeText?: string;
}
export interface CardPayload { card: ActivityCard }
@@ -270,6 +284,8 @@ export interface SettingDesc {
default?: unknown; options?: string[];
effect: 'hot' | 'new-run' | 'restart';
secret?: boolean;
/** P2 受管:system = 系统受管项,仅系统管理面可写(用户/项目层拒绝)。 */
scope?: 'system';
}
// ---- 插件 ----
+214
View File
@@ -0,0 +1,214 @@
// 文档驾驶舱 I2 纯逻辑层测试:能力门控、筛选切换、统计呈现(真 0 vs 不可算)、
// 快照一致性/更新提示、上下文键、选择保留、树徽标。组件取数编排不在此覆盖。
import { describe, expect, it } from 'vitest'
import {
contextKey, nodeSummaryLine, sameSnapshot, selectionSurvives, statDisplay,
STAT_FILTERS, toggleFilter, treeBadges, updateNotice,
type CockpitMeta, type CockpitStats, type CockpitTreeNode,
} from './documentCockpit'
import { cockpitReady, COCKPIT_COMMANDS } from './workspaces'
const meta = (rev: string, seq: number): CockpitMeta => ({
schemaVersion: 'document-cockpit.v1', projectId: 'p1', snapshotRevision: rev,
asOfSeq: seq, generatedAt: '2026-10-16T00:00:00Z', truncated: false,
})
describe('cockpitReady', () => {
const all = COCKPIT_COMMANDS.map(id => ({ id, enabled: true }))
it('七命令齐备且 enabled 才就绪', () => {
expect(cockpitReady(null)).toBe(false)
expect(cockpitReady([])).toBe(false)
expect(cockpitReady(all.map(c => ({ ...c, enabled: false })))).toBe(false)
expect(cockpitReady(all.slice(0, 6))).toBe(false)
expect(cockpitReady(all)).toBe(true)
})
})
describe('统计呈现:真 0 与不可算不得互相冒充', () => {
const stats: CockpitStats = {
documentCount: 0,
completion: { known: 3, total: 5, state: 'known' },
emptyNodeCount: 0,
unverifiedSourceCount: 2,
conflictCount: null as unknown as number,
pendingChangeCount: null,
updatedAt: '',
statReasons: { conflictCount: 'upstream_error', pendingChangeCount: 'not_projected_v1' },
}
it('真 0 显示 0', () => {
expect(statDisplay(stats, 'empty').value).toBe('0')
expect(statDisplay(stats, 'documents').value).toBe('0')
})
it('null 显示 — 并带原因', () => {
const d = statDisplay(stats, 'conflict')
expect(d.value).toBe('—')
expect(d.hint).toBe('upstream_error')
})
it('未纳入投影的统计显式标注原因', () => {
const d = statDisplay(stats, 'pending')
expect(d.value).toBe('—')
expect(d.hint).toBe('not_projected_v1')
})
it('完成度三分态:known/partial/unavailable', () => {
expect(statDisplay(stats, 'completion').value).toBe('3/5')
expect(statDisplay({ ...stats, completion: { known: 1, total: 5, state: 'partial' } }, 'completion').hint).toBe('部分计算')
const u = statDisplay({ ...stats, completion: { known: 0, total: 0, state: 'unavailable' } }, 'completion')
expect(u.value).toBe('—')
})
})
describe('筛选切换', () => {
it('点击切换、再点清除;统计键与筛选值一一对应', () => {
expect(toggleFilter('', 'state:empty')).toBe('state:empty')
expect(toggleFilter('state:empty', 'state:empty')).toBe('')
expect(toggleFilter('state:empty', 'state:conflict')).toBe('state:conflict')
expect(STAT_FILTERS.map(s => s.filter)).toEqual(['state:empty', 'state:unverified', 'state:conflict'])
})
})
describe('快照一致性与更新提示', () => {
it('同 revision 同 seq 视为同快照', () => {
expect(sameSnapshot(meta('wm:5', 5), meta('wm:5', 5))).toBe(true)
expect(sameSnapshot(meta('wm:5', 5), meta('wm:6', 6))).toBe(false)
expect(sameSnapshot(undefined, meta('wm:5', 5))).toBe(false)
})
it('展示快照落后时提示刷新,一致时不提示', () => {
expect(updateNotice(meta('wm:5', 5), meta('wm:6', 6))).toContain('wm:6')
expect(updateNotice(meta('wm:6', 6), meta('wm:6', 6))).toBeNull()
expect(updateNotice(null, meta('wm:6', 6))).toBeNull()
})
})
describe('旧响应丢弃的上下文键', () => {
it('项目与筛选任一变化都改变键', () => {
expect(contextKey('p1', '')).not.toBe(contextKey('p2', ''))
expect(contextKey('p1', '')).not.toBe(contextKey('p1', 'state:empty'))
expect(contextKey('p1', 'state:empty')).toBe(contextKey('p1', 'state:empty'))
})
})
describe('刷新不重置选择', () => {
it('文档仍在根清单则保留', () => {
const kept = selectionSurvives({ kind: 'document', id: 'd1' }, ['d1', 'd2'])
expect(kept).toEqual({ kind: 'document', id: 'd1' })
})
it('文档被筛掉则清空', () => {
expect(selectionSurvives({ kind: 'document', id: 'd9' }, ['d1', 'd2'])).toBeNull()
})
it('章节选择随父文档存活', () => {
expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d1'], 'd1')).toEqual({ kind: 'section', id: 's1' })
expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d2'], 'd1')).toBeNull()
expect(selectionSurvives(null, ['d1'])).toBeNull()
})
})
describe('树徽标与状态行', () => {
const node = (over: Partial<CockpitTreeNode['state']> & { visibility?: string }): CockpitTreeNode => ({
ref: { kind: 'section', id: 's1' }, title: '章节', path: [], childCount: 0,
visibility: over.visibility || 'full',
state: {
content: 'complete', verification: 'not_applicable', sourceCount: 0,
conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true, ...over,
},
} as CockpitTreeNode)
it('诚实徽标:空/受限/未核实/冲突计数', () => {
expect(treeBadges(node({}))).toEqual([])
expect(treeBadges(node({ content: 'empty' }))).toContain('空')
expect(treeBadges(node({ verification: 'unverified' }))).toContain('未核实')
expect(treeBadges(node({ conflictCount: 2 }))).toContain('冲突 2')
expect(treeBadges(node({ visibility: 'metadata_only' }))).toContain('仅元数据')
})
it('结构缺口显式标注(冻结事实:doc→section 两级)', () => {
const line = nodeSummaryLine(node({ content: 'empty' }))
expect(line).toContain('内容为空')
expect(line).toContain('层级止于章节')
})
})
// ---- I3:阅读器与右栏纯逻辑 ----
import {
assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary,
prefsKey, mergePrefs, loadPrefs,
type CockpitHistorySummary, type CockpitVersionSummary,
} from './documentCockpit'
describe('偏好:按用户+项目隔离,枚举外脏值回落默认', () => {
it('prefsKey 区分用户与项目', () => {
expect(prefsKey('a', 'p1')).not.toBe(prefsKey('a', 'p2'))
expect(prefsKey('a', 'p1')).not.toBe(prefsKey('b', 'p1'))
})
it('mergePrefs 逐项校验', () => {
expect(mergePrefs(null)).toEqual({ lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' })
expect(mergePrefs({ lastView: 'reader', density: 'full', rightPanelTab: 'conflicts' })).toEqual({
lastView: 'reader', density: 'full', rightPanelTab: 'conflicts',
})
const junk = mergePrefs({ lastView: 'galaxy', density: 42, rightPanelTab: 'nope' })
expect(junk.lastView).toBe('panorama')
expect(junk.density).toBe('compact')
expect(junk.rightPanelTab).toBe('sources')
})
it('loadPrefs 在无 localStorage 环境安全回落', () => {
expect(loadPrefs('a', 'p1').lastView).toBe('panorama')
})
})
describe('版本摘要与行级差异', () => {
const v = (seq: number, content?: string, redacted?: boolean): CockpitVersionSummary => ({
seq, version: `v${seq}`, at: '2026-10-16T00:00:00Z', basisCount: 0, content, redacted,
})
it('当前+前一版;受限整体降级', () => {
const s = versionSummary([v(3, '三'), v(2, '二'), v(1, '一')])
expect(s.current?.version).toBe('v3')
expect(s.previous?.version).toBe('v2')
expect(s.redacted).toBe(false)
const r = versionSummary([v(3, undefined, true)])
expect(r.redacted).toBe(true)
expect(versionSummary(undefined).current).toBeUndefined()
})
it('diffLines 标记新增/删除/保留', () => {
const d = diffLines('共同\n新增行', '共同\n删除行')
expect(d).toContainEqual({ flag: '=', text: '共同' })
expect(d).toContainEqual({ flag: '+', text: '新增行' })
expect(d).toContainEqual({ flag: '-', text: '删除行' })
})
})
describe('历史严格合并:乱序与重复丢弃(DV-R05)', () => {
const h = (seq: number): CockpitHistorySummary => ({ seq, at: '', op: 'x', summary: `s${seq}` })
it('只接受 seq 更大的条目', () => {
const r = mergeHistoryStrict([h(10), h(9)], [h(11), h(8)])
expect(r.items.map(i => i.seq)).toEqual([10, 9, 11])
expect(r.dropped).toBe(1)
})
it('重复 seq 丢弃且计入 dropped', () => {
const r = mergeHistoryStrict([h(10)], [h(10), h(11)])
expect(r.items.map(i => i.seq)).toEqual([10, 11])
expect(r.dropped).toBe(1)
})
it('空历史接受全部', () => {
expect(mergeHistoryStrict([], [h(1), h(2)]).items.map(i => i.seq)).toEqual([1, 2])
})
it('historyLine 带操作者与本地时间', () => {
expect(historyLine(h(1))).toContain('s1')
})
})
describe('阅读器装配:受限章节给占位不冒充空内容', () => {
const sec = (id: string, content: string): CockpitTreeNode => ({
ref: { kind: 'section', id }, title: id, path: [], childCount: 0,
state: { content, verification: 'not_applicable', sourceCount: 0, conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true },
visibility: 'full',
})
it('正文/受限/未加载三分态', () => {
const out = assembleReader(
[sec('a', 'complete'), sec('b', 'complete'), sec('c', 'empty')],
{
a: { body: [{ kind: 'text', text: '正文A' }] },
b: { body: [{ kind: 'restricted', reason: 'section-duty-or-authorship' }] },
},
)
expect(out[0].blocks).toEqual([{ kind: 'text', text: '正文A' }])
expect(out[1].blocks[0].kind).toBe('restricted')
expect(out[2].blocks[0].reason).toBe('not_loaded')
})
})
+342
View File
@@ -0,0 +1,342 @@
// 文档驾驶舱 I2 纯逻辑层(rollout I2;契约:Dev 仓 docs/superpowers/specs
// 2026-10-16-document-visualization-read-model-contract.md @ 37b272ba,
// 四项取舍冻结见 plans/…-rollout.md §7)。只放可测判定:筛选切换、统计呈现
//(真 0 vs 不可算)、快照一致性与更新提示、上下文键与选择保留。组件只做取数编排与渲染。
// ---- 契约类型(I1 后端 DTO 的前端精简形态) ----
export interface CockpitNodeRef { kind: string; id: string; version?: string }
export interface CockpitMeta {
schemaVersion: string
projectId: string
snapshotRevision: string
asOfSeq: number
generatedAt: string
truncated: boolean
truncationReasons?: string[]
}
export interface CockpitNodeState {
content: string // complete|partial|empty|inaccessible|unavailable
verification: string // verified|mixed|unverified|disputed|not_applicable
sourceCount: number
conflictCount: number
impactCount: number
missingChildCount: number
structuralGap: boolean
}
export interface CockpitTreeNode {
ref: CockpitNodeRef
title: string
path: string[]
parent?: CockpitNodeRef
childCount: number
state: CockpitNodeState
lastChangedAt?: string
lastChangedBy?: string
visibility: string // full|redacted|metadata_only
}
export interface CockpitStats {
documentCount: number
completion: { known: number; total: number; state: string }
emptyNodeCount: number
unverifiedSourceCount: number
conflictCount: number
pendingChangeCount: number | null
updatedAt?: string
statReasons: Record<string, string>
}
export interface CockpitChangeSummary {
node: CockpitNodeRef
title: string
changedAt: string
actor?: string
kind: string
summary: string
version?: string
}
export interface CockpitOverview {
meta: CockpitMeta
project: { title: string }
stats: CockpitStats
documents: CockpitTreeNode[]
recentChanges: CockpitChangeSummary[]
}
export interface CockpitTreePage {
meta: CockpitMeta
items: CockpitTreeNode[]
nextCursor?: string
hasMore: boolean
}
// ---- 统计带:点击即切换共享筛选(视图规格 §2;未知筛选由后端 400 拒绝) ----
export const STAT_FILTERS: { key: string; label: string; filter: string }[] = [
{ key: 'empty', label: '空缺节点', filter: 'state:empty' },
{ key: 'unverified', label: '未核实来源', filter: 'state:unverified' },
{ key: 'conflict', label: '冲突', filter: 'state:conflict' },
]
/** 点击统计卡:同筛选再次点击=清除(只定位,不执行写入)。 */
export function toggleFilter(current: string, filter: string): string {
return current === filter ? '' : filter
}
/** 统计呈现:null(不可算)显示 — 加原因;真 0 显示 0。两侧都不得互相冒充(契约 §2/§6)。 */
export function statDisplay(stats: CockpitStats, key: string): { value: string; hint?: string } {
const reasons = stats.statReasons || {}
if (key === 'documents') return { value: String(stats.documentCount) }
if (key === 'completion') {
const c = stats.completion
if (c.state === 'unavailable') return { value: '—', hint: reasons['completion'] || '无法计算' }
if (c.state === 'partial') return { value: `${c.known}/${c.total}`, hint: '部分计算' }
return { value: `${c.known}/${c.total}` }
}
const map: Record<string, number | null | undefined> = {
empty: stats.emptyNodeCount,
unverified: stats.unverifiedSourceCount,
conflict: stats.conflictCount,
pending: stats.pendingChangeCount,
}
const v = map[key]
// 原因表按后端 DTO 字段名(statReasons 键=字段名),展示键需映射。
const reasonKey: Record<string, string> = {
empty: 'emptyNodeCount', unverified: 'unverifiedSourceCount',
conflict: 'conflictCount', pending: 'pendingChangeCount',
}
if (v === null || v === undefined) return { value: '—', hint: reasons[reasonKey[key] || key] || '暂不可计算' }
return { value: String(v) }
}
// ---- 快照一致性(DV-R05 前端侧):统计与树徽标同快照才同屏可信 ----
export function sameSnapshot(a?: CockpitMeta, b?: CockpitMeta): boolean {
if (!a || !b) return false
return a.snapshotRevision === b.snapshotRevision && a.asOfSeq === b.asOfSeq
}
/** 更新提示:展示中快照落后于最新已见快照时提示刷新(不抢占滚动,视图规格 §5)。 */
export function updateNotice(viewed: CockpitMeta | null, latest: CockpitMeta | null): string | null {
if (!viewed || !latest) return null
if (viewed.snapshotRevision === latest.snapshotRevision && viewed.asOfSeq === latest.asOfSeq) return null
return `内容已更新(快照 ${latest.snapshotRevision})——刷新查看最新状态`
}
/** 上下文键:项目+筛选标识一轮取数上下文;不匹配的旧响应直接丢弃(契约 §8)。 */
export function contextKey(projectId: string, filter: string): string {
return `${projectId}\u0001${filter}`
}
/** 刷新不重置选择:文档仍在根清单(或选中章节的父文档仍在)则保留,否则清空。 */
export function selectionSurvives(
selected: CockpitNodeRef | null,
rootDocIds: string[],
parentDocId?: string,
): CockpitNodeRef | null {
if (!selected) return null
if (selected.kind === 'document') return rootDocIds.includes(selected.id) ? selected : null
if (parentDocId && rootDocIds.includes(parentDocId)) return selected
return null
}
/** 树行徽标(诚实状态:空/部分/受限/未核实/冲突计数;不虚构成功)。 */
export function treeBadges(n: CockpitTreeNode): string[] {
const out: string[] = []
if (n.state.content === 'empty') out.push('空')
else if (n.state.content === 'partial') out.push('部分')
else if (n.state.content === 'inaccessible') out.push('受限')
if (n.visibility === 'metadata_only') out.push('仅元数据')
if (n.state.verification === 'unverified') out.push('未核实')
if (n.state.conflictCount > 0) out.push(`冲突 ${n.state.conflictCount}`)
return out
}
/** 节点状态一句话总结(全景 §4.2)。 */
export function nodeSummaryLine(n: CockpitTreeNode): string {
const parts: string[] = []
const content = { complete: '内容完整', partial: '内容部分', empty: '内容为空', inaccessible: '内容受限', unavailable: '暂不可读' }[n.state.content] || n.state.content
parts.push(content)
if (n.state.structuralGap) parts.push('层级止于章节')
if (n.state.sourceCount > 0) parts.push(`来源 ${n.state.sourceCount}`)
if (n.state.conflictCount > 0) parts.push(`冲突 ${n.state.conflictCount}`)
if (n.state.impactCount > 0) parts.push(`影响 ${n.state.impactCount}`)
return parts.join(' · ')
}
// ---- I3:阅读器与右栏纯逻辑 ----
export interface CockpitVersionSummary {
seq: number
version: string
actor?: string
at: string
basisCount: number
content?: string
redacted?: boolean
}
export interface CockpitContentBlock { kind: string; text?: string; reason?: string }
export interface CockpitSourceSummary {
ref: { kind: string; id: string }
label: string
capturedAt?: string
confidence: string
verification: string
visibility: string
redactedReason?: string
}
export interface CockpitHistorySummary {
seq: number
at: string
op: string
actor?: string
summary: string
operationId?: string
}
export interface CockpitImpactSummary {
ref: { kind: string; id: string }
edgeType: string
edgeState: string
direction: string
layer: string
}
export interface CockpitAssertion { node: CockpitNodeRef; claim: string; visibility: string; redactedReason?: string }
export interface CockpitConflictSummary {
id: string
node: CockpitNodeRef
state: string
left: CockpitAssertion
right: CockpitAssertion
detectedAt: string
resolutionRef?: string
}
export interface CockpitPage<T> { meta?: CockpitMeta; items: T[]; nextCursor?: string; hasMore: boolean; truncated?: boolean }
export const PANEL_TABS = [
{ id: 'sources', label: '内容来源' },
{ id: 'history', label: '修改历史' },
{ id: 'impacts', label: '影响对象' },
{ id: 'conflicts', label: '冲突与风险' },
] as const
export type PanelTabId = (typeof PANEL_TABS)[number]['id']
export type ViewMode = 'panorama' | 'reader'
export type Density = 'compact' | 'full'
export interface CockpitPrefs {
lastView: ViewMode
density: Density
rightPanelTab: PanelTabId
}
/** 偏好按 用户+项目 隔离(设计 §7.3);仅本地存储,不构成授权。 */
export function prefsKey(actor: string, projectId: string): string {
return `cockpit-prefs:${actor}:${projectId}`
}
/** 解析偏好:枚举外的脏值逐项回落默认(panorama+compact+sources)。 */
export function mergePrefs(raw: unknown): CockpitPrefs {
const base: CockpitPrefs = { lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' }
if (!raw || typeof raw !== 'object') return base
const r = raw as Record<string, unknown>
const out = { ...base }
if (r.lastView === 'panorama' || r.lastView === 'reader') out.lastView = r.lastView
if (r.density === 'compact' || r.density === 'full') out.density = r.density
if (PANEL_TABS.some(t => t.id === r.rightPanelTab)) out.rightPanelTab = r.rightPanelTab as PanelTabId
return out
}
export function loadPrefs(actor: string, projectId: string): CockpitPrefs {
try {
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(prefsKey(actor, projectId)) : null
return mergePrefs(raw ? JSON.parse(raw) : null)
} catch {
return mergePrefs(null)
}
}
export function savePrefs(actor: string, projectId: string, prefs: CockpitPrefs): void {
try {
if (typeof localStorage !== 'undefined') localStorage.setItem(prefsKey(actor, projectId), JSON.stringify(prefs))
} catch { /* 存储不可用时偏好静默降级为会话内 */ }
}
/** 版本摘要:当前版本 + 前一版本(视图规格 §5)。versions 为最新在前;受限版本无 content。 */
export function versionSummary(versions: CockpitVersionSummary[] | undefined): {
current?: CockpitVersionSummary
previous?: CockpitVersionSummary
redacted: boolean
} {
if (!versions || versions.length === 0) return { redacted: false }
const current = versions[0]
const previous = versions.length > 1 ? versions[1] : undefined
return { current, previous, redacted: !!current.redacted }
}
/** 历史面板合并:只接受严格更新(seq 更大且未见)的条目;过期与重复都计入 dropped(DV-R05/契约 §8)。 */
export function mergeHistoryStrict(existing: CockpitHistorySummary[], incoming: CockpitHistorySummary[]): {
items: CockpitHistorySummary[]
dropped: number
} {
const seen = new Set(existing.map(e => e.seq))
const maxSeq = existing.length > 0 ? Math.max(...seen) : -1
const items = [...existing]
let dropped = 0
for (const e of incoming) {
if (e.seq <= maxSeq || seen.has(e.seq)) {
dropped++
continue
}
seen.add(e.seq)
items.push(e)
}
return { items, dropped }
}
/** 阅读器装配:把文档的章节详情按树顺序组装为连续正文;受限章节给占位说明而非空白。 */
export function assembleReader(
sections: CockpitTreeNode[],
details: Record<string, { body?: CockpitContentBlock[]; versions?: CockpitVersionSummary[] } | undefined>,
): { ref: CockpitNodeRef; title: string; contentState: string; blocks: CockpitContentBlock[]; versions?: CockpitVersionSummary[] }[] {
return sections.map(sec => {
const d = details[sec.ref.id]
const readable = !!d?.body && d.body.length > 0 && d.body[0].kind === 'text'
const restricted = !!d?.body && d.body.length > 0 && d.body[0].kind === 'restricted'
const blocks: CockpitContentBlock[] = readable
? (d!.body as CockpitContentBlock[])
: restricted
? [{ kind: 'restricted', reason: d?.body?.[0]?.reason || 'section-duty-or-authorship' }]
: [{ kind: 'restricted', reason: 'not_loaded' }]
return { ref: sec.ref, title: sec.title, contentState: sec.state.content, blocks, versions: d?.versions }
})
}
/** 行级差异(阅读器 Collapsible 的完整对比):按行对齐的朴素 LCS 免实现——
* 以行为单位做集合比对标记 +/=/-,足够呈现「完整差异」而不冒充精确补丁。 */
export function diffLines(current: string, previous: string): { flag: '+' | '-' | '='; text: string }[] {
const cur = current.split('\n')
const prev = new Set(previous.split('\n'))
const curSet = new Set(cur)
const out: { flag: '+' | '-' | '='; text: string }[] = []
for (const line of cur) out.push({ flag: prev.has(line) ? '=' : '+', text: line })
for (const line of previous.split('\n')) if (!curSet.has(line)) out.push({ flag: '-', text: line })
return out
}
/** 历史条目的自然语言时间线文本(右栏 §6)。 */
export function historyLine(h: CockpitHistorySummary): string {
const who = h.actor ? `${h.actor} ` : ''
return `${h.summary}(${who}${new Date(h.at).toLocaleString('zh-CN')})`
}
+197
View File
@@ -0,0 +1,197 @@
// 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。
export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver'
interface DutyRoles {
author: string
reviewers: string[]
approver: string
}
export interface DutyDocDraft extends DutyRoles {
docId: string
title: string
code: string
typeKey: string
stage: string
dutyVersion: number
activeRound: number
legacySectionOverrides: number
dirty: boolean
}
export interface DutyMember {
owner: string
role: string
valid: boolean
canAuthor: boolean
canReview: boolean
canApprove: boolean
}
interface DutySaveItem {
docId: string
author: string
reviewers: string[]
approver: string
expectedDutyVersion: number
confirmInvalidation: boolean
}
export function cellDuty(roles: DutyRoles, owner: string): CellDuty {
if (roles.author === owner) return 'author'
if (roles.approver === owner) return 'approver'
if (roles.reviewers.includes(owner)) return 'reviewer'
return 'none'
}
export function applyCell(roles: DutyRoles, owner: string, duty: CellDuty): {
roles: DutyRoles
replacedAuthor: string
replacedApprover: string
} {
const replacedAuthor = duty === 'author' && roles.author !== '' && roles.author !== owner ? roles.author : ''
const replacedApprover = duty === 'approver' && roles.approver !== '' && roles.approver !== owner ? roles.approver : ''
let author = roles.author === owner ? '' : roles.author
let approver = roles.approver === owner ? '' : roles.approver
let reviewers = roles.reviewers.filter(person => person !== owner)
switch (duty) {
case 'author':
author = owner
break
case 'approver':
approver = owner
break
case 'reviewer':
reviewers = [...reviewers, owner]
break
case 'none':
break
default: {
const exhaustive: never = duty
throw new Error(exhaustive)
}
}
return { roles: { author, reviewers, approver }, replacedAuthor, replacedApprover }
}
export function replacementNote(roles: DutyRoles, owner: string, duty: CellDuty): string {
if (duty === 'author' && roles.author !== '' && roles.author !== owner) return `将替换${roles.author}`
if (duty === 'approver' && roles.approver !== '' && roles.approver !== owner) return `将替换${roles.approver}`
return ''
}
function dutyConflicts(roles: DutyRoles): number {
const seen = new Map<string, number>()
const mark = (owner: string) => {
if (!owner) return
seen.set(owner, (seen.get(owner) ?? 0) + 1)
}
mark(roles.author)
mark(roles.approver)
for (const reviewer of roles.reviewers) mark(reviewer)
let conflicts = 0
for (const count of seen.values()) if (count > 1) conflicts += 1
return conflicts
}
export function completeness(roles: DutyRoles): { authors: number; reviewers: number; approvers: number; conflicts: number } {
return {
authors: roles.author ? 1 : 0,
reviewers: roles.reviewers.length,
approvers: roles.approver ? 1 : 0,
conflicts: dutyConflicts(roles),
}
}
export function saveIssues(roles: DutyRoles): string[] {
const issues: string[] = []
if (!roles.author) issues.push('需指定编制人')
if (roles.reviewers.length === 0) issues.push('审核至少一人')
if (!roles.approver) issues.push('需指定批准人')
if (dutyConflicts(roles) > 0) issues.push('一人只能担一个职责')
return issues
}
function rolesFromCells(docId: string, cells: { docId: string; owner: string; duty: string }[]): DutyRoles {
const roles: DutyRoles = { author: '', reviewers: [], approver: '' }
for (const cell of cells) {
if (cell.docId !== docId) continue
if (cell.duty !== 'none' && cell.duty !== 'author' && cell.duty !== 'reviewer' && cell.duty !== 'approver') continue
const next = applyCell(roles, cell.owner, cell.duty)
roles.author = next.roles.author
roles.reviewers = next.roles.reviewers
roles.approver = next.roles.approver
}
return roles
}
export function absorbServerDocs(
current: DutyDocDraft[],
docs: {
docId: string; title: string; code?: string; typeKey: string; stage?: string
dutyVersion: number; activeRound?: number; legacySectionOverrides?: number
}[],
cells: { docId: string; owner: string; duty: string }[],
replaceDirty = false,
): DutyDocDraft[] {
const byId = new Map(current.map(doc => [doc.docId, doc]))
const pageIds = new Set(docs.map(doc => doc.docId))
const next: DutyDocDraft[] = []
for (const doc of docs) {
const previous = byId.get(doc.docId)
const serverRoles = rolesFromCells(doc.docId, cells)
const keepDraft = !replaceDirty && previous?.dirty === true
next.push({
docId: doc.docId,
title: doc.title,
code: doc.code ?? doc.docId,
typeKey: doc.typeKey,
stage: doc.stage ?? '',
dutyVersion: keepDraft ? previous.dutyVersion : doc.dutyVersion,
activeRound: doc.activeRound ?? 0,
legacySectionOverrides: doc.legacySectionOverrides ?? 0,
dirty: keepDraft,
author: keepDraft ? previous.author : serverRoles.author,
reviewers: keepDraft ? previous.reviewers : serverRoles.reviewers,
approver: keepDraft ? previous.approver : serverRoles.approver,
})
}
for (const doc of current) {
if (!replaceDirty && doc.dirty && !pageIds.has(doc.docId)) next.push(doc)
}
return next
}
export function editCell(docs: DutyDocDraft[], docId: string, owner: string, duty: CellDuty): DutyDocDraft[] {
return docs.map(doc => {
if (doc.docId !== docId) return doc
const next = applyCell(doc, owner, duty)
return { ...doc, ...next.roles, dirty: true }
})
}
export function dirtyDocs(docs: DutyDocDraft[]): DutyDocDraft[] {
return docs.filter(doc => doc.dirty)
}
export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean): { docs: DutySaveItem[]; issues: string[] } {
const dirty = dirtyDocs(docs)
const issues: string[] = []
if (dirty.length === 0) issues.push('没有未保存的职责')
if (dirty.length > 20) issues.push('一次保存最多 20 份文档')
for (const doc of dirty) {
for (const issue of saveIssues(doc)) issues.push(`${doc.title}:${issue}`)
}
return {
docs: dirty.slice(0, 20).map(doc => ({
docId: doc.docId,
author: doc.author,
reviewers: doc.reviewers,
approver: doc.approver,
expectedDutyVersion: doc.dutyVersion,
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
})),
issues,
}
}
+203
View File
@@ -0,0 +1,203 @@
import type { CellDuty, DutyDocDraft, DutyMember } from './draft'
import { cellDuty, completeness, replacementNote, saveIssues } from './draft'
const DUTY_OPTIONS: { value: CellDuty; label: string }[] = [
{ value: 'none', label: '无职责' },
{ value: 'author', label: '编制' },
{ value: 'reviewer', label: '审核' },
{ value: 'approver', label: '批准' },
]
function DutySelect({
doc, owner, disabled, onChange,
}: {
doc: DutyDocDraft
owner: string
disabled: boolean
onChange: (duty: CellDuty) => void
}) {
const current = cellDuty(doc, owner)
return (
<label className="duty-cell">
<select
aria-label={`${owner} 在 ${doc.title} 的职责`}
value={current}
disabled={disabled}
onChange={event => {
const value = event.target.value
if (value === 'none' || value === 'author' || value === 'reviewer' || value === 'approver') onChange(value)
}}
>
{DUTY_OPTIONS.map(option => {
const hint = replacementNote(doc, owner, option.value)
return <option key={option.value} value={option.value}>{hint ? `${option.label}(${hint})` : option.label}</option>
})}
</select>
</label>
)
}
export function DutyByMemberView({
member, members, docs, disabled, onMember, onCell,
}: {
member: string
members: DutyMember[]
docs: DutyDocDraft[]
disabled: boolean
onMember: (owner: string) => void
onCell: (docId: string, owner: string, duty: CellDuty) => void
}) {
return (
<div>
<div className="setting-row">
<label htmlFor="duty-member">成员</label>
<select id="duty-member" aria-label="选择成员" value={member} onChange={event => onMember(event.target.value)}>
<option value="">选择成员</option>
{members.map(person => <option key={person.owner} value={person.owner}>{person.owner}</option>)}
</select>
</div>
{!member ? <p className="ad-hint">先选择成员,再为每份文档指定无职责、编制、审核或批准。</p> : (
<table className="ad-table">
<thead><tr><th>文档</th><th>类型 / 阶段</th><th>职责</th><th>完整性</th></tr></thead>
<tbody>
{docs.map(doc => {
const score = completeness(doc)
return (
<tr key={doc.docId}>
<td>{doc.title}<span className="ad-dim ad-mono"> · {doc.code}</span></td>
<td className="ad-dim">{doc.typeKey}{doc.stage ? ` / ${doc.stage}` : ''}</td>
<td><DutySelect doc={doc} owner={member} disabled={disabled} onChange={duty => onCell(doc.docId, member, duty)} /></td>
<td className="ad-dim">编 {score.authors}/1 · 审 {score.reviewers} · 批 {score.approvers}/1</td>
</tr>
)
})}
{docs.length === 0 && <tr><td colSpan={4} className="ad-dim">没有符合筛选的文档</td></tr>}
</tbody>
</table>
)}
</div>
)
}
export function DutyByDocumentView({
docId, docs, members, disabled, onDoc, onCell,
}: {
docId: string
docs: DutyDocDraft[]
members: DutyMember[]
disabled: boolean
onDoc: (id: string) => void
onCell: (docId: string, owner: string, duty: CellDuty) => void
}) {
const doc = docs.find(item => item.docId === docId)
const score = doc ? completeness(doc) : null
const issues = doc ? saveIssues(doc) : []
return (
<div>
<div className="setting-row">
<label htmlFor="duty-doc">文档</label>
<select id="duty-doc" aria-label="选择文档" value={docId} onChange={event => onDoc(event.target.value)}>
<option value="">选择整份文档</option>
{docs.map(item => <option key={item.docId} value={item.docId}>{item.title}</option>)}
</select>
</div>
{score && <p className="ad-hint">编制 {score.authors}/1、审核 {score.reviewers} 人、批准 {score.approvers}/1、冲突 {score.conflicts}{issues.length > 0 ? `。${issues.join(',')}` : ''}</p>}
{!doc ? <p className="ad-hint">先选择整份文档。这里不能选择章节,也没有生成入口。</p> : (
<table className="ad-table">
<thead><tr><th>成员</th><th>项目身份</th><th>职责</th></tr></thead>
<tbody>
{members.map(person => (
<tr key={person.owner}>
<td className="ad-mono">{person.owner}{person.valid ? '' : '(无效)'}</td>
<td className="ad-dim">{person.role === 'owner' ? '项目负责人' : '项目成员'}</td>
<td><DutySelect doc={doc} owner={person.owner} disabled={disabled || !person.valid} onChange={duty => onCell(doc.docId, person.owner, duty)} /></td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
export function DutyMatrixView({
docs, members, disabled, narrow, onCell,
}: {
docs: DutyDocDraft[]
members: DutyMember[]
disabled: boolean
narrow: boolean
onCell: (docId: string, owner: string, duty: CellDuty) => void
}) {
if (narrow) {
return <p className="ad-hint">当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。</p>
}
return (
<div className="duty-matrix-wrap">
<table className="ad-table duty-matrix">
<thead>
<tr>
<th className="duty-sticky">成员</th>
{docs.map(doc => {
const score = completeness(doc)
const ready = score.authors === 1 && score.reviewers >= 1 && score.approvers === 1 && score.conflicts === 0
return <th key={doc.docId}>{doc.title}<span className={ready ? 'ad-chip' : 'ad-chip ad-chip-warn'}>{ready ? '三权齐' : '未齐'}</span></th>
})}
</tr>
</thead>
<tbody>
{members.map(person => (
<tr key={person.owner}>
<td className="duty-sticky ad-mono">{person.owner}</td>
{docs.map(doc => (
<td key={doc.docId}>
<DutySelect doc={doc} owner={person.owner} disabled={disabled || !person.valid} onChange={duty => onCell(doc.docId, person.owner, duty)} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
export function DutyChangePreview({
impacts, confirm, onConfirm,
}: {
impacts: {
docId: string
title?: string
replacedAuthor?: string
replacedApprover?: string
needsConfirm?: boolean
reasons?: string[]
}[]
confirm: boolean
onConfirm: (value: boolean) => void
}) {
if (impacts.length === 0) return null
const needs = impacts.some(item => item.needsConfirm)
return (
<div className="settings-group">
<h4>保存前预览</h4>
<ul>
{impacts.map(item => (
<li key={item.docId}>
{item.title || item.docId}
{item.replacedAuthor ? `:编制将替换${item.replacedAuthor}` : ''}
{item.replacedApprover ? `:批准将替换${item.replacedApprover}` : ''}
{item.needsConfirm ? ';在途审签将失效,历史名单保持不变' : ''}
{item.reasons && item.reasons.length > 0 ? `;${item.reasons.join(',')}` : ''}
</li>
))}
</ul>
{needs && (
<label>
<input type="checkbox" checked={confirm} onChange={event => onConfirm(event.target.checked)} />
确认使受影响的在途审签失效
</label>
)}
</div>
)
}
+90 -9
View File
@@ -3,8 +3,9 @@
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定。
// 参数增量(编写中)一到即算工具,解说不用等工具真正开始执行才离开回复区。
import type { Message, Part } from './contracts'
import type { Message, Part, StreamEvent } from './contracts'
import type { LiveState } from './store'
import { splitTimeline, type ReplayPart } from './messageparts'
export type ToolLiveState = 'composing' | 'running' | 'done'
@@ -12,6 +13,7 @@ export type ToolLiveState = 'composing' | 'running' | 'done'
export type LiveItem =
| { kind: 'thinking'; text: string; startedAt: number; endedAt: number }
| { kind: 'text'; index: number; text: string }
| { kind: 'agent'; text: string; agentRun?: string; agentPhase?: 'spawn' | 'progress' | 'result' }
| {
kind: 'tool'
callId?: string
@@ -29,7 +31,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
@@ -118,13 +120,13 @@ export function applyToolCall(items: LiveItem[], name: string, callId?: string,
}
// applyToolOutput 把执行中的输出追加到对应工具,只保留尾部,避免长日志撑住内存。
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string): LiveItem[] {
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string, maxChars = OUTPUT_TAIL): LiveItem[] {
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
if (!isTool(it) || it.state === 'done') continue
if (callId && it.callId && it.callId !== callId) continue
let output = it.output + text
if (output.length > OUTPUT_TAIL) output = output.slice(output.length - OUTPUT_TAIL)
if (output.length > maxChars) output = output.slice(output.length - maxChars)
const out = items.slice()
out[i] = { ...it, output }
return out
@@ -149,6 +151,27 @@ export function applyCheck(items: LiveItem[], passed: boolean, reason: string):
return [...items, { kind: 'check', passed, reason }]
}
/** A child streams cumulative previews. Replace that child's latest preview in
* place, while preserving real phase changes and the order of other children. */
function applyAgentProgress(items: LiveItem[], agentRun: string, note: string): LiveItem[] {
const text = note.trim()
if (!text) return items
for (let i = items.length - 1; i >= 0; i--) {
const previous = items[i]
if (previous.kind !== 'agent' || previous.agentRun !== agentRun) continue
if (previous.agentPhase !== 'progress') break
const value = previous.text.slice('协作进展:'.length)
if (value === text || value.startsWith(text)) return items
if (text.startsWith(value) && !/^调用\s/.test(text)) {
const next = items.slice()
next[i] = { ...previous, text: `协作进展:${text}` }
return next
}
break
}
return [...items, { kind: 'agent', text: `协作进展:${text}`, agentRun, agentPhase: 'progress' }]
}
// applyFinalizing 标记收尾开始:此前的正文都是过程,此后的正文才是给用户的回复。
export function applyFinalizing(items: LiveItem[]): LiveItem[] {
if (items.some(it => it.kind === 'final')) return items
@@ -193,7 +216,9 @@ export function liveToMessage(live: LiveState): Message {
parts.push({ type: 'check', process: true, check: { passed: it.passed, reason: it.reason || undefined } })
continue
}
if (it.kind === 'thinking') {
if (it.kind === 'agent') {
parts.push({ type: 'text', text: it.text, process: true, replaySource: 'agent' } as ReplayPart)
} else if (it.kind === 'thinking') {
const durationMs = it.endedAt - it.startedAt
if (it.text) parts.push({
type: 'thinking', text: it.text, process: true,
@@ -221,7 +246,63 @@ export function liveToMessage(live: LiveState): Message {
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
}
}
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
const createdAt = live.startedAt > 0 && Number.isFinite(live.startedAt) ? new Date(live.startedAt).toISOString() : ''
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt }
}
/** Rebuild one run's process from its durable ordered events, without the live preview's output-tail limit. */
export function runEventsToTimeline(events: StreamEvent[], parentRunId = events[0]?.runId, completeRecord = false): ReplayPart[] {
let items: LiveItem[] = []
let finalizing = false
for (const event of [...events].sort((a, b) => a.seq - b.seq)) {
const p = event.payload as Record<string, unknown> | undefined
const now = Date.parse(event.ts) || 0
if (event.runId !== parentRunId && event.type !== 'agent.progress' && event.type !== 'agent.result') continue
switch (event.type) {
case 'agent.spawn':
if (typeof p?.name === 'string') items.push({ kind: 'agent', text: `协作代理「${p.name}」已开始`,
agentRun: String(p.agentRun ?? event.runId ?? ''), agentPhase: 'spawn' })
break
case 'agent.progress':
if (typeof p?.note === 'string') items = applyAgentProgress(items, String(p.agentRun ?? event.runId ?? ''), p.note)
break
case 'agent.result':
items.push({ kind: 'agent', text: `协作结束:${typeof p?.summary === 'string' && p.summary.trim() ? p.summary : String(p?.status ?? '已结束')}`,
agentRun: String(p?.agentRun ?? event.runId ?? ''), agentPhase: 'result' })
break
case 'thinking.delta':
if (typeof p?.text === 'string') items = applyThinkingDelta(items, p.text, now)
break
case 'message.delta':
if ((!finalizing || completeRecord) && typeof p?.text === 'string') items = applyTextDelta(items, typeof p.index === 'number' ? p.index : 0, p.text)
break
case 'tool.call.delta':
if (typeof p?.delta === 'string') items = applyToolCallDelta(items, typeof p.index === 'number' ? p.index : 0,
p.delta, typeof p.callId === 'string' ? p.callId : undefined, typeof p.name === 'string' ? p.name : undefined, now)
break
case 'tool.call':
items = applyToolCall(items, typeof p?.name === 'string' ? p.name : '工具',
typeof p?.callId === 'string' ? p.callId : undefined,
typeof p?.arguments === 'string' ? p.arguments : undefined, now)
break
case 'tool.output':
if (typeof p?.text === 'string') items = applyToolOutput(items,
typeof p.callId === 'string' ? p.callId : undefined, p.text, Number.POSITIVE_INFINITY)
break
case 'tool.result':
items = applyToolResult(items, typeof p?.callId === 'string' ? p.callId : undefined,
typeof p?.status === 'string' ? p.status : 'ok', typeof p?.summary === 'string' ? p.summary : undefined, now)
break
case 'run.phase':
if (p?.phase === 'finalizing') { finalizing = true; items = applyFinalizing(items) }
break
case 'run.check':
items = applyCheck(items, !!p?.passed, typeof p?.reason === 'string' ? p.reason : '')
break
}
}
const live: LiveState = { runId: events[0]?.runId ?? '', phase: 'done', items, agentNotes: {}, startedAt: 0 }
return completeRecord ? liveToMessage(live).parts : splitTimeline(liveToMessage(live).parts).timeline
}
// livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。
@@ -246,9 +327,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 的实时气泡才该收起。
+74 -13
View File
@@ -1,5 +1,4 @@
// 消息切分:过程时间线 = 从开头到最后一个 process 部件(中间夹着的内容部件留在时间线里);
// 最终段 = 它之后的正文与交付卡片。渲染顺序仍是落库顺序,只在这一刀上折叠。
// 消息切分:每轮过程按落库顺序留在可回看的时间线,终轮回复直接可读。
import type { Part } from './contracts'
export interface TimelineSplit {
@@ -7,22 +6,84 @@ export interface TimelineSplit {
final: Part[]
}
/** 最后一个 process 部件之前(含自身)是工作时间线,其余是最终回复。
* promoteLastText:回复区没有正文时,把时间线里最后一段解说提上来。失败或被截断的 run 用它保住可见回复。 */
/** Client-only provenance. It is never written to the Core message contract. */
export interface ReplayPart extends Part { replaySource?: 'agent' }
/** 最后一个 process 部件之前的记录属于该轮过程;错误与文件入口始终可见。
* 终态没有回复时,只把最后一段过程解说提升为可见回复。 */
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 }
const timeline: Part[] = []
const final: Part[] = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (i > last || part.type === 'error' || part.type === 'artifact_ref') final.push(part)
else timeline.push(part)
}
if (textAt < 0) return { timeline, final }
const lifted = { ...timeline[textAt], process: undefined }
return { timeline: timeline.filter((_, i) => i !== textAt), final: [lifted, ...final] }
if (promoteLastText && !final.some(part => part.type === 'text' || part.type === 'error')) {
let idx = -1
for (let i = timeline.length - 1; i >= 0; i--) {
if (timeline[i].type === 'text') { idx = i; break }
}
if (idx >= 0) final.unshift(...timeline.splice(idx, 1))
}
return { timeline, final }
}
const normalized = (text?: string) => (text ?? '').replace(/\s+/g, ' ').trim()
function sameProcessPart(saved: Part, replayed: Part): boolean {
if (saved.type !== replayed.type) return false
switch (saved.type) {
case 'tool_call': return !!saved.toolCall?.id && saved.toolCall.id === replayed.toolCall?.id
case 'tool_result': return !!saved.toolResult?.callId && saved.toolResult.callId === replayed.toolResult?.callId
case 'check': return saved.check?.passed === replayed.check?.passed && saved.check?.reason === replayed.check?.reason
case 'thinking':
case 'text': return normalized(saved.text) === normalized(replayed.text)
default: return false
}
}
/** Keep the settled message as the content authority; add only event-only records
* and full tool output. Event deltas can be missing, while terminal parts can
* contain retry notes or parsed inline thinking that the event stream cannot reconstruct. */
export function mergeProcessTimeline(saved: Part[], replayed: ReplayPart[], final: Part[]): Part[] {
if (replayed.length === 0) return saved
const finalTexts = final.filter(part => part.type === 'text').map(part => normalized(part.text)).filter(Boolean)
const merged: Part[] = []
let cursor = 0
let pending: Part[] = []
for (const part of replayed) {
const text = normalized(part.text)
const agentNote = part.replaySource === 'agent'
if (part.type === 'text' && !agentNote && ((text && finalTexts.some(value => value.includes(text) || text.includes(value)))
|| /<\/?think\b/i.test(part.text ?? ''))) continue
let match = -1
for (let i = cursor; i < saved.length; i++) {
if (sameProcessPart(saved[i], part)) { match = i; break }
}
if (match < 0) {
// A missing delta batch can leave only a prefix in events. The complete
// terminal part is authoritative; never render that prefix beside it.
if (!agentNote && (part.type === 'text' || part.type === 'thinking') && text && saved.some(item => {
if (item.type !== part.type) return false
const value = normalized(item.text)
return value && (value.includes(text) || text.includes(value))
})) continue
pending.push(part)
continue
}
merged.push(...saved.slice(cursor, match), ...pending)
pending = []
const output = (part as Part & { toolOutput?: string }).toolOutput
merged.push(output && saved[match].type === 'tool_call'
? { ...saved[match], toolOutput: output } as Part : saved[match])
cursor = match + 1
}
merged.push(...saved.slice(cursor), ...pending)
return merged
}
/** 同一轮里同名工件只留最新一张,updates 是被折叠掉的次数。 */
+6 -1
View File
@@ -1,17 +1,22 @@
// 输入排队(纯数据):回合进行中先入本地队列,终态再逐条发送。
export interface WorkReference { cardId: string; resultVersion: number; title: string }
export interface QueuedMessage {
id: string
text: string
attachmentIds: string[]
attachmentNames: string[]
taskRef?: WorkReference
deliveryError?: string
}
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[]): QueuedMessage {
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[], taskRef?: WorkReference): QueuedMessage {
return {
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
text,
attachmentIds,
attachmentNames,
...(taskRef ? { taskRef } : {}),
}
}
-11
View File
@@ -1,11 +0,0 @@
// 浏览器端随机初始密码(管理员未显式给定密码时):
// crypto.getRandomValues 拒绝易混淆字符(0/O/1/l/I),保证可抄写。
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'
export function randomPassword(length = 12): string {
const buf = new Uint32Array(length)
crypto.getRandomValues(buf)
let out = ''
for (let i = 0; i < length; i++) out += ALPHABET[buf[i] % ALPHABET.length]
return out
}
+38
View File
@@ -3,3 +3,41 @@ export function projectStage(settings?: Record<string, unknown> | null): string
const stage = settings?.stage
return typeof stage === 'string' && stage.trim() !== '' ? stage.trim() : null
}
/** Service metadata describes readiness; permission is always checked by the server. */
export function projectWorkError(project: { settings?: Record<string, unknown> } | undefined): string | null {
if (!project) return '项目已不可用,请刷新项目列表或联系项目负责人'
const settings = project.settings
if (!settings || (!('rdm.state' in settings) && !('rdm.projectId' in settings))) return null
switch (settings['rdm.state']) {
case 'ready':
if (typeof settings['rdm.projectId'] === 'string' && settings['rdm.projectId'].trim()) return null
break
case 'provisioning': return '项目正在准备,请稍后刷新项目列表再试'
case 'failed': return '项目准备失败,请联系项目负责人重试创建'
case 'archived': return '项目已归档,请联系项目负责人恢复后再新建对话'
case 'closed': return '项目已关闭,请选择其他项目或联系项目负责人'
}
return '项目尚未绑定,请联系项目负责人完成项目接入'
}
export function projectStateLabel(settings?: Record<string, unknown>): string | null {
switch (settings?.['rdm.state']) {
case 'provisioning': return '准备中'
case 'failed': return '准备失败'
case 'unbound': return '未接入'
case 'archived': return '已归档'
case 'closed': return '已关闭'
default: return null
}
}
export function projectActionError(error: unknown): string {
const raw = String(error)
if (/\b401\b/.test(raw)) return '登录已失效,请重新登录后再试'
if (/\b403\b/.test(raw)) return '当前账号无法管理此项目,请联系项目负责人'
if (/\b404\b/.test(raw)) return '项目已不可用,请刷新项目列表或联系项目负责人'
if (/\b409\b/.test(raw)) return '项目名称已被占用或项目状态已变化,请更换名称或刷新后再试'
if (/\b503\b/.test(raw)) return '项目服务暂未就绪,请稍后重试或联系项目负责人'
return raw.replace(/^Error:\s*/, '')
}
+195
View File
@@ -0,0 +1,195 @@
// U6/KTD5:RDM 管理与项目页面的纯逻辑层——能力门控(与 rdmAuditReady 同源语义:
// 命令清单不可用=未就绪,fail-closed)与命令载荷构造。页面组件只消费本模块与
// api.rdmCommand,不在组件里散落命令 id / 参数拼写;身份一律经认证会话,载荷不
// 携带任何身份字段(G1-5)。
import type { Project } from './contracts'
import { projectWorkError } from './projectmeta'
export function rdmCommandProjectId(project: Project): string {
const id = project.settings?.['rdm.projectId']
if (typeof id !== 'string' || !id.trim()) {
throw new Error('项目尚未绑定,请联系项目负责人完成项目接入')
}
return id.trim()
}
/** Historical project reads remain available; this does not grant read permission. */
export function rdmProjectReadError(project: Project | undefined): string | null {
const state = project?.settings?.['rdm.state']
if (project && (state === 'ready' || state === 'archived' || state === 'closed')) {
try { rdmCommandProjectId(project); return null }
catch (e) { return String(e).replace(/^Error:\s*/, '') }
}
return projectWorkError(project) ?? '项目尚未绑定,请联系项目负责人完成项目接入'
}
const PROJECT_READ_COMMANDS = new Set([
'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get',
'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan',
])
export function rdmProjectCommandError(project: Project, command: string): string | null {
return PROJECT_READ_COMMANDS.has(command) ? rdmProjectReadError(project) : projectWorkError(project)
}
/** 系统管理「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_DUTY_COMMANDS = [
'rdm.duty.matrix',
'rdm.duty.preview',
'rdm.review.assign.doc',
'rdm.review.assign.doc.batch',
] 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)
/** 文档职责页是否就绪。命令未齐则 fail-closed。 */
export const rdmDutyReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_DUTY_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 memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role })
export const assignDocDutiesPayload = (
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean },
) => ({
projectId, docId, author, reviewers, approver,
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
})
export const dutyMatrixPayload = (
projectId: string,
query: {
view: 'member' | 'document' | 'matrix'
memberOwner?: string
docId?: string
query?: string
stage?: string
typeKey?: string
assignment?: string
memberQuery?: string
memberCursor?: string
docCursor?: string
memberLimit?: number
docLimit?: number
},
) => ({ projectId, ...query })
export const dutyPreviewPayload = (
projectId: string,
docs: { docId: string; author: string; reviewers: string[]; approver: string; expectedDutyVersion: number; confirmInvalidation: boolean }[],
) => ({ projectId, docs })
export const dutyLegacyPayload = (projectId: string) => ({ projectId })
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
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from './recovery'
import { humanChangePasswordError, humanRedeemError, requiresCurrentPassword } from './components/LoginPage'
// 账户链路文案与模式判定(2026-10-14 current_required 加固后前后端契约对齐的
// 防漂移锁):改密门必须区分 grant_setup / current_required,恢复提案 409 的
// 两种语义必须分开表达。
describe('requiresCurrentPassword', () => {
it('current_required 必须要当前密码,grant_setup 免当前密码', () => {
expect(requiresCurrentPassword('current_required')).toBe(true)
expect(requiresCurrentPassword('grant_setup')).toBe(false)
})
})
describe('humanChangePasswordError', () => {
it('后端 current password incorrect 映射为当前密码不正确', () => {
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
})
})
describe('humanRedeemError', () => {
it('401/409/429 语义分离', () => {
expect(humanRedeemError('409: login key already used')).toContain('已被使用')
expect(humanRedeemError('401: invalid or expired login key')).toContain('无效或已过期')
expect(humanRedeemError('429: too many failed attempts')).toContain('稍后再试')
})
})
describe('recoveryStatusLabel', () => {
it('覆盖全部五种提案状态', () => {
for (const status of ['pending', 'confirmed', 'completed', 'revoked', 'expired'] as const) {
expect(recoveryStatusLabel[status]).toBeTruthy()
}
})
})
describe('recoveryProposalHint', () => {
it('pending/confirmed 给出下一步,终态不给', () => {
expect(recoveryProposalHint('pending')).toContain('本人确认')
expect(recoveryProposalHint('confirmed')).toContain('新密码')
expect(recoveryProposalHint('completed')).toBe('')
})
})
describe('recoveryProposalErrorMessage', () => {
it('OWNER_PROOF_UNAVAILABLE 与活动提案冲突分开表达', () => {
expect(recoveryProposalErrorMessage('409: OWNER_PROOF_UNAVAILABLE')).toContain('会话证明')
expect(recoveryProposalErrorMessage('409: store: conflict')).toContain('已有进行中的恢复提案')
})
it('未识别错误剥状态码原样透出', () => {
expect(recoveryProposalErrorMessage('500: boom')).toBe('boom')
})
})
+29
View File
@@ -0,0 +1,29 @@
// 恢复提案展示与错误文案(纯函数):管理端 UsersPanel 与本人 MyAccountSettings
// 共用,vitest 锁定语义防漂移。后端 409 两种含义按响应体区分:
// OWNER_PROOF_UNAVAILABLE=无合格会话证明;其余 conflict=已有活动提案。
import type { RecoveryProposal } from './api'
export const recoveryStatusLabel: Record<RecoveryProposal['status'], string> = {
pending: '待本人确认',
confirmed: '已确认,待兑换设密',
completed: '已完成',
revoked: '已撤销',
expired: '已过期',
}
/** 管理端视角的下一步指引:提案成立后动作在属主侧(设置 → 账号)。 */
export function recoveryProposalHint(status: RecoveryProposal['status']): string {
if (status === 'pending') return '等待该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现'
if (status === 'confirmed') return '该用户已确认;请提醒其尽快在受限会话中完成新密码设置,到期未用需重新生成'
return ''
}
export function recoveryProposalErrorMessage(raw: string): string {
if (raw.includes('OWNER_PROOF_UNAVAILABLE')) {
return '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置'
}
if (raw.includes('409')) return '该用户已有进行中的恢复提案;请先撤销旧提案或等它完成/过期'
if (raw.includes('403')) return '需要管理员身份'
if (raw.includes('404')) return '用户不存在或已被移除'
return raw.replace(/^\d{3}:\s*/, '')
}
+17
View File
@@ -0,0 +1,17 @@
import { api } from './api'
import type { StreamEvent } from './contracts'
/** Read the durable record in bounded pages; callers own cancellation and projection. */
export async function loadRunEvents(conversationId: string, runId: string, signal: AbortSignal): Promise<StreamEvent[]> {
const events: StreamEvent[] = []
let after = 0
while (true) {
const page = await api.runEventsPage(conversationId, runId, after, signal)
const rows = page.events ?? []
events.push(...rows)
if (rows.length < 500) return events
const next = rows[rows.length - 1].seq
if (next <= after) throw new Error('process event cursor did not advance')
after = next
}
}
+1 -21
View File
@@ -1,24 +1,4 @@
// 中栏 → 右栏单向滚动同步,以及互定位后的 1.2s 闪烁。
// 比例按可滚动范围计算;任一侧没有溢出时不同步,避免把静止栏拽动。
export interface ScrollBox {
scrollTop: number
scrollHeight: number
clientHeight: number
}
export function scrollRatio(box: ScrollBox): number | null {
const range = box.scrollHeight - box.clientHeight
if (range <= 0) return null
return box.scrollTop / range
}
export function scrollTopForRatio(box: Pick<ScrollBox, 'scrollHeight' | 'clientHeight'>, ratio: number): number | null {
const range = box.scrollHeight - box.clientHeight
if (range <= 0) return null
const clamped = Math.min(1, Math.max(0, ratio))
return clamped * range
}
// Explicit message/card location feedback. The middle and work panes scroll independently.
/** 给已定位的节点加上 mk-flash,1.2s 后摘掉。 */
export function flashElement(el: Element | null) {
+591 -111
View File
File diff suppressed because it is too large Load Diff
+90
View File
@@ -194,6 +194,10 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.tl-tools-sum { margin-left: 8px; color: var(--text-dim); }
.tl-check { color: var(--text-dim); font-size: var(--fs-ui-sm); margin: 4px 0; }
.tl-tools .status-error { margin-left: 8px; color: var(--err-text); }
.tl-tool-detail { margin-top: 4px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.tl-thinking { margin: 8px 0; padding: 8px 12px; border-left: 2px solid var(--border-strong); color: var(--text-dim); }
.tl-thinking-label { display: block; margin-bottom: 4px; font-size: var(--fs-ui-sm); font-weight: 600; }
.tl-thinking-body { white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--fs-ui-sm); line-height: 1.6; }
/* 第三栏「文件」入口内的多标签预览(外壳样式见 .right-panel .preview-pane) */
.preview-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; }
@@ -226,6 +230,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.process-collapse .process-collapse-meta { color: var(--text-dim); }
.process-collapse .process-collapse-excerpt { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
.process-collapse .body { padding: 4px 12px 10px; }
.process-load-note { padding: 8px 0; color: var(--text-dim); font-size: var(--fs-ui-sm); }
/* 流式状态行(呼吸/扫光走 mk-* 类;已工作计时弱色等宽) */
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: var(--fs-ui-sm); margin-bottom: 6px; }
@@ -627,7 +632,92 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.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-export { margin-top: 16px; display: flex; flex-direction: column; gap: 8px; }
.rdm-audit-export-row { display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; }
.rdm-audit-export-kind { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-export-kind .ui-input { min-width: 180px; height: 32px; font-size: var(--fs-ui-sm); }
.rdm-audit-export-draft { padding: 8px 10px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--text); }
.rdm-audit-export-draft p { margin: 0 0 8px; }
.rdm-audit-export-result { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-export-err { font-size: var(--fs-ui-sm); color: var(--err-text); }
.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; }
.duty-matrix-wrap { overflow: auto; }
.duty-matrix .duty-sticky { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
.duty-matrix thead th { position: sticky; top: 0; background: var(--bg-panel); z-index: 2; }
.duty-cell select { font-size: var(--fs-ui-sm); color: var(--text); background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); }
/* ---- 文档驾驶舱(I2;令牌驱动,词汇沿用 rdm-audit 卡片体系) ---- */
.cockpit { display: flex; flex-direction: column; min-height: 320px; }
.cockpit-header { display: flex; align-items: center; gap: 10px; padding: 0 0 10px; }
.cockpit-project { max-width: 220px; height: 32px; font-size: var(--fs-ui-sm); }
.cockpit-filter-chip, .cockpit-refresh { height: 28px; padding: 0 10px; font-size: var(--fs-ui-sm); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); color: var(--text); cursor: pointer; }
.cockpit-snap { margin-left: auto; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-banner { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); padding: 6px 10px; margin-bottom: 8px; font-size: var(--fs-ui-sm); }
.cockpit-banner-err { border-color: var(--border); }
.cockpit-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; margin-bottom: 12px; }
.cockpit-stat { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; text-align: center; font: inherit; }
button.cockpit-stat { cursor: pointer; }
.cockpit-stat-active { border-color: var(--text); }
.cockpit-stat-unavail { color: var(--text-muted, var(--text)); }
.cockpit-stat-num { display: block; font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
.cockpit-stat-label { display: block; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-columns { display: grid; grid-template-columns: minmax(220px, 300px) 1fr; gap: 14px; }
.cockpit-tree { min-height: 0; overflow-y: auto; }
.cockpit-tree-row { display: flex; align-items: center; gap: 6px; padding: 3px 4px; border-radius: var(--radius-sm); }
.cockpit-tree-row-active { background: var(--bg-panel); }
.cockpit-tree-caret { border: none; background: none; cursor: pointer; padding: 0 2px; font-size: var(--fs-ui-sm); color: var(--text); }
.cockpit-tree-title { border: none; background: none; padding: 0; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); text-align: left; }
.cockpit-badge { font-size: var(--fs-ui-sm); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0 4px; color: var(--text-muted, var(--text)); }
.cockpit-tree-time { margin-left: auto; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-tree-empty, .cockpit-tree-more { border: none; background: none; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); padding: 2px 4px; }
button.cockpit-tree-more { cursor: pointer; text-decoration: underline; }
.cockpit-truncated { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); padding: 4px 0; }
.cockpit-center { min-height: 0; overflow-y: auto; }
.cockpit-doclist { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.cockpit-docrow { display: flex; align-items: baseline; gap: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); padding: 8px 10px; cursor: pointer; font: inherit; text-align: left; }
.cockpit-docrow-title { font-size: var(--fs-ui-base, var(--fs-ui-sm)); color: var(--text); }
.cockpit-docrow-meta { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-changes { display: flex; flex-direction: column; gap: 4px; }
.cockpit-changerow { display: flex; justify-content: space-between; gap: 10px; font-size: var(--fs-ui-sm); }
.cockpit-changesum { color: var(--text); }
.cockpit-changetime { color: var(--text-muted, var(--text)); }
.cockpit-nodehead { display: flex; align-items: baseline; gap: 10px; padding-bottom: 8px; }
.cockpit-back { border: none; background: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); padding: 0; text-decoration: underline; }
.cockpit-nodetitle { font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
.cockpit-nodesum { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-childtable { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
.cockpit-childtable th, .cockpit-childtable td { border-bottom: 1px solid var(--border); padding: 4px 6px; text-align: left; color: var(--text); }
@media (max-width: 760px) { .cockpit-columns { grid-template-columns: 1fr; } .cockpit-tree { max-height: 40vh; } }
/* ---- 驾驶舱 I3:阅读器与右栏(令牌驱动) ---- */
.cockpit-columns-3 { grid-template-columns: minmax(200px, 260px) 1fr minmax(240px, 300px); }
.cockpit-right { min-height: 0; overflow-y: auto; border-left: 1px solid var(--border); padding-left: 12px; }
.cockpit-viewtoggle { display: flex; gap: 0; }
.cockpit-viewbtn { height: 28px; padding: 0 10px; font-size: var(--fs-ui-sm); border: 1px solid var(--border); background: var(--bg-panel); color: var(--text); cursor: pointer; }
.cockpit-viewbtn-active { background: var(--text); color: var(--bg-panel); }
.cockpit-reader-para { white-space: pre-wrap; font-size: var(--fs-ui-base, var(--fs-ui-sm)); line-height: 1.6; color: var(--text); margin: 6px 0; }
.cockpit-density-full .cockpit-reader-para { line-height: 1.9; margin: 10px 0; }
.cockpit-reader-section { border-top: 1px solid var(--border); padding: 8px 0; }
.cockpit-reader-sechead { display: flex; align-items: baseline; gap: 8px; }
.cockpit-reader-sectitle { font-weight: 600; color: var(--text); font-size: var(--fs-ui-base, var(--fs-ui-sm)); }
.cockpit-versions { font-size: var(--fs-ui-sm); }
.cockpit-density-full .cockpit-versions { line-height: 1.7; }
.cockpit-versions-toggle { font-size: var(--fs-ui-sm); color: var(--text); text-decoration: underline; cursor: pointer; }
.cockpit-versions-head { color: var(--text-muted, var(--text)); padding: 4px 0; }
.cockpit-diff { white-space: pre-wrap; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 8px; font-size: var(--fs-ui-sm); }
.cockpit-diff-add { color: var(--text); }
.cockpit-diff-del { color: var(--text-muted, var(--text)); text-decoration: line-through; }
.cockpit-diff-same { color: var(--text-muted, var(--text)); }
.cockpit-panel-tabs { display: flex; gap: 4px; padding-bottom: 8px; }
.cockpit-panel-tab { border: 1px solid var(--border); background: none; padding: 3px 8px; font-size: var(--fs-ui-sm); color: var(--text); cursor: pointer; border-radius: var(--radius-sm); }
.cockpit-panel-tab-active { background: var(--bg-panel); border-color: var(--text); }
.cockpit-panel-body { display: flex; flex-direction: column; gap: 6px; }
.cockpit-panel-count { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
.cockpit-panel-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border); padding: 4px 0; }
.cockpit-panel-kind { color: var(--text-muted, var(--text)); }
.cockpit-panel-label { color: var(--text); }
.cockpit-panel-conflict { flex-direction: column; }
.cockpit-conflict-side { color: var(--text); }
+107 -9
View File
@@ -1,14 +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 { previewTabsKey } from './previewtabs'
import type { StreamEvent, SurfaceSnapshot } from './contracts'
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' },
@@ -18,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, '初版'))
@@ -31,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)
@@ -40,14 +61,17 @@ describe('surface.updated projection', () => {
describe('surface.updated 可见性联动', () => {
it('自动开标签时右栏切到文件预览(syncFilesEntry 语义)', () => {
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('files')
expect(ui.rightCollapsed).toBe(false)
expect(ui.filesView).toBe('preview')
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(true)
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 痕迹', () => {
@@ -75,3 +99,77 @@ describe('U6 工作区任务页', () => {
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()]
}
+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,
}
}
+39
View File
@@ -30,15 +30,51 @@ export const RDM_AUDIT_COMMANDS = [
'rdm.inspect.history',
] as const
/** 概览页导出区单独探测。不并入上面的七命令就绪门。 */
export const RDM_EXPORT_COMMANDS = [
'rdm.inspect.export.create',
'rdm.inspect.export.status',
'rdm.inspect.export.download',
] as const
/** 三条导出命令都启用才显示概览导出区。缺一或清单不可用则隐藏。 */
export function rdmExportReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
return RDM_EXPORT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
}
/** 正式报告被完整性门拒绝时,界面先确认再带 draft 重试。 */
export function exportNeedsDraft(message: string): boolean {
return /不完整|草稿/.test(message)
}
/** 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))
}
/** 文档驾驶舱消费的全部 rdm.cockpit.* 命令(I1 只读面;rollout I2)。 */
export const COCKPIT_COMMANDS = [
'rdm.cockpit.overview',
'rdm.cockpit.tree',
'rdm.cockpit.node',
'rdm.cockpit.node.sources',
'rdm.cockpit.node.history',
'rdm.cockpit.node.impacts',
'rdm.cockpit.node.conflicts',
] as const
/** 七命令齐备且 enabled 才展示驾驶舱入口(与 rdmAuditReady 同源语义)。 */
export function cockpitReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
return COCKPIT_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: '🔍' },
{ key: 'rdm-cockpit', title: '文档驾驶舱', icon: '📋' },
]
function shortcutNum(raw?: string): number | null {
@@ -52,6 +88,8 @@ function shortcutNum(raw?: string): number | null {
interface BuildWorkspaceOptions {
/** rdm.inspect.* 命令面探测通过(store.rdmAuditAvailable)。默认 false:能力未知/缺失不展示审计入口。 */
rdmAudit?: boolean
/** rdm.cockpit.* 命令面探测通过(store.rdmCockpitAvailable)。默认 false:能力未知/缺失不展示驾驶舱入口。 */
rdmCockpit?: boolean
}
export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWorkspaceOptions = {}): WorkspaceEntry[] {
@@ -83,6 +121,7 @@ export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWork
}
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
if (opts.rdmAudit === true) ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
if (opts.rdmCockpit === true) ranked.push({ rank: takeFree(3), entry: BUILTINS[2] })
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
return ranked
.filter(item => item.rank <= 9)
+331
View File
@@ -0,0 +1,331 @@
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('renders stable semantic timestamps on user and assistant messages', () => {
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理材料' }]), id: 'user', role: 'user' }, reply([{ type: 'text', text: '材料已收到' }])]
const html = render()
expect(html.match(/<time class="message-timestamp"/g)).toHaveLength(2)
expect(html.match(/dateTime="2026-10-12T10:00:00.000Z"/g)).toHaveLength(2)
expect(html).toContain('title="2026/10/12')
})
it('announces unread work without inserting its full result into the chat', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card({ status: 'done', work: { result: 'background-result-full-text', resultVersion: 2, readVersion: 1 } })]
const html = render()
expect(html).toContain('1 项工作有新结果')
expect(html).not.toContain('background-result-full-text')
})
it('shows an explicit removable result reference without copying its content into the draft', () => {
snapshot.state!.workReference = { cardId: 'work', resultVersion: 2, title: '材料整理' }
const html = render()
expect(html).toContain('基于「材料整理」的第 2 版结果')
expect(html).toContain('aria-label="移除工作结果引用"')
})
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('leaves completed results to the visible right work pane', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = completedResults()
const html = renderWork()
expect(html).toBe('')
})
it('keeps the work card in one detail location when the right work pane is visible', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card()]
const html = render()
expect(html).not.toContain('data-conversation-work')
expect(html).not.toContain('核对会议结论')
snapshot.state!.ui.rightCollapsed = true
expect(render()).toContain('核对会议结论')
})
it('keeps the conversation detail when the right pane is reading files', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.ui.rightTab = 'files'
snapshot.state!.cards = [card()]
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('shows only a location link for anchored confirmation when the work pane owns its actions', () => {
snapshot.state!.ui.rightCollapsed = false
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('在工作栏处理')
expect(html).not.toContain('确认新增')
})
it('keeps a submitted card as a location link when its full body is in the work pane', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card({ id: 'result_1', kind: 'result', title: '材料核对结果', status: 'done',
body: [{ type: 'markdown', markdown: '材料核对的完整正文' }] })]
snapshot.state!.messages = [reply([{ type: 'card', card: { cardId: 'result_1', kind: 'result', data: [] } }])]
const html = render()
expect(html).toContain('材料核对结果')
expect(html).not.toContain('展开全文')
snapshot.state!.ui.rightCollapsed = true
expect(render()).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('keeps a thinking-only record available for deliberate review', () => {
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
parts: [{ type: 'thinking', process: true, text: 'private-internal-deliberation' }],
live: true, idKey: 'thinking-test', renderPart: () => null,
}))
expect(html).toContain('private-internal-deliberation')
snapshot.state!.messages = [reply([{ type: 'thinking', process: true, text: 'private-internal-deliberation' },
{ type: 'text', text: '已完成核对' }])]
expect(render()).toContain('执行记录')
})
it('lets readers reopen the full output of a grouped read action', () => {
setPersistedOpen('tool-record:read-history:call-read:0', true)
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
parts: [{ type: 'tool_call', process: true,
toolCall: { id: 'call-read', name: 'fs_read_file', arguments: '{"path":"材料.txt"}' },
toolOutput: 'full-read-output' } as Message['parts'][number]],
live: false, idKey: 'read-history', renderPart: () => null,
}))
expect(html).toContain('full-read-output')
})
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('会覆盖旧草稿,原件保留')
})
})
+19 -3
View File
@@ -1,6 +1,6 @@
// 设计系统守护测试(父仓 DESIGN.md §11 禁止清单 · 宿主侧,2026-09-30)。
// 棘轮基线:现存豁免登记在下方;新增同类违规直接失败。收紧 = 修掉违规后删基线条目。
// 范围:src/styles.css 与 src/**/*.tsx。@ui 组件库侧由 Agent-System-UI 的守护测试覆盖。
// 范围:src/**/*.css 与 src/**/*.tsx。@ui 组件库侧由 Agent-System-UI 的守护测试覆盖。
import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { join, relative, dirname } from 'node:path'
@@ -36,8 +36,14 @@ describe('DESIGN.md 宪法在位', () => {
// ---- 2. 动效唯一所有者 ----
describe('动效唯一所有者', () => {
it('styles.css 无 @keyframes(全部走 @ui/motion.css 的 mk-* 类)', () => {
expect(stripCssComments(read(join(SRC, 'styles.css')))).not.toContain('@keyframes')
it('宿主 CSS 无 @keyframes 或私有 transition(全部走 @ui/motion.css 的 mk-* 类)', () => {
for (const path of walk(SRC).filter(p => p.endsWith('.css'))) {
const css = stripCssComments(read(path))
expect(css, `${relative(SRC, path)} 不得定义 @keyframes`).not.toContain('@keyframes')
const transitions = css.split('\n').filter(line => /transition\s*:/.test(line) && !/transition\s*:\s*none\b/.test(line))
if (path.endsWith('styles.css')) continue // 历史 transition 由现有棘轮逐步收紧;新分层 CSS 不再扩散。
expect(transitions, `${relative(SRC, path)} 动效必须消费 mk-*`).toEqual([])
}
})
})
@@ -53,6 +59,16 @@ describe('裸色值棘轮', () => {
const extra = found.filter(h => !allow.has(h))
expect(extra, `新增裸色值 ${[...new Set(extra)].join(', ')}——改用 tokens 令牌;确需豁免更新 HEX_BASELINE_STYLES 并写明理由`).toEqual([])
})
it('分层组件 CSS 只消费令牌,不派生私有颜色', () => {
const componentCss = walk(SRC).filter(p => p.endsWith('.css') && !p.endsWith('styles.css'))
const extra: string[] = []
for (const path of componentCss) {
const css = stripCssComments(read(path))
for (const hex of collectHex(css)) extra.push(`${relative(SRC, path)}: ${hex}`)
if (/color-mix\s*\(/.test(css)) extra.push(`${relative(SRC, path)}: color-mix`)
}
expect(extra, '分层组件 CSS 的颜色须由 @ui/tokens.css 提供').toEqual([])
})
it('组件 TSX 无裸色值', () => {
const extra: string[] = []
for (const p of walk(SRC).filter(p => /\.(tsx|ts)$/.test(p) && !p.endsWith('.d.ts'))) {
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import {
absorbServerDocs, applyCell, cellDuty, completeness, editCell, replacementNote, saveIssues, savePayload,
type DutyDocDraft,
} from '../src/duties/draft'
function doc(over: Partial<DutyDocDraft> = {}): DutyDocDraft {
return {
docId: 'd1', title: '需求', code: 'UR-1', typeKey: 'design', stage: 'BD',
dutyVersion: 3, activeRound: 0, legacySectionOverrides: 0, dirty: false,
author: 'alice', reviewers: ['bob'], approver: 'carol',
...over,
}
}
describe('文档职责草稿', () => {
it('单元格由三权反推,三人视图读到同一份角色', () => {
const roles = { author: 'alice', reviewers: ['bob', 'dave'], approver: 'carol' }
expect(cellDuty(roles, 'alice')).toBe('author')
expect(cellDuty(roles, 'bob')).toBe('reviewer')
expect(cellDuty(roles, 'dave')).toBe('reviewer')
expect(cellDuty(roles, 'carol')).toBe('approver')
expect(cellDuty(roles, 'erin')).toBe('none')
expect(completeness(roles)).toEqual({ authors: 1, reviewers: 2, approvers: 1, conflicts: 0 })
})
it('替换编制前先记住原编制,并保证一人一责', () => {
const next = applyCell({ author: 'alice', reviewers: ['bob'], approver: 'carol' }, 'bob', 'author')
expect(next.replacedAuthor).toBe('alice')
expect(next.roles).toEqual({ author: 'bob', reviewers: [], approver: 'carol' })
expect(replacementNote({ author: 'alice', reviewers: ['bob'], approver: 'carol' }, 'bob', 'author')).toBe('将替换alice')
})
it('清掉唯一编制后允许留在草稿,但禁止保存', () => {
const cleared = editCell([doc()], 'd1', 'alice', 'none')
expect(cleared[0].author).toBe('')
expect(cleared[0].dirty).toBe(true)
expect(saveIssues(cleared[0])).toContain('需指定编制人')
expect(savePayload(cleared, false).issues.join(' ')).toContain('需指定编制人')
})
it('过期服务端页不覆盖未保存草稿', () => {
const dirty = doc({ author: 'erin', dirty: true, dutyVersion: 3 })
const absorbed = absorbServerDocs([dirty], [{
docId: 'd1', title: '需求', typeKey: 'design', dutyVersion: 4,
}], [{ docId: 'd1', owner: 'alice', duty: 'author' }, { docId: 'd1', owner: 'bob', duty: 'reviewer' }, { docId: 'd1', owner: 'carol', duty: 'approver' }])
expect(absorbed[0].author).toBe('erin')
expect(absorbed[0].dutyVersion).toBe(3)
})
it('写入结果未知时采用服务端版本,不再留着旧草稿重放', () => {
const dirty = doc({ author: 'erin', dirty: true, dutyVersion: 3 })
const absorbed = absorbServerDocs([dirty], [{
docId: 'd1', title: '需求', typeKey: 'design', dutyVersion: 4,
}], [
{ docId: 'd1', owner: 'alice', duty: 'author' },
{ docId: 'd1', owner: 'bob', duty: 'reviewer' },
{ docId: 'd1', owner: 'carol', duty: 'approver' },
], true)
expect(absorbed[0]).toMatchObject({ author: 'alice', dutyVersion: 4, dirty: false })
})
it('保存载荷是每份脏文档的完整目标,超过 20 份被拒绝', () => {
const many = Array.from({ length: 21 }, (_, index) => doc({ docId: `d${index}`, dirty: true }))
const blocked = savePayload(many, true)
expect(blocked.issues).toContain('一次保存最多 20 份文档')
const one = savePayload([doc({ dirty: true, activeRound: 2 })], true)
expect(one.docs[0]).toMatchObject({
docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'carol',
expectedDutyVersion: 3, confirmInvalidation: true,
})
})
})
+64 -4
View File
@@ -5,7 +5,9 @@ import {
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated,
applyCheck, applyFinalizing,
hasSettledReply, liveToMessage, livePhaseLabel, showProcessInline, type LiveItem,
runEventsToTimeline,
} from '../src/live'
import type { StreamEvent } from '../src/contracts'
import type { LiveState } from '../src/store'
function live(over: Partial<LiveState> = {}): LiveState {
@@ -159,6 +161,61 @@ describe('liveToMessage', () => {
})
})
it('reopens the ordered complete process from persisted run events', () => {
const output = 'x'.repeat(5000)
const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent =>
({ seq, type, payload, runId: 'r1', conversationId: 'c1', ts: '2026-10-14T10:00:00Z', v: 1 })
const parts = runEventsToTimeline([
event(1, 'thinking.delta', { index: 0, text: '先核对' }),
event(2, 'message.delta', { index: 0, text: '开始读取材料' }),
event(3, 'tool.call', { callId: 'call1', name: 'fs_read_file', arguments: '{"path":"a.txt"}' }),
event(4, 'tool.output', { callId: 'call1', text: output }),
event(5, 'tool.result', { callId: 'call1', status: 'ok', summary: '读取成功' }),
event(6, 'run.phase', { phase: 'finalizing' }),
event(7, 'message.delta', { index: 1, text: '最终回复' }),
])
expect(parts.map(part => part.type)).toEqual(['thinking', 'text', 'tool_call', 'tool_result'])
expect((parts[2] as typeof parts[number] & { toolOutput: string }).toolOutput).toBe(output)
expect(parts.some(part => part.text === '最终回复')).toBe(false)
})
it('retains collaboration notes from a child after the parent has replied', () => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'parent'): StreamEvent =>
({ seq, type, payload, runId, conversationId: 'c1', ts: '2026-10-14T10:00:00Z', v: 1 })
const parts = runEventsToTimeline([
event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(2, 'run.phase', { phase: 'finalizing' }),
event(3, 'message.delta', { index: 0, text: '最终回复' }),
event(4, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
event(5, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
], 'parent')
expect(parts.map(part => part.text)).toEqual([
'协作代理「资料核对」已开始', '协作进展:正在核对附录', '协作结束:附录已核对',
])
expect(parts.every(part => part.replaySource === 'agent')).toBe(true)
})
it('coalesces cumulative child updates without mixing tasks or erasing tool phases', () => {
const notes = [
['one', '我来检索'], ['two', '核对第二份材料'], ['one', '我来检索一下材料'],
['one', '调用 knowledge_search'], ['one', '根据检索结果,开始整理'],
...Array.from({ length: 80 }, () => ['one', '根据检索结果,开始整理完整报告']),
]
const events: StreamEvent[] = notes.map(([agentRun, note], seq) => ({
seq: seq + 1, type: 'agent.progress', payload: { agentRun, note }, runId: agentRun,
conversationId: 'c', ts: '2026-10-15T10:00:00Z', v: 1,
}))
expect(runEventsToTimeline(events, 'parent').map(part => part.text)).toEqual([
'协作进展:我来检索一下材料', '协作进展:核对第二份材料',
'协作进展:调用 knowledge_search', '协作进展:根据检索结果,开始整理完整报告',
])
})
it('uses the stable run start time for the temporary assistant message', () => {
expect(liveToMessage(live({ startedAt: Date.parse('2026-10-15T10:00:00Z') })).createdAt)
.toBe('2026-10-15T10:00:00.000Z')
})
describe('livePhaseLabel', () => {
it('names the current activity in Chinese', () => {
let items: LiveItem[] = applyThinkingDelta([], '想', 0)
@@ -175,10 +232,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)
+18 -1
View File
@@ -1,7 +1,7 @@
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
import { describe, expect, it } from 'vitest'
import { humanChangePasswordError, humanLoginError } from '../src/components/LoginPage'
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
describe('humanLoginError(登录)', () => {
it('401 统一为用户名或密码不正确', () => {
@@ -30,3 +30,20 @@ describe('humanChangePasswordError(改密门)', () => {
expect(humanChangePasswordError('400: password must be 8-256 characters')).toBe('password must be 8-256 characters')
})
})
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
expect(humanRedeemError('409: login key already used')).toBe('该 Key 已被使用过,请联系管理员重新签发')
expect(humanRedeemError('401: invalid or expired login key')).toBe('Key 无效或已过期,请联系管理员重新签发')
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
})
it('未知错误剥状态码前缀透传', () => {
expect(humanRedeemError('400: bad')).toBe('bad')
})
})
describe('humanChangePasswordError(登录恢复新增文案)', () => {
it('并发凭据变化给出重新登录指引', () => {
expect(humanChangePasswordError('401: credentials changed concurrently; retry login')).toBe('登录状态已变化(密码在其他地方被更新),请重新登录')
})
})
+100 -4
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 { splitTimeline, mergeProcessTimeline, 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,66 @@ 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 intermediate narration with its tools in reading order and the reply outside', () => {
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.timeline.map(part => part.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'tool_call'])
expect(split.timeline.filter(part => part.type === 'text').map(part => part.text)).toEqual([
'我会先核对**两份可读材料**。', '评审意见未能读取,完整结论尚未生成。',
])
expect(split.final.map(part => part.text)).toEqual(['接下来确认日期冲突。'])
})
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).toEqual([])
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'text', '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(['text', '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 narration beside execution records when it 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' } }),
@@ -32,7 +92,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('promotes only the last narration 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' } }),
@@ -41,7 +101,6 @@ describe('splitTimeline', () => {
]
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([])
})
@@ -54,6 +113,43 @@ describe('splitTimeline', () => {
})
})
it('enriches saved process without losing retry notes or parsed inline thinking', () => {
const saved: Part[] = [
p({ type: 'text', process: true, text: '模型在输出前失败,正在重试' }),
p({ type: 'thinking', process: true, text: '检查附件边界' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } }),
p({ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } }),
]
const replayed: Part[] = [
p({ type: 'text', process: true, text: '<think>检查附件边界</think>' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' }, toolOutput: '完整输出' } as Part),
p({ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } }),
p({ type: 'text', process: true, text: '最终回复' }),
p({ type: 'text', process: true, text: '协作进展:附录已核对' }),
]
const merged = mergeProcessTimeline(saved, replayed, [p({ type: 'text', text: '最终回复' })])
expect(merged.map(part => part.text).filter(Boolean)).toEqual([
'模型在输出前失败,正在重试', '检查附件边界', '协作进展:附录已核对',
])
expect((merged[2] as Part & { toolOutput: string }).toolOutput).toBe('完整输出')
expect(merged.filter(part => part.type === 'tool_call')).toHaveLength(1)
})
it('does not repeat a partial event delta beside the complete saved narration', () => {
const saved = [p({ type: 'text', process: true, text: '开始读取材料' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } })]
const replayed = [p({ type: 'text', process: true, text: '开始读取' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } })]
expect(mergeProcessTimeline(saved, replayed, []).map(part => part.text).filter(Boolean)).toEqual(['开始读取材料'])
})
it('keeps a collaboration result even when the final reply contains its short status', () => {
const note = p({ type: 'text', process: true, text: '协作结束:已完成', replaySource: 'agent' } as Part)
const merged = mergeProcessTimeline([p({ type: 'text', process: true, text: '完成' })], [note],
[p({ type: 'text', text: '已完成' })])
expect(merged.map(part => part.text)).toEqual(['完成', '协作结束:已完成'])
})
describe('artifact ids in reply text', () => {
it('extracts unique ids in order', () => {
expect(artifactIdsIn('见 art_0123456789ab 和 `art_abcdefabcdef`,再提 art_0123456789ab'))
+368
View File
@@ -0,0 +1,368 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { auth } from '../src/auth'
import { store } from '../src/store'
import type { ActivityCard, Conversation, Project } from '../src/contracts'
import { assignDocDutiesPayload, memberAddPayload } from '../src/rdmManagement'
const project = (id: string, settings?: Project['settings']): Project => ({
id, name: id, settings, createdAt: '', updatedAt: '',
})
const ready = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' })
const task = (id: string): Conversation => ({ id, projectId: 'host-1', title: id, createdAt: '', updatedAt: '' })
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(ok => { resolve = ok })
return { promise, resolve }
}
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
let projects: Project[]
let tasks: Conversation[]
let requests: { path: string; init?: RequestInit }[]
let route: (path: string, init?: RequestInit) => Promise<Response>
beforeEach(async () => {
projects = []; tasks = []; requests = []
const saved = new Map<string, string>()
vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null,
setItem: (key: string, value: string) => { saved.set(key, value) },
removeItem: (key: string) => { saved.delete(key) } })
route = async (path, init) => {
if (path === '/api/projects' && !init?.method) return response(projects)
if (path.startsWith('/api/conversations') && !init?.method) return response(tasks)
if (path === '/api/me') return response({ owner: 'alice', name: 'Alice', auth: true })
if (path.endsWith('/artifacts')) return response([])
if (path.endsWith('/cards')) return response({ cards: [] })
if (path.endsWith('/surfaces')) return response([])
if (['/api/profiles', '/api/plugins', '/api/workspaces', '/api/commands', '/api/admin-zone/sections'].includes(path)) return response([])
if (path === '/api/settings') return response({ schema: [], defaults: {}, user: {}, project: {} })
if (path.startsWith('/api/commands/')) return response({ payload: { ok: true } })
return response({})
}
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
requests.push({ path, init })
return route(path, init)
})
vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
auth.beginSession()
await store.refreshProjects()
await store.refreshTasks()
store.setUI({ lastProjectId: null })
requests = []
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('project command identity', () => {
it.each(['archived', 'closed'])('preserves canonical member, permission and duty reads for a %s project', async state => {
const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state })
const commands = ['rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get',
'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan']
for (const command of commands) await api.rdmProjectCommand(historical, command, { docId: 'doc-2' })
expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([
{ path: '/api/commands/rdm.project.member.list', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.perm.check', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.perm.matrix', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.review.doc.status', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.catalog', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.get', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.matrix', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.preview', params: { projectId: 'domain-7', docId: 'doc-2' } },
{ path: '/api/commands/rdm.duty.legacy.scan', params: { projectId: 'domain-7', docId: 'doc-2' } },
])
})
it.each(['archived', 'closed'])('still blocks project and document mutations for a %s project', async state => {
const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state })
for (const command of ['rdm.project.member.add', 'rdm.project.member.remove', 'rdm.project.member.setRole',
'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', 'rdm.duty.legacy.apply',
'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final']) {
await expect(api.rdmProjectCommand(historical, command)).rejects.toThrow()
}
expect(requests).toEqual([])
})
it.each(['rdm.perm.matrix', 'rdm.duty.catalog', 'rdm.duty.get', 'rdm.project.member.setRole'])('uses the same canonical boundary for %s', async command => {
await api.rdmProjectCommand(ready, command, { projectId: 'another-host', member: 'bob' })
expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([
{ path: `/api/commands/${command}`, params: { projectId: 'domain-7', member: 'bob' } },
])
})
it('sends member and duty commands to the canonical domain project from service metadata', async () => {
await api.rdmProjectCommand(ready, 'rdm.project.member.add', memberAddPayload(ready.id, 'bob'))
await api.rdmProjectCommand(ready, 'rdm.review.assign.doc', assignDocDutiesPayload(ready.id, 'doc-2', 'alice', ['bob'], 'carol'))
expect(requests.map(r => JSON.parse(String(r.init?.body)).params)).toEqual([
{ projectId: 'domain-7', member: 'bob', role: 'member' },
{ projectId: 'domain-7', docId: 'doc-2', author: 'alice', reviewers: ['bob'], approver: 'carol' },
])
})
it.each([
project('host-1', { 'rdm.state': 'unbound' }),
project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'failed' }),
project('host-1', { 'rdm.projectId': 'domain-7' }),
project('host-1'),
])('does not invent a domain ID or send commands for an unready binding: %j', async p => {
await expect(api.rdmProjectCommand(p, 'rdm.project.member.list')).rejects.toThrow()
expect(requests).toEqual([])
})
})
describe('project work and mutation failures', () => {
it('keeps private history readable for an archived project while refusing new work', async () => {
projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'archived' })]
tasks = [task('private-history')]
const previous = route
route = async (path, init) => {
if (path === '/api/conversations/private-history') return response({ conversation: tasks[0],
messages: [{ id: 'own-message', conversationId: 'private-history', role: 'user', parts: [{ type: 'text', text: 'My history' }], createdAt: '' }],
runs: [], activeHitl: [], lastSeq: 0 })
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
return previous(path, init)
}
await store.refreshProjects()
await store.openTask('private-history')
await expect(store.createTask()).rejects.toThrow()
expect(store.getState().currentConvId).toBe('private-history')
expect(store.getState().messages.map(m => m.id)).toEqual(['own-message'])
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
})
it.each([ready, project('host-1')])('creates private work with the host ID in ready RDM and ordinary base mode: %j', async p => {
projects = [p]
await store.refreshProjects()
const previous = route
route = async (path, init) => {
if (path === '/api/projects/host-1/conversations' && init?.method === 'POST') {
tasks = [task('new-private')]
return response(tasks[0])
}
if (path === '/api/conversations/new-private') {
return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 })
}
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
return previous(path, init)
}
await store.createTask()
expect(store.getState().currentConvId).toBe('new-private')
expect(requests.filter(r => r.init?.method === 'POST').map(r => r.path)).toEqual(['/api/projects/host-1/conversations'])
})
it.each(['provisioning', 'failed', 'unbound', 'archived', 'closed'])('does not create a fallback project or conversation for a selected %s project', async state => {
projects = [project('host-1', { 'rdm.state': state }), project('host-2')]
await store.refreshProjects()
requests = []
await expect(store.createTask()).rejects.toThrow()
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
expect(store.getState().currentProjectId).toBe('host-1')
expect(store.getState().toast).toMatch(/项目.*(稍后|负责人|恢复|关闭)/)
})
it('does not bypass a selected project absent from the current list', async () => {
await store.openProject('revoked-project')
requests = []
await expect(store.createTask()).rejects.toThrow()
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
})
it.each(['delete', 'rename'])('rejects a denied %s instead of resolving success', async action => {
route = async () => response({ error: 'project administration required' }, 403)
const mutation = action === 'delete' ? api.deleteProject('host-1') : api.renameProject('host-1', 'renamed')
await expect(mutation).rejects.toThrow(/403/)
})
it('retains selection and gives an actionable message when project creation is rejected', async () => {
projects = [ready]
await store.refreshProjects()
route = async () => response({ error: 'name reserved' }, 409)
await expect(store.createProject('Taken')).rejects.toThrow()
expect(store.getState().currentProjectId).toBe('host-1')
expect(store.getState().toast).toMatch(/名称.*(其他|更换)/)
expect(store.getState().toast).not.toContain('已创建')
})
it('checks a denied settings save instead of resolving success', async () => {
route = async () => response({ error: 'denied' }, 403)
await expect(api.putProjectSettings('host-1', {})).rejects.toThrow(/403/)
})
})
describe('identity-scoped state', () => {
it('resets state on a successful cookie login, without relying on a component callback', async () => {
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
route = async () => response({ owner: 'bob', name: 'Bob', isAdmin: false, mustChangePassword: false, passwordSet: true, sessionScope: 'normal' })
await api.login('bob', 'password-for-test')
expect(store.getState().projects).toEqual([])
expect(store.getState().tasks).toEqual([])
})
it('clears old state as soon as logout starts, even while the server response is pending', async () => {
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
const pending = deferred<Response>()
route = async () => pending.promise
const logout = api.logout()
expect(store.getState().projects).toEqual([])
expect(store.getState().tasks).toEqual([])
pending.resolve(response({ ok: true }))
await logout
})
it('clears the previous account when the authenticated owner changes during identity refresh', async () => {
await store.refreshMe()
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
const previous = route
projects = []; tasks = []
route = (path, init) => path === '/api/me' ? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true })) : previous(path, init)
await store.refreshMe()
expect(store.getState()).toMatchObject({ me: { owner: 'bob' }, projects: [], tasks: [], currentProjectId: null, currentConvId: null })
})
it('clears projects, private tasks, previews and persisted selections on a new session', async () => {
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
store.openSurface('surface-alice')
store.setUI({ projectSettingsId: 'host-1', adminZoneOpen: true, activeWorkspace: 'private-workspace' })
localStorage.setItem('as.conv', 'alice-private')
auth.beginSession()
expect(store.getState()).toMatchObject({ projects: [], tasks: [], me: null, currentProjectId: null, currentConvId: null, artifacts: [], previewTabs: {}, previewActive: {} })
expect(store.getState().ui).toMatchObject({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null })
expect(localStorage.getItem('as.conv')).toBeNull()
expect(JSON.parse(localStorage.getItem('as.ui') ?? '{}').lastProjectId).toBeNull()
})
it('drops old project responses after switching accounts even when IDs are shared', async () => {
const old = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects' ? old.promise : previous(path, init)
const pending = store.refreshProjects()
auth.beginSession()
projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' })]
route = previous
await store.refreshProjects()
old.resolve(response([project('alice-only')]))
await pending
expect(store.getState().projects.map(p => p.id)).toEqual(['host-1'])
expect(store.getState().currentProjectId).toBe('host-1')
})
it('drops an old private task list after logout and a new session', async () => {
const old = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/conversations' ? old.promise : previous(path, init)
const pending = store.refreshTasks()
auth.logout()
auth.beginSession()
tasks = [task('bob-private')]
route = previous
await store.refreshTasks()
old.resolve(response([task('alice-private')]))
await pending
expect(store.getState().tasks.map(t => t.id)).toEqual(['bob-private'])
})
it('drops an old artifact response when the next account selects the same project', async () => {
await store.openProject('host-1')
const old = deferred<Response>()
const previous = route
route = (path, init) => path.endsWith('/artifacts') ? old.promise : previous(path, init)
const pending = store.refreshArtifacts('host-1')
auth.beginSession()
route = previous
await store.openProject('host-1')
old.resolve(response([{ id: 'alice-artifact', projectId: 'host-1', owner: 'alice', name: 'Alice private', kind: 'file', size: 10, createdAt: '', updatedAt: '' }]))
await pending
expect(store.getState().artifacts).toEqual([])
})
it.each(['action', 'pin', 'dismiss', 'to-project', 'tidy'] as const)('ignores a late project card %s from the previous account on the same shared project', async operation => {
projects = [ready]
await store.refreshProjects()
const old = deferred<Response>()
const previous = route
route = (path, init) => path.startsWith('/api/cards/') ? old.promise : previous(path, init)
const card: ActivityCard = { id: 'alice-card', projectId: 'host-1', kind: 'progress', status: 'active', title: 'Alice private work', createdAt: '', updatedAt: '' }
if (operation === 'tidy') store.upsertCard({ ...card, status: 'done' })
const pending = operation === 'action' ? store.cardAction(card, 'finish')
: operation === 'pin' ? store.cardPin(card, true)
: operation === 'dismiss' ? store.cardDismiss(card)
: operation === 'tidy' ? store.tidyCards() : store.cardToProject(card)
auth.beginSession()
route = previous
await store.refreshProjects()
old.resolve(response({ card: { ...card, status: 'done' }, origin: { ...card, status: 'dismissed' } }))
await pending
expect(store.getState().projectCards).toEqual([])
expect(store.getState().cards).toEqual([])
})
it('does not reopen a previous account conversation after its project load finishes late', async () => {
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects()
await store.openProject('another-project')
const old = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects/host-1/artifacts' ? old.promise : previous(path, init)
const pending = store.openTask('alice-private')
auth.beginSession()
tasks = []
route = previous
await store.refreshProjects()
old.resolve(response([]))
await pending
expect(store.getState().currentConvId).toBeNull()
expect(requests.map(r => r.path)).not.toContain('/api/conversations/alice-private')
})
it('does not resolve old task creation as success after a new login', async () => {
projects = [ready]
await store.refreshProjects()
const old = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects/host-1/conversations' ? old.promise : previous(path, init)
const pending = store.createTask()
auth.beginSession()
route = previous
await store.refreshProjects()
old.resolve(response(task('alice-created')))
await expect(pending).rejects.toThrow(/登录状态/)
expect(store.getState().currentConvId).toBeNull()
})
it('does not return a previous account attachment ID when artifact refresh finishes after login', async () => {
projects = [ready]
await store.refreshProjects()
const old = deferred<Response>()
const started = deferred<void>()
const previous = route
route = (path, init) => {
if (path.endsWith('/uploads')) return Promise.resolve(response({ artifactId: 'alice-upload', filename: 'alice.txt', size: 3 }))
if (path.endsWith('/artifacts')) { started.resolve(); return old.promise }
return previous(path, init)
}
const pending = store.uploadAttachment(new File(['abc'], 'alice.txt'))
await started.promise
auth.beginSession()
route = previous
await store.refreshProjects()
old.resolve(response([]))
expect(await pending).toBeNull()
})
})
describe('export capability identity', () => {
it('drops the old identity export capability before any new command refresh', async () => {
const previous = route
route = async (path, init) => path === '/api/commands'
? response(['rdm.inspect.export.create', 'rdm.inspect.export.status', 'rdm.inspect.export.download']
.map(id => ({ id, enabled: true }))) : previous(path, init)
await store.refreshPlugins()
expect(store.getState().rdmExportAvailable).toBe(true)
auth.beginSession()
expect(store.getState().rdmExportAvailable).toBe(false)
})
})
+101
View File
@@ -0,0 +1,101 @@
// U6 页面接线契约测试(方案 r1 U6/KTD5):RDM 管理/项目页面的能力门控与命令
// 载荷构造是纯逻辑——先红后实现。门控语义与 rdmAuditReady 同源:命令清单不可用
//(未登录/请求失败/null)一律视为未就绪(fail-closed),普通基座不出现 RDM 页面。
import { describe, expect, it } from 'vitest'
import {
RDM_ROLES_COMMANDS, RDM_MEMBER_COMMANDS, RDM_DUTY_COMMANDS, ROLE_ACTIONS,
rdmRolesReady, rdmMembersReady, rdmDutyReady, dutyMatrixPayload, dutyPreviewPayload,
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(rdmDutyReady(all(RDM_DUTY_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(rdmDutyReady(drop(RDM_DUTY_COMMANDS, 'rdm.duty.matrix'))).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(rdmDutyReady('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' })
expect(dutyMatrixPayload('p1', { view: 'matrix', docLimit: 10 })).toEqual({ projectId: 'p1', view: 'matrix', docLimit: 10 })
expect(dutyPreviewPayload('p1', [{ docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', expectedDutyVersion: 2, confirmInvalidation: true }]))
.toEqual({ projectId: 'p1', docs: [{ docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', expectedDutyVersion: 2, confirmInvalidation: true }] })
})
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')
})
})
+26
View File
@@ -0,0 +1,26 @@
import { afterEach, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { loadRunEvents } from '../src/runEvents'
import type { StreamEvent } from '../src/contracts'
afterEach(() => vi.restoreAllMocks())
function event(seq: number): StreamEvent {
return { seq, type: 'message.delta', runId: 'run', conversationId: 'conversation', ts: '2026-10-15T07:00:00Z', v: 1, payload: { index: 0, text: `已保存输出${seq}` } }
}
it('reads a complete saved record across the 500-event boundary in order', async () => {
const query = vi.spyOn(api, 'runEventsPage')
.mockResolvedValueOnce({ events: Array.from({ length: 500 }, (_, i) => event(i + 1)) })
.mockResolvedValueOnce({ events: [event(501), event(502)] })
const controller = new AbortController()
const result = await loadRunEvents('conversation', 'run', controller.signal)
expect(result.map(item => item.seq)).toEqual(Array.from({ length: 502 }, (_, i) => i + 1))
expect(query).toHaveBeenNthCalledWith(1, 'conversation', 'run', 0, controller.signal)
expect(query).toHaveBeenNthCalledWith(2, 'conversation', 'run', 500, controller.signal)
})
it('stops an invalid nonadvancing record cursor instead of fetching forever', async () => {
const query = vi.spyOn(api, 'runEventsPage').mockResolvedValue({ events: Array.from({ length: 500 }, (_, i) => event(i + 1)) })
await expect(loadRunEvents('conversation', 'run', new AbortController().signal)).rejects.toThrow('cursor did not advance')
expect(query).toHaveBeenCalledTimes(2)
})
-16
View File
@@ -1,16 +0,0 @@
import { describe, expect, it } from 'vitest'
import { scrollRatio, scrollTopForRatio } from '../src/scrollsync'
describe('scrollsync', () => {
it('没有溢出时不同步', () => {
expect(scrollRatio({ scrollTop: 0, scrollHeight: 100, clientHeight: 100 })).toBeNull()
expect(scrollTopForRatio({ scrollHeight: 80, clientHeight: 80 }, 0.5)).toBeNull()
})
it('按可滚动范围的比例映射,并夹在 0 到 1', () => {
expect(scrollRatio({ scrollTop: 50, scrollHeight: 200, clientHeight: 100 })).toBe(0.5)
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, 0.5)).toBe(100)
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, 2)).toBe(200)
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, -1)).toBe(0)
})
})
+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],
])
})
})
+24
View File
@@ -0,0 +1,24 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { auth } from '../src/auth'
afterEach(() => vi.restoreAllMocks())
describe('versioned work request bodies', () => {
it('preserves multiple selections and free input on the card action endpoint', async () => {
const request = vi.spyOn(auth, 'fetch').mockResolvedValue(new Response(JSON.stringify({ card: { id: 'task' } })))
await api.cardAction('task', 'work.submit', { resultVersion: 3, selectedIds: ['summary', 'parameters'], freeText: '请说明依据' })
const [url, options] = request.mock.calls[0]
expect(url).toBe('/api/cards/task/actions/work.submit')
expect(JSON.parse(String(options?.body))).toEqual({ resultVersion: 3, selectedIds: ['summary', 'parameters'], freeText: '请说明依据' })
})
it('sends a result identity separately from the user message and discards client display metadata', async () => {
const request = vi.spyOn(auth, 'fetch').mockResolvedValue(new Response(JSON.stringify({ message: { id: 'reply' } })))
const reference = { cardId: 'task', resultVersion: 3, title: '说明书整理', result: 'must-not-be-client-context' }
await api.sendMessage('conversation', '比较这些重点', [], 'request-1', reference)
expect(JSON.parse(String(request.mock.calls[0][1]?.body))).toEqual({
text: '比较这些重点', attachmentIds: [], requestId: 'request-1', taskRef: { cardId: 'task', resultVersion: 3 },
})
})
})
+520
View File
@@ -0,0 +1,520 @@
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() })
it('preserves rejected referenced input without retrying it or blocking later queued chat', async () => {
const reference = { cardId: 'saved-result', resultVersion: 1, title: '整理结果' }
const submit = vi.spyOn(api, 'sendMessage').mockRejectedValueOnce(new Error('400: reference no longer valid'))
.mockResolvedValue({ message: { id: 'accepted', conversationId: 'a', role: 'user', parts: [], createdAt: '' } })
expect(await store.sendMessage('请解释重点', ['saved-upload'], 'retry-safe-id', reference)).toBe('rejected')
const failed = store.getState().outbox[0]
expect(failed).toMatchObject({ id: 'retry-safe-id', text: '请解释重点', attachmentIds: ['saved-upload'], taskRef: reference })
expect(failed.deliveryError).toBeTruthy()
await store.flushOutbox()
expect(submit).toHaveBeenCalledTimes(1)
store.enqueueOutbox('先讨论其他事情', [], [])
await store.flushOutbox()
expect(submit).toHaveBeenCalledTimes(2)
expect(store.getState().outbox).toHaveLength(1)
store.clearOutboxReference(failed.id)
await store.sendOutboxNow(failed.id)
expect(submit).toHaveBeenLastCalledWith('a', '请解释重点', ['saved-upload'], 'retry-safe-id')
expect(store.getState().outbox).toEqual([])
})
describe('work projection ordering and context', () => {
it('keeps an earlier background task out of a newer foreground process', async () => {
const current: Run = { id: 'new-topic', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
const child: Run = { ...current, id: 'old-child', kind: 'subagent', parentRunId: 'old-topic' }
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [current, child]))
await store.openConversation('a')
store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在回答新的问题' }, current.id))
store.applyEvent(event(2, 'agent.progress', { agentRun: child.id, note: '旧材料仍在整理' }, child.id))
expect(store.getState().live?.agentNotes[child.id]).toBeUndefined()
store.applyEvent(event(3, 'agent.spawn', { agentRun: 'new-child', name: '新任务', task: '核对新问题' }, current.id))
store.applyEvent(event(4, 'agent.progress', { agentRun: 'new-child', note: '核对当前问题' }, 'new-child'))
expect(store.getState().live?.agentNotes['new-child']).toBe('核对当前问题')
store.applyEvent(event(5, 'agent.result', { agentRun: child.id, summary: '旧材料已整理' }, child.id))
expect(store.getState().live?.runId).toBe(current.id)
expect(store.getState().live?.agentNotes[child.id]).toBeUndefined()
})
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('sends a versioned optional choice once and keeps it out of the conversation', async () => {
const task = { ...card('work-choice', 'done'), work: { resultVersion: 2, readVersion: 0,
result: '完整材料结果', decision: { mode: 'multiple' as const, state: 'open' as const } } }
store.upsertCard(task)
const response = deferred<Awaited<ReturnType<typeof api.cardAction>>>()
const action = vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
const payload = { resultVersion: 2, selectedIds: ['summary', 'facts'], freeText: '请保留来源' }
const pending = store.cardAction(task, 'work.submit', payload)
await store.cardAction(task, 'work.submit', payload)
expect(action).toHaveBeenCalledOnce()
expect(action).toHaveBeenCalledWith(task.id, 'work.submit', payload)
response.resolve({ card: { ...task, work: { ...task.work, decision: { ...task.work.decision, state: 'submitted' } } },
receipt: { id: 'unexpected-work-receipt', conversationId: 'a', role: 'assistant', parts: [], createdAt: '' } })
await pending
expect(store.getState().messages).toEqual([])
expect(store.getState().cardActionStates[task.id]).toBeUndefined()
expect(store.getState().cards.find(c => c.id === task.id)?.work?.decision?.state).toBe('submitted')
})
it('does not unlock an uncertain optional submission merely because its result is done', async () => {
const task = { ...card('work-unknown', 'done'), work: { resultVersion: 1, readVersion: 0,
result: '结果', decision: { mode: 'single' as const, state: 'open' as const } } }
store.upsertCard(task)
vi.spyOn(api, 'cardAction').mockRejectedValue(new TypeError('connection lost'))
vi.mocked(api.listCards).mockResolvedValue([task])
await store.cardAction(task, 'work.submit', { resultVersion: 1, selectedIds: ['summary'] })
expect(store.getState().cardActionStates[task.id]).toBe('unknown')
store.upsertCard({ ...task, work: { ...task.work, decision: { ...task.work.decision, state: 'submitted' } } })
expect(store.getState().cardActionStates[task.id]).toBeUndefined()
})
it('settles successful metadata actions while an ordinary card still needs action', async () => {
const task = { ...card('work-read', 'action_required'), work: { resultVersion: 1, readVersion: 0 } }
store.upsertCard(task)
vi.spyOn(api, 'cardAction').mockResolvedValue({ card: { ...task, work: { ...task.work, readVersion: 1 } } })
await store.cardAction(task, 'work.read', { resultVersion: 1 })
expect(store.getState().cardActionStates[task.id]).toBeUndefined()
})
it('retains an explicit result reference across a queued send without adding it to chat text', async () => {
const task = { ...card('work-ref', 'done'), work: { result: '完整结果', resultVersion: 3, readVersion: 3 } }
store.upsertCard(task)
store.referenceWork(task)
const reference = store.getState().workReference!
store.enqueueOutbox('比较重点', [], [], reference)
store.clearWorkReference()
expect(store.getState().outbox[0].taskRef).toEqual({ cardId: 'work-ref', resultVersion: 3, title: '整理材料' })
const send = vi.spyOn(api, 'sendMessage').mockResolvedValue({ message: { id: 'queued-ref', conversationId: 'a', role: 'user', parts: [], createdAt: '' } })
await store.flushOutbox()
expect(send).toHaveBeenCalledWith('a', '比较重点', [], expect.any(String), reference)
expect(store.getState().workReference).toBeNull()
})
it('clears record selection and result reference when navigating to another conversation', async () => {
const task = { ...card('work-record', 'done'), sourceRunId: 'child', work: { result: '完整结果', resultVersion: 1, readVersion: 1 } }
store.upsertCard(task)
store.openWorkRecord(task)
store.referenceWork(task)
expect(store.getState().workRecord?.runId).toBe('child')
await store.openConversation('b')
expect(store.getState().workRecord).toBeNull()
expect(store.getState().workReference).toBeNull()
})
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)
})
})
+26 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { buildWorkspaceEntries, rdmAuditReady, RDM_AUDIT_COMMANDS } from '../src/workspaces'
import { buildWorkspaceEntries, rdmAuditReady, rdmExportReady, exportNeedsDraft, RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } from '../src/workspaces'
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置「代理」。
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
@@ -72,4 +72,29 @@ describe('rdmAuditReady', () => {
expect(rdmAuditReady(null)).toBe(false)
expect(rdmAuditReady(undefined)).toBe(false)
})
it('导出命令不参与七命令就绪门', () => {
expect(rdmAuditReady(all)).toBe(true)
const withExportOff = [
...all,
{ id: 'rdm.inspect.export.create', enabled: false },
]
expect(rdmAuditReady(withExportOff)).toBe(true)
})
})
describe('rdmExportReady', () => {
const audit = RDM_AUDIT_COMMANDS.map(id => ({ id, enabled: true }))
const exp = RDM_EXPORT_COMMANDS.map(id => ({ id, enabled: true }))
it('三条导出命令启用才显示导出区', () => {
expect(rdmExportReady([...audit, ...exp])).toBe(true)
expect(rdmExportReady(audit)).toBe(false)
expect(rdmExportReady(exp.filter(c => c.id !== 'rdm.inspect.export.download'))).toBe(false)
})
it('正式报告不完整时才要求草稿确认', () => {
expect(exportNeedsDraft('正式报告不完整(合规缺口)——传 draft:true 生成草稿')).toBe(true)
expect(exportNeedsDraft('项目不存在')).toBe(false)
})
})