Compare commits

...
Author SHA1 Message Date
weichen ae761c2c4a Merge remote-tracking branch 'origin/main' into feature/deliverable-docx-r1 2026-10-01 02:50:12 +00:00
weichen e87bcf595f Render chat deliverable cards and pin live artifact projection
Why: tool artifacts were only reachable via the collapsed process
section or the artifacts workspace; users had to read model text to
learn a file existed. Deliverables should appear directly in the
conversation as an openable card (ZCode/Kimi pattern) that opens the
right-rail preview.

Evidence:
- Conversation.tsx artifact_ref renderer upgraded from a small chip to
  a deliverable card (icon + name + kind/size meta + preview affordance
  + single download action); whole card click = rail preview (the one
  preview placement, fullscreen still reachable from the rail header)
- live.ts projects artifact items as content parts pinned after the
  reply text, isomorphic with the new llm.go persisted order; card is
  visible while streaming
- styles: deliverable-card uses tokens only (radius-lg card, bg-raised,
  hairline border, mk-rise-in entrance); design-guard suite green
- vitest 40/40 (live.test pinned-tail case updated, messageparts.test
  covers new persisted order plus legacy inline order); tsc clean

Impact: every generated artifact (docx/pdf/image/…) now surfaces as an
in-chat card immediately, click-to-preview in the right rail with real
formatting for styled docx; workspace panel (⌘1) unchanged as the
file-system fallback.

Refs: task feature/deliverable-docx-r1 (backend: write_docx + pinned
artifact_ref)
2026-10-01 02:49:08 +00:00
weichen 7418e507a0 Merge pull request 'Redesign the shell on Zai tokens: panels, admin zone, artifact convergence, chat motion' (#24) from feature/ui-tokens-v3 into main 2026-10-01 02:00:47 +00:00
weichen 1fc1d2f9b4 Merge origin/main (CF3 project inbox) and re-tokenize its styles
Why: Keep the task branch on the shared baseline before acceptance.
The merged CF3 code carried a bare hex fallback (#d8d8d8) and off-scale
font size in .inbox-scope — the design guard caught it; fixed to tokens
(--border / --radius-full / --fs-ui-sm / --bg-hover) per DESIGN.md.
Evidence: clean ort merge; npx tsc -b clean; npm test -> 39 passed
(guard green after the fix); npm run build succeeds.
Impact: brings the project inbox dual view into the branch with its
styles brought onto the token system; no behavior changes.
Refs: task feature/ui-tokens-v3.
2026-09-30 19:30:22 +00:00
weichen e7fa8756db Merge remote-tracking branch 'origin/main' into feature/ui-tokens-v3 2026-09-30 19:28:35 +00:00
weichen 801e551d8d Migrate chat folds to Collapsible and adopt ToolBlock
Why: Approved redesign phase 4: process folds, long-reply folds, thinking
blocks and context-observation fold move to the @ui Collapsible primitive
(two-layer height+fade animation, delayed unmount, open-state memory);
tool calls render as ToolBlock rows with running sweep and paired results;
the live bar gains the ZCode worked-time line with sweep; input placeholder
states the backend's honest busy semantics (serialized per conversation).
Evidence: npx tsc -b clean; npm test -> 39 passed (details-guard included);
npm run build succeeds; combined make test recorded in the Dev task card.
Impact: Conversation.tsx replaces all <details> usage (guard-enforced);
ProcessParts pairs tool_call with its trailing tool_result into ToolBlock;
styles.css rewrites fold selectors for the Collapsible structure and adds
message mount animation (mk-rise-in); no contract or API changes.
Refs: Agent-System-Dev task feature/ui-tokens-v3 (phase 4).
2026-09-30 19:25:10 +00:00
weichen b3112aa412 Merge pull request 'Add project inbox dual view with live project stream (CF3)' (#23) from feature/project-inbox-r1 into main 2026-09-30 19:19:29 +00:00
weichen 93587fe813 Add project inbox dual view with live project stream (CF3)
Why: D49/D51 — aggregated project view (project cards + other
conversations' pending) so todos survive conversation switches;
D50 — subscribe the project channel via scope=project.
Evidence: api endpoints (listProjectCards/cardToProject, card.* named
SSE events — also fixes live card events previously dropped);
store projectCards/inboxScope/applyProjectEvent/cardToProject with
badge combining both scopes; Workbench scope tabs + grouped ProjectInbox;
styles for scope control.
Impact: existing conversation inbox behavior unchanged; badge now counts
both scopes.
Refs: documents proposal 2026-09-30-card-stream-artifact-unify-project-inbox.md (D48-D51).
2026-09-30 19:16:57 +00:00
weichen bfaf65ae1f Converge artifact preview to one placement and one download
Why: Approved redesign phase 3: one artifact = one preview placement
(right rail by default; full-screen overlay as the only large-window form)
plus a single download button per surface. Removes the redundant
three-action chips/rows and the nested modal chrome in AgentTracker.
Evidence: npx tsc -b clean; npm test -> 38 passed. Combined runs recorded
in the Dev task card.
Impact: new store.requestPreview() is the single preview entry (Command
semantics; placement derived there); artifact chips and workspace rows
reduce to title-click = rail preview, eye = full preview, download;
docBridge card opens route through the same entry; AgentTracker drops its
inner .modal shell (OverlayHost is the only chrome). No API changes.
Refs: Agent-System-Dev task feature/ui-tokens-v3 (phase 3).
2026-09-30 19:14:31 +00:00
weichen 48a14fb4d2 Move system capabilities into an admin-only full-screen zone
Why: Approved redesign phase 2 (IA): normal users run projects entirely
through conversation; system capabilities (user management, agent profiles,
plugin management, plugin-declared workspaces) belong behind a dedicated
admin entry, not in the user-facing sidebar or settings. The sidebar
workspace strip is removed; artifacts stay reachable via the right-rail
tab and conversation chips.
Evidence: npx tsc -b clean; npm test -> 38 passed (design-guard ratchet
still green). Full combined run recorded in the Dev task card.
Impact: new AdminZone full-screen view with a declarative section registry
(users/profiles/plugins/plugin-workspaces via UIRenderer); sections moved
out of SettingsModal (now personal preferences only) and AdminUsersModal
(now password change only); store ui.adminUsersOpen renamed adminZoneOpen;
workspaceEntries narrowed by role so plugin entries no longer take Cmd1-9
for regular users; theme toggle moved into the account menu.
Refs: Agent-System-Dev task feature/ui-tokens-v3 (phase 2).
2026-09-30 19:11:20 +00:00
weichen 8bc5b47914 Apply Zai panel layout, mono primary buttons and type scale
Why: Phase 1 of the approved frontend redesign: re-skin the shell to the
Zai look (floating rounded panels on an inset base with 4px gutters),
invert primary buttons to the mono scheme, and move every font size onto
the --fs-ui-* scale. Also wires @ui/motion.css and replaces local
keyframes with mk-* utilities.
Evidence: npx tsc -b clean; npm test -> 38 passed (new host-side
design-guard ratchet); npm run build succeeds. Combined Dev make test
recorded in the Dev task card.
Impact: styles.css fully re-tokenized (panel borders/radius, primary
button color-mix hover, login brand panel in near-mono navy); inline
fontSize literals in components replaced with scale tokens except one
decorative glyph in the ratchet baseline. No component class names or
behavior changed.
Refs: Agent-System-Dev task feature/ui-tokens-v3; DESIGN.md (parent repo).
2026-09-30 19:00:06 +00:00
weichen c5e93568a5 Merge pull request 'Wire preview capabilities into the app shell and dual-dock placement' (#21) from feature/file-preview-caps into main 2026-09-30 17:20:27 +00:00
weichen 89bfa83cae Wire preview capabilities into the app shell and dual-dock placement
Why: the adjudicated plan (D1-D4) calls for office/link previews with
loaders injected from the host, right-rail light preview alongside the
overlay viewer, and artifact deletion kept as an API capability with no
UI entry.

Evidence: previewsetup.ts injects docx (mammoth), xlsx (SheetJS, sheet
tabs, 200-row cap), pptx (pptx-preview full-fidelity; pptx2html was
dropped for a broken module entry — tXml case mismatch breaks Linux
builds — per the D2 fallback clause), and the /api/url-preview fetcher,
all via dynamic import: the main entry grows ~12KB while xlsx (430KB),
pptx (1.2MB) and jszip (98KB) load on first preview. DocViewer becomes a
FilePreview container (keeps text-family editing); Workbench gains a
preview tab with rail-preview state (store.railPreview); artifact chips
and panel rows gain a rail-preview action; the panel delete button is
removed while api.deleteArtifact and DELETE /api/artifacts/{id} remain.
tsc clean; vitest 33 passed; production build succeeds.

Impact: previews work end to end for the whole format matrix; deletion
stays plugin-reachable only.

Refs: task feature/file-preview-caps; ui fb76239; core 4d29301; backend
6d4ccfd.
2026-09-30 17:17:29 +00:00
weichen 6752b788b9 Merge pull request 'Render submit_card card parts as CardAnchor with inline full-text expansion (CF1)' (#20) from feature/cardflow-r1 into main 2026-09-30 17:12:36 +00:00
weichen 411d09b3a8 Merge remote-tracking branch 'origin/main' into feature/cardflow-r1 2026-09-30 16:55:44 +00:00
weichen af0385451a Merge pull request 'Bump vitest to 4.1.11 past the mocker path traversal advisory' (#19) from fix/dep-vuln-bumps into main 2026-09-30 16:48:48 +00:00
weichen a61bd70399 Bump vitest to 4.1.11 past the mocker path traversal advisory
Why: GHSA-82fw-gwwq-j7x9 (path traversal / arbitrary file read via @vitest/mocker redirect mock) affects vitest 2.1.0-4.1.10; the locked 3.x toolchain was inside the range. Dev-only dependency, but the fix is a routine bump — follow-up to the 2026-09-30 dependency CVE inventory.
Evidence: vitest and @vitest/mocker now at 4.1.11 (first fixed release); npm audit reports 0 vulnerabilities; npx vitest run 33/33 green on vitest 4.
Impact: package.json/package-lock.json devDependency bump only; no production dependency or source change.
Refs: Dev-side task fix/dep-vuln-bumps (gitlink pin follows after combined verification)
2026-09-30 16:47:34 +00:00
weichen b329de3bf5 Add cardflow deltas on fresh main: CardAnchor routing, D32 locate, card contract field
Why: main(含任务1 PR #18 的 parts 模型与增强版 CF0)之上补齐卡流增量:cardId→CardAnchor 锚点路由、卡→消息定位(locateMessage + data-msg-id)、Part.card.cardId 契约镜像。
Evidence: tsc 0 错误;vitest 33/33(main 的 messageparts 等测试全过)。
Impact: Conversation/Workbench/store/contracts 四文件;CF0 与过程折叠采用 main 增强版不回退。
Refs: Dev 任务卡 feature/cardflow-r1。
2026-09-30 16:37:29 +00:00
weichen b067b57975 Merge pull request 'Agent-System-Frontend: review fixes for chat-process-split-r1 (symlink, live interleaving, artifact process)' (#18) from feature/chat-process-split-r1 into main 2026-09-30 16:15:19 +00:00
weichen c6464bc4b0 Merge main into feature/chat-process-split-r1 2026-09-30 16:15:07 +00:00
weichen 65f7753a79 Merge pull request 'Record Gitea access facts in AGENTS.md' (#17) from feature/gitea-access-facts into main 2026-09-30 16:08:28 +00:00
weichen 8712fadb49 Record Gitea access facts in AGENTS.md
Why: agents rediscovered Gitea hosts and credential locations from
scratch each session, and the retired git-panel host kept failing with
TLS broken-pipe errors before the right host was recalled.

Evidence: curl git-home...:30319/api/v1/version with token from
~/.secrets/gitea -> {"version":"1.27.3"}; ssh -p 31411 git@git-ssh...
(BatchMode) -> "Hi there, weichen!" with key ~/.ssh/home_gitea (ED25519,
SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20). Same section already
merged in Agent-System-Dev (PR #73).

Impact: AGENTS.md only; records hosts and credential locations, no
secret values (baseline hard boundary 2).

Refs: Agent-System-Dev PR #73
2026-09-30 16:06:16 +00:00
weichen 64837575a9 Port artifact events to the items stream and finish the main merge
Why: the merge commit left tsc broken in two spots - an artifact.created
handler still pushing to the removed LiveState.parts, and main's newer ui
icons not yet checked out in this worktree; also Part was imported unused.

Evidence: artifact.created now appends an artifact LiveItem (process=true,
matching the backend's ae8e5d0) projected to artifact_ref by
liveToMessage; new test asserts it lands after its tool call. tsc -b exit
0 after initializing the ui submodule checkout; vitest 33/33.

Impact: live artifact chips render inside the process stream and fold on
reconcile; no functional change elsewhere.

Refs: feature/chat-process-split-r1
2026-09-30 15:47:22 +00:00
weichen 765789f34d Merge main into feature/chat-process-split-r1
Why: main advanced with PR #15/#16 (part-order preservation, artifact
downloads, cross-session UI); task-sync requires the branch to carry it.

Evidence: resolution keeps the arrival-ordered items stream from this
branch (tool-driven process marking, pure/testable logic in live.ts) and
adopts main's rendering and ids: groupParts in-place process blocks,
real tool callIds on live tool parts, messageparts utilities, and the
restored phase-driven showProcessInline predicate alongside the CF0
long-reply excerpt that main's rewrite had dropped. vitest 32/32
(includes main's messageparts suite and this branch's 12 live cases);
tsc -b clean.

Impact: single coherent live pipeline - items accumulate in event order,
liveToMessage projects Parts with tool-driven process marks, rendering
folds consecutive process blocks in place; responding still collapses
the process area (Cursor-style) as originally reviewed.

Refs: feature/chat-process-split-r1
2026-09-30 15:43:49 +00:00
weichen 754ce83ffd Interleave live parts per turn and mark commentary process on tool.call
Why: liveToMessage emitted all text buckets then all tools, while the
persisted message interleaves per turn (thinking, text, calls), so parts
reordered on reconcile with 2+ tool turns. Commentary stayed in the reply
area until the next turn's text bucket appeared, because the process flag
was positional (i < length - 1) instead of tool-driven.

Evidence: LiveState now holds an arrival-ordered items stream
(thinking/text deltas merge into their last segment; tools land in place),
so event order - which matches the backend's per-turn append order - maps
1:1 onto persisted parts. A text segment is process iff a tool.call lands
before the next thinking/text segment, aligning with the backend's
Process: len(calls) > 0; the collapse predicate moved to testable
showProcessInline. Long replies (>800 chars) now show a 100-char excerpt
and label the count as Markdown source length. vitest 27/27 pass (was 5
live cases, now 12 incl. multi-turn order, immediate process marking,
collapse predicate); tsc -b clean.

Impact: streaming UI no longer reshuffles parts across turns or on final
reconcile; tool-turn commentary folds into the process area as soon as the
tool call arrives. Residual edge (documented in live.ts): a silent
tool-only turn with no text and no thinking attributes its call to the
previous text segment.

Refs: code review of feature/chat-process-split-r1 (2026-09-30)
2026-09-30 15:30:41 +00:00
weichen 22a87232d7 Untrack the node_modules symlink and ignore both directory and link forms
Why: 642299d committed node_modules as a symlink pointing at the absolute
path /home/weichen/projects/Agent-System-Dev/modules/frontend/node_modules.
The .gitignore rule "node_modules/" only matches directories, not symlinks,
so the link slipped through and other clones would receive this machine's
path while normal installs get bypassed.

Evidence: git ls-tree HEAD node_modules => mode 120000 blob before this
commit; git check-ignore -v node_modules exits 0 with rule
".gitignore:2:node_modules" after unstaging the link.

Impact: fresh clones no longer carry a machine-specific symlink; local
installs work normally. No code changes.

Refs: code review of feature/chat-process-split-r1 (2026-09-30)
2026-09-30 15:28:29 +00:00
weichen fb219e04a3 Merge pull request 'Preserve part order, wire artifact downloads, and upgrade file management' (#15) from feature/chat-order-artifact-files into main 2026-09-30 15:23:49 +00:00
weichen edb5dbe73e Preserve part order, wire artifact downloads, and upgrade file management
Why: AssistantMessage reordered a message's parts by the process flag,
so tool calls floated above the text that surrounded them instead of
appearing where they actually ran; live streams had the same defect via
a fixed template order and never showed artifacts mid-run. Generated
files (e.g. meeting-minutes docx) had no visible download path, the
artifacts panel was a bare list, DocViewer nested a modal inside the
overlay, and settings rows used a legacy grid with a floating save
button.

Evidence: messageparts.ts adds groupParts (order-preserving grouping of
consecutive process parts, unit-tested) plus humanSize/artifactIcon
helpers; Conversation renders blocks in original order and the artifact
chip becomes icon + open + download; LiveState.parts accumulates events
in arrival order including artifact_ref so live view matches the
persisted message; ArtifactsPanel gains search/kind filter/upload and
per-row preview/download/delete-confirm; DocViewer is a single-layer
OverlayHost with pdf iframe, csv/json monospace, and a binary info card
with a download CTA; SettingsModal migrates to @ui SettingRow with the
save button pinned in the shell actions bar; inbox cards receive the
docBridge so result cards can open artifacts. tsc clean, vitest 20
passed, production build succeeds.

Impact: message timelines read in true execution order during and after
runs; every artifact is previewable and downloadable from the message
chip, the workspace panel, and the viewer; settings render on the fixed
two-column shell.

Refs: task feature/chat-order-artifact-files (ui# c3e1f3b backend#
44b51fa).
2026-09-30 15:21:46 +00:00
weichen 9c87953877 Merge pull request 'Surface cross-session controls in the UI with a project settings panel' (#16) from feature/cross-session-reading into main 2026-09-30 15:19:56 +00:00
weichen a3c556cd0a Surface cross-session controls in the UI with a project settings panel
Why: the layered permission model (project gate + per-conversation
outbound/inbound flags) shipped in Backend PR #18 needs visible
controls; project scope also had no settings UI yet though the API
existed.

Evidence: npm run build (tsc -b + vite build) and npm test (15/15)
pass.

Impact: new schema-driven ProjectSettingsModal (first project-scope
settings surface, writes putProjectSettings, reuses SettingsModal
group/input helpers); TaskSidebar gains a private shield on rows and
in the row menu, per-conversation allowCrossRead in the row menu, and
a project-settings gear on project group heads; Conversation header
gains a cross-read toggle; contracts/api/store carry the new flags.

Refs: proposals/2026-09-30-cross-session-reading.md; Backend PR #18;
Dev task feature/cross-session-reading.
2026-09-30 15:19:46 +00:00
weichen 642299ddd4 Collapse process output the moment the final reply starts streaming (Cursor-style)
Why: 流式期间思考/工具/中间轮解说与最终回复混排在中栏,用户难以扫读答复;参考 Cursor:回复开始输出时过程即时折叠,可手动展开回看。
Evidence: npx tsc --noEmit 通过;npx vitest run 20/20(新增 test/live.test.ts:分桶、除末桶外 process 标记、终态同构)。
Impact: LiveState.text 改为按 delta index 分桶的 texts;responding 阶段过程折叠、acting 重展开;终态新增 >800 字长回复折叠(CF0);无契约变更。
Refs: 任务卡 feature/chat-process-split-r1;配套 backend 提交 0e76f59(工具轮文本 process=true)。
2026-09-30 14:54:37 +00:00
weichen b2fbcf72bc Merge pull request 'Consume the UI capability platform (avatars, settings shell)' (#14) from feature/ui-capability-platform into main 2026-09-30 14:23:17 +00:00
weichen 59ffe044b5 Consume the UI capability platform: shared icons/avatars, settings shell
Why: avatars, cards, and settings are base capabilities now (user
decision 2026-09-30); the host must eat its own dogfood so plugins get
the identical surfaces.

Evidence:
- Local icons.tsx removed; Icon/Avatar/BrandMark/primitives/Settings
  Shell imported from @ui; message stream gains avatar columns (user
  initial from /api/me name, assistant brand mark); SettingsModal
  rebuilt on SettingsShell (side-nav sections with badges, EmptyState
  for plugins, iconified actions); AdminUsersModal rows show shared
  avatars.
- tsconfig paths pin react/react-dom types to the frontend copy — the
  source-composed ui package otherwise resolves a second @types/react
  through its own node_modules during make frontend (npm ci), which
  breaks type identity (surfaced by SVGProps crossing the boundary);
  vite dedupe already guarantees one runtime copy.
- Headless on the real binary: avatar columns render, settings shell
  switches sections with badges, admin rows show avatars, zero console
  errors; build + vitest green.

Impact: visual/structural only; no API or state changes.

Refs: Agent-System-Documents proposal 2026-09-30-ui-redesign-and-auth.md
2026-09-30 14:22:37 +00:00
weichen 01672deec5 Merge pull request 'Restyle the whole UI: linear icons, layered surfaces, refined chrome (R4)' (#13) from feature/visual-refresh-r4 into main 2026-09-30 12:44:34 +00:00
weichen 9fc1387235 Restyle the whole UI: linear icon set, layered surfaces, refined chrome
Why: user feedback (2026-09-30) — the UI still lacked the polish and
modern feel of ZCode; per its design system (calm/dense/operational)
this is the R4 visual convergence pass.

Evidence:
- New icons.tsx: 32 Lucide-style linear SVG icons replacing every emoji/
  glyph (grep for the old glyph set now returns 0 in components); brand
  mark + workspace-icon mapping shared by sidebar, rail, login, menus.
- Login/change gates gain a wide-screen brand panel (gradient + radial
  glows + feature list), segmented tabs, focus-ring inputs, glowing
  submit; sidebar gets a solid accent New Task CTA, inline-icon search,
  selected task with accent bar, hover-reveal row actions, account
  footer chips; chat area gains floating rounded composer with focus
  glow, calmer header/tool blocks, greener HITL confirmations, empty
  state mark; modals gain backdrop blur + spring-in.
- Two headless review iterations on the real binary (--auth on, both
  themes): fixed bubble padding, headline break, and lifted the panel
  step for clearer three-column separation; final walk shows 21 SVGs,
  zero leftover glyphs, zero console errors, build + vitest + full make
  test green.

Impact: pure visual layer (CSS + icon components + class-level JSX);
no behavior, API, or state changes; both themes driven by the new token
scale in the ui repo.

Refs: Agent-System-Documents proposal 2026-09-30-ui-redesign-and-auth.md (R4)
2026-09-30 12:43:39 +00:00
weichen 2516df5b3b Merge pull request 'Login page, forced password change, admin user management (R2)' (#12) from feature/auth-admin-accounts into main 2026-09-30 11:17:04 +00:00
weichen 1d6b8a64a6 Add login page, forced password change, and admin user management
Why: accounts are admin-issued (user decision 2026-09-30, no self
registration); initial and admin-reset passwords must force a change on
first login.

Evidence:
- LoginPage: password (primary) + API key tabs; ChangePasswordGate
  blocks the app until the forced change completes (mismatch/length
  validation inline); App auth state machine checking -> login ->
  mustchange -> ok, session cookie flows through fetch automatically.
- Account menu gains change-password and admin-only user management;
  AdminUsersModal: create account (random initial password generator,
  admin checkbox), reset password, status table (ADMIN/USER, pending
  change chips).
- Fixed a real bug found in the headless walk: menu toggles passed
  e.currentTarget into the setState updater where React nulls it by
  render time, so anchor was null and the menu never rendered; capture
  the element before the updater.
- Headless journey on the real binary with --auth on: admin bootstrap
  via CLI -> first-login forced change -> app; admin creates bob (bob
  sees no user management); alice reset via CLI -> forced change on
  next login; API-key tab login; logout; zero console errors. npm run
  build + vitest green; make test full suite exit 0.

Impact: replaces the old API-key-only LoginGate; embedded no-auth mode
unchanged (no login page shown).

Refs: proposal 2026-09-30-ui-redesign-and-auth.md section 5 (R2,
admin-issued variant)
2026-09-30 11:16:47 +00:00
weichen 6cfc1059c5 Merge pull request 'Rebuild left nav as task-centric sidebar (option A, R1)' (#11) from feature/task-sidebar-r1 into main 2026-09-30 10:45:33 +00:00
weichen dc70399d45 Rebuild the left nav as a task-centric sidebar (option A, R1)
Why: creation entries were split (project + at sidebar top-left, new
session at bottom-left) and the project tree forced per-project
navigation; the approved option A design makes one primary New Task
entry, organization a view mode, and identity always present.

Evidence:
- New TaskSidebar: single New Task split button (current-project context,
  Cmd-N, secondary menu for new project / new task in project), project
  <-> timeline organization with persisted collapse, pinned section,
  archive view toggle, hover row actions, account footer fed by
  /api/me (embedded mode shows local tenant; logout clears the key),
  workspace icon strip retained (D25 shared list with Cmd-1..9).
- store: tasks = cross-project list via GET /api/conversations; openTask
  switches project context; pin/archive optimistic + refresh; last
  project context persisted; stale conversations field removed.
- tasklist.ts pure helpers + 6 vitest cases; npm run build passes;
  headless walk on the real binary: cross-project open, pinned-first,
  archive hide/show/restore, new-task flow, zero console errors.

Impact: ProjectNav removed; contracts.ts regenerated from core
(pinned/archived); no composer/right-pane changes (D25/D26/D31 intact).

Refs: Agent-System-Documents docs/proposals/2026-09-30-ui-redesign-and-auth.md
2026-09-30 10:41:12 +00:00
weichen 3aeafc4440 Merge pull request 'feature/prompt-library' (#10) from feature/prompt-library into main 2026-09-30 10:36:34 +00:00
weichen 4abf13d33d Mirror the prompt module contract and add the library API
Why: the backend prompt library ships new contract shapes and an
endpoint; the frontend mirror and api binding keep the TS side in sync
for upcoming management UI.

Evidence: tsc + vite build green as part of composite make test.

Impact: contracts.ts gains PromptModule/Trigger and
AgentProfile.promptModules; api gains listPromptModules. No UI change
yet.

Refs: feature/prompt-library task card.
2026-09-30 10:35:13 +00:00
weichen 0eb9066510 Merge pull request 'feature/chat-live-quality: live streaming bubble, markdown spacing, document-task quality' (#9) from feature/chat-live-quality into main 2026-09-30 09:59:24 +00:00
weichen 616795be41 Show the live streaming bubble and fix markdown spacing
Why: during a run nothing was rendered (the assistant message only lands
at completion, so the LiveState projection never hit the screen), and
white-space:pre-wrap on .part-text turned marked's inter-block newlines
into blank lines, giving ordered lists a huge per-item gap. A latent
race also wiped thinking/tool/phase state that arrived before the send
POST resolved, and thinking text ran inline with the answer.

Evidence: headless playwright against the mock backend shows the live
bar, an expanded thinking panel, tool chips and incremental text with a
blinking cursor mid-run; list item gaps measure 2.1px with
white-space:normal; make test (frontend 9 tests) green.

Impact: users watch thinking, tool calls and the answer stream in real
time; markdown typography (lists, tables, quotes, headings) is compact;
terminal runs fold the process trail behind a summary.

Refs: feature/chat-live-quality task card.
2026-09-30 09:57:35 +00:00
weichen a54022c438 Constrain the chat column height so the message stream scrolls
Why: .chat is a column-flex item of .main without min-height:0, so its
content-based automatic minimum height grew past the viewport; .chat-body
never overflowed (never scrolled) and the input bar was pushed off-screen.
Evidence: headless Chromium on mock serve, 1024x620: before .chat=2130px
and input-bar bottom=717 (>viewport), chat-body scrollable=false; after
.chat=620, chat-body clientH 501 / scrollH 2011 with wheel scroll moving
600px up and back to bottom, input-bar bottom=620. Variants (empty
conversation, single short message, 1024x400, 720x600) keep the input
visible. Full combo make test green.
Impact: chat page was unusable beyond one screen of content; single CSS
rule chain, no behavior change elsewhere.
Refs: Agent-System-Dev task card feature/chat-scroll-fix.
2026-09-30 09:15:45 +00:00
weichen a617504268 Merge pull request 'Add the tidy action and collapsed-rail pending badge (T5)' (#8) from feature/polish-r1 into main 2026-09-30 08:18:01 +00:00
weichen 4f4711a22a Add the tidy action and collapsed-rail pending badge (T5)
Why: T5 notification strategy - the collapsed right rail must still surface the pending count (badge), and the inbox needs a one-key tidy (done cards dismissed; resolved verdicts keep their stamp).
Evidence: tsc clean; vitest 9/9; vite build ok; delegation-only logic.
Impact: App collapsed rail badge, store tidyCards (done->dismiss via existing cardDismiss), Workbench tidy button, styles.
Refs: docs/proposals/2026-09-30-ui-inbox-command-workspace.md T5; task card feature/polish-r1
2026-09-30 08:13:13 +00:00
weichen e5b65ca79a Merge pull request 'Make the right rail pure inbox and move deep views into overlay workspaces (T4)' (#7) from feature/workspaces-r1 into main 2026-09-30 08:08:25 +00:00
weichen 58f3599660 Sync main before merge (stale-candidate guard) 2026-09-30 08:08:01 +00:00
weichen 298c41ff67 Make the right rail pure inbox and move deep views into overlay workspaces (T4)
Why: D25 final state - the right rail answers only "what needs me now" (inbox + a pinned current-task card); artifacts/agents/plugin panels become overlay workspaces with Cmd shortcuts and a left-rail quick section; DocViewer/AgentTracker migrate onto the shared OverlayHost.
Evidence: tsc clean; vitest 9/9; vite build ok; delegation-only logic (overlay semantics covered by ui OverlayHost suite).
Impact: Workbench drops classic tabs and gains CurrentTaskCard (plan+todos merged); new Workspaces.tsx (workspaceEntries, Cmd1-9 handler, builtin/plugin/server workspace rendering); store gains activeWorkspace/serverWorkspaces + open/close; ProjectNav quick section; Conversation project badge (D26); DocViewer/AgentTracker render through OverlayHost.
Refs: docs/proposals/2026-09-30-ui-inbox-command-workspace.md T4; task card feature/workspaces-r1
2026-09-30 08:04:21 +00:00
weichen 6a2b21a28d Merge pull request 'Wire inline confirm, cardlink locate, drop zone, and symmetric command menu (T3)' (#6) from feature/command-bus-r1 into main 2026-09-30 07:55:00 +00:00
weichen ee85556584 Wire inline confirm, cardlink locate, drop zone, and symmetric command menu (T3)
Why: the chat column must host the danger-command confirm interaction inline (anchored, blocking hint, collapsed once resolved), cardlinks must locate their inbox card (D32), files dropped on the conversation must attach, and the slash menu must reflect the command registry (same source as buttons/cards/chat, D27).
Evidence: vitest 9/9; tsc clean; delegation-only logic (rendering covered by ui InlineConfirmCard suite, dispatch by backend e2e).
Impact: Conversation renders anchored confirm cards via @ui InlineConfirmCard with openInbox locate (store helper), PartCard cardlink click locates the inbox card, chat-body accepts file drops through the existing upload path, composer hints while a confirm is open, CommandMenu tolerates commands without a slash alias and marks danger=high.
Refs: docs/proposals/2026-09-30-ui-inbox-command-workspace.md T3; task card feature/command-bus-r1
2026-09-30 07:54:41 +00:00
weichen 801a684c26 Merge pull request 'Wire inbox components (frontend)' (#4) from feature/inbox-r2 into main 2026-09-30 06:58:17 +00:00
weichen 9c44d6b497 Wire inbox tab as the right-rail head with live card state
Why: T2 frontend of the inbox (D25): the inbox is the first tab with a live
  pending count; card events (created/updated/status/resolved) upsert the
  projection in real time; confirm-projection cards route clicks back to HITL
  resolution (run resumes, server syncs both sides); other actions call the
  cards API and append the server-persisted receipt into the timeline.

Evidence: tsc + build green; vitest 9/9; card state resets/refreshes on
  conversation open and reconcile.

Impact: default right tab becomes 收件箱 (localStorage-persisted preference
  still honored for existing users).

Refs: modules/documents docs/proposals/2026-09-30-ui-inbox-command-workspace.md
2026-09-30 06:58:16 +00:00
weichen b020ab5dab Merge pull request 'Regenerate contracts from Core (activity card, card events, UI vocabularies)' (#3) from feature/core-cards-commands-r1 into main 2026-09-30 06:25:52 +00:00
weichen 53d1f04607 Regenerate contracts from Core (activity card, card events, UI vocabularies)
Why: Core T1 added ActivityCard, card.* events, HITL anchoring fields and seven
  UI vocabularies; this repo's contracts.ts is the generated mirror.

Evidence: make contracts from Core main; frontend vitest 9/9 still green
  (additive types, shape tests unaffected).

Impact: Types available for T2 inbox implementation; no behavior change.

Refs: Agent-System-Documents proposal 2026-09-30-ui-inbox-command-workspace
2026-09-30 06:25:51 +00:00
weichen ab38738747 Merge pull request 'Consume Agent-System-UI via source-composition alias' (#2) from feature/ui-foundation-r0 into main 2026-09-30 05:52:22 +00:00
35 changed files with 3651 additions and 856 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
node_modules/
# 无斜杠形式同时忽略目录与符号链接(目录式规则不匹配 symlink,曾致链接被提交)。
node_modules
dist/
*.local
+17
View File
@@ -17,3 +17,20 @@
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。
- 跨仓 gitlink 变更须经组合验证后在 Dev 仓推进;受保护分支经 PR 合入。
- 验证命令见 PROJECT.md「验证」节;完成声明须附实际命令输出证据。
## Gitea 与 Git 访问事实(2026-09-30 实测)
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
见到一律按过期处理,不要再试。当前权威通道:
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
(各仓 origin 与 gitlink 均为此格式)。
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
用作 `Authorization: token <值>` 请求头。**token 值不入仓、不回显、不写进产出物**
(baseline 硬边界 2:只记录来源、字段与可用状态)。
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
+523 -194
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -10,16 +10,21 @@
"preview": "vite preview"
},
"dependencies": {
"jszip": "^3.10.2",
"mammoth": "^1.13.0",
"marked": "^15.0.7",
"pptx-preview": "^1.0.7",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^22.20.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vitest": "^3.0.5"
"vitest": "^4.1.11"
}
}
+49 -38
View File
@@ -1,57 +1,49 @@
import { useEffect, useRef, useState } from 'react'
import { auth } from './auth'
import { api } from './api'
import { LoginPage, ChangePasswordGate } from './components/LoginPage'
import { ChangePasswordModal } from './components/AdminUsersModal'
import { AdminZone } from './components/AdminZone'
import { Icon } from '@ui'
import { store, useStore } from './store'
import { ProjectNav } from './components/ProjectNav'
import { TaskSidebar } from './components/TaskSidebar'
import { Conversation } from './components/Conversation'
import { Workbench } from './components/Workbench'
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
import { AgentTracker } from './components/AgentTracker'
import { SettingsModal } from './components/SettingsModal'
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
import { DocViewer } from './components/DocViewer'
// 三栏布局:左(项目会话)/ 中(对话)/ 右(工作台);分隔条拖拽与超调收起(4.4)。
const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
const RIGHT_MIN = 280, RIGHT_MAX = 560
// 登录门:服务端开启 API key 认证时(PG 档默认)要求输入密钥;嵌入式档无认证直接放行。
function LoginGate({ onSuccess }: { onSuccess: () => void }) {
const [key, setKey] = useState('')
const [err, setErr] = useState('')
const submit = async () => {
auth.setKey(key)
try {
const r = await auth.fetch('/api/health')
if (r.ok) { onSuccess(); return }
} catch { /* 网络错误按认证失败处理,提示重试 */ }
auth.clear()
setErr('密钥无效或已撤销,请重试')
}
return (
<div className="login-gate">
<div className="login-card">
<h2>Agent-System</h2>
<p className="login-hint">本服务已启用 API key 认证。密钥由服务端创建:<code>agent-system apikey create --owner &lt;name&gt;</code></p>
<input type="password" value={key} onChange={e => setKey(e.target.value)}
placeholder="ask_…" onKeyDown={e => e.key === 'Enter' && submit()} autoFocus />
<button onClick={submit}>进入</button>
{err && <div className="login-err">{err}</div>}
</div>
</div>
)
}
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)
const toast = useStore(s => s.toast)
const trackerRunId = useStore(s => s.ui.trackerRunId)
const viewingArtifact = useStore(s => s.ui.viewingArtifact)
// authState:checking → ok(放行)| required(登录门)。
const [authState, setAuthState] = useState<'checking' | 'required' | 'ok'>('checking')
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(() => {
;(async () => {
try {
const r = await auth.fetch('/api/health')
setAuthState(r.status === 401 ? 'required' : 'ok')
if (r.status === 401) { setAuthState('login'); return }
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) { setMustChangeOwner(me.owner); setAuthState('mustchange'); return }
setAuthState('ok')
} catch {
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
}
@@ -95,9 +87,19 @@ export function App() {
window.addEventListener('mouseup', up)
}
if (authState === 'login') {
return <LoginPage
onAuthed={() => setAuthState('ok')}
onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} />
}
if (authState === 'mustchange') {
return <ChangePasswordGate
owner={mustChangeOwner}
onDone={async () => { await store.refreshMe().catch(() => undefined); setAuthState('ok') }}
onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} />
}
if (authState !== 'ok') {
return authState === 'required' ? <LoginGate onSuccess={() => setAuthState('ok')} />
: <div className="login-gate"><div className="login-card"><p>连接服务中…</p></div></div>
return <div className="login-gate"><div className="login-card"><p>连接服务中…</p></div></div>
}
return (
@@ -105,16 +107,19 @@ export function App() {
{!ui.leftCollapsed ? (
<>
<div className="left-nav" style={{ width: ui.leftWidth }}>
<ProjectNav />
<TaskSidebar />
</div>
<div className="divider" onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft} title="拖动调整宽度;继续向左超过最小宽度后松开可收起;双击切换" />
</>
) : (
<div className="left-nav collapsed" style={{ width: 52 }}>
<div className="icon-only">
<button title="展开侧栏" onClick={store.toggleLeft}>☰</button>
<button title="新会话" onClick={() => store.createConversation()}>✚</button>
<button title="设置" onClick={() => store.openSettings(true)}>⚙</button>
<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.openInbox()}><Icon name="inbox" />
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
</button>
<button title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" /></button>
</div>
</div>
)}
@@ -133,13 +138,19 @@ export function App() {
) : (
<div className="right-panel collapsed" style={{ width: 44 }}>
<div className="icon-only">
<button title="展开工作台" onClick={store.toggleRight}>◧</button>
<button title="展开收件箱" onClick={store.toggleRight}>◧
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
</button>
</div>
</div>
)}
<Workspaces />
{trackerRunId && <AgentTracker />}
{ui.settingsOpen && <SettingsModal />}
{ui.projectSettingsId && <ProjectSettingsModal />}
{ui.adminZoneOpen && <AdminZone />}
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
{toast && <div className="toast">{toast}</div>}
</div>
+50 -5
View File
@@ -3,7 +3,8 @@
import { auth } from './auth'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, PlanSnapshot, TodoList, UploadInfo,
AgentProfile, SettingDesc, StreamEvent, PlanSnapshot, TodoList, UploadInfo, PromptModule,
ActivityCard,
} from './contracts'
const BASE = ''
@@ -23,6 +24,23 @@ export const api = {
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }),
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(r)),
setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
}),
logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }),
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 }>
}),
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)),
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)),
@@ -43,6 +61,20 @@ export const api = {
auth.fetch(`${BASE}/api/hitl/${cardId}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resp) })
.then(r => json<{ resolved: boolean; pivoted: boolean; pivotText: string }>(r)),
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 }) })
.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)),
cardDismiss: (cardId: string) =>
auth.fetch(`${BASE}/api/cards/${cardId}/dismiss`, { method: 'POST' }).then(r => json<{ card: ActivityCard }>(r)),
listProjectCards: (projectId: string) =>
auth.fetch(`${BASE}/api/projects/${projectId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
cardToProject: (cardId: string) =>
auth.fetch(`${BASE}/api/cards/${cardId}/to_project`, { method: 'POST' }).then(r => json<{ card: ActivityCard; origin: ActivityCard }>(r)),
upload: (projectId: string, file: File) => {
const fd = new FormData()
fd.append('file', file)
@@ -50,12 +82,19 @@ export const api = {
},
listArtifacts: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/artifacts`).then(r => json<Artifact[]>(r)),
artifactContentUrl: (id: string) => auth.withKey(`${BASE}/api/artifacts/${id}/content`),
// 下载直链:后端按 ?download=1 回 Content-Disposition: attachment(RFC 5987 文件名)。
artifactDownloadUrl: (id: string) => {
const url = auth.withKey(`${BASE}/api/artifacts/${id}/content`)
return url.includes('?') ? `${url}&download=1` : `${url}?download=1`
},
saveArtifactContent: (id: string, content: string) => auth.fetch(`${BASE}/api/artifacts/${id}/content`, { method: 'PUT', body: content }),
deleteArtifact: (id: string) => auth.fetch(`${BASE}/api/artifacts/${id}`, { method: 'DELETE' }),
getPlan: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/plan`).then(r => r.ok ? r.json() : null) as Promise<PlanSnapshot | null>,
getTodos: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/todos`).then(r => r.ok ? r.json() : null) as Promise<TodoList | null>,
listProfiles: () => auth.fetch(`${BASE}/api/profiles`).then(r => json<AgentProfile[]>(r)),
listPromptModules: () => auth.fetch(`${BASE}/api/prompts`).then(r => json<PromptModule[]>(r)),
saveProfile: (p: AgentProfile) => auth.fetch(`${BASE}/api/profiles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() }),
deleteProfile: (id: string) => auth.fetch(`${BASE}/api/profiles/${id}`, { method: 'DELETE' }),
@@ -67,7 +106,8 @@ export const api = {
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name: string; description: string }[]>,
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>,
}
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
@@ -78,13 +118,17 @@ export interface SSEHandlers {
onError?: (err: Error) => void
}
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers): { close: () => void } {
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers, projectId?: string): { close: () => void } {
let closed = false
let es: EventSource | null = null
const connect = () => {
if (closed) return
es = new EventSource(auth.withKey(`${BASE}/api/events?conversationId=${encodeURIComponent(conversationId)}&since=${since}`))
// 项目流(CF3/D50):scope=project 订阅项目频道(伪会话键,信封带 ProjectID)。
const url = projectId
? `${BASE}/api/events?scope=project&projectId=${encodeURIComponent(projectId)}&since=${since}`
: `${BASE}/api/events?conversationId=${encodeURIComponent(conversationId)}&since=${since}`
es = new EventSource(auth.withKey(url))
es.onopen = () => handlers.onOpen?.()
es.onerror = () => {
if (closed) return
@@ -95,7 +139,8 @@ export function connectEvents(conversationId: string, since: number, handlers: S
// 同时监听具名事件与默认(服务端 event: 字段)。
const types = ['message.delta', 'thinking.delta', 'tool.call', 'tool.result', 'run.phase',
'hitl.request', 'hitl.resolved', 'plan.update', 'todo.update', 'agent.spawn', 'agent.progress',
'agent.result', 'artifact.created', 'artifact.updated', 'run.completed', 'run.failed', 'error']
'agent.result', 'artifact.created', 'artifact.updated', 'run.completed', 'run.failed', 'error',
'card.created', 'card.updated', 'card.status', 'card.resolved']
for (const t of types) es.addEventListener(t, ev => handle(t, (ev as MessageEvent).data))
}
+5
View File
@@ -30,6 +30,11 @@ export const auth = {
headers.set('Authorization', `Bearer ${key}`)
return fetch(input, { ...init, headers })
},
// 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。
logout() {
this.clear()
try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ }
},
// 无法带头的资源 URL(EventSource / <img> / 下载):追加 ?key=。
withKey(url: string): string {
const key = this.apiKey()
+64
View File
@@ -0,0 +1,64 @@
// 管理员用户管理已迁「系统管理」分区(components/adminSections.tsx UsersPanel,
// 重设计方案 阶段②)。本文件只保留登录者主动改密弹窗(与强制改密共用 API)。
import { useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { Icon } from '@ui'
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal ad-card" role="dialog" aria-label={title} style={{ maxWidth: 640 }}>
<div className="modal-header">
<b>{title}</b>
<span className="spacer" />
<button className="ad-mini" onClick={onClose} aria-label="关闭"><Icon name="x" size={13} /></button>
</div>
<div className="ad-body">{children}</div>
</div>
</div>
)
}
/** 主动改密(账户菜单入口):当前密码 + 新密码 + 确认。 */
export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
const me = useStore(s => s.me)
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [err, setErr] = useState('')
const [ok, setOk] = useState(false)
const [busy, setBusy] = useState(false)
const submit = async () => {
setErr('')
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
if (next.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.changePassword(current, next)
setOk(true)
store.toast('密码已更新')
setTimeout(onClose, 900)
} catch (e) {
setErr(String(e).includes('401') ? '当前密码不正确' : String(e))
} finally { setBusy(false) }
}
return (
<Shell title="修改密码" onClose={onClose}>
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
<div className="ad-changepw">
<label className="lg-label">当前密码({me?.owner ?? ''})</label>
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} />
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
<button className="lg-submit" disabled={busy || !current || !next || !confirm} onClick={submit}>{busy ? '提交中…' : '更新密码'}</button>
</div>
)}
</Shell>
)
}
+66
View File
@@ -0,0 +1,66 @@
// 系统管理(重设计方案 阶段②):管理员专属的全屏独立视图(带返回,非弹窗)。
// 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。
// 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。
import { useState } from 'react'
import { store, useStore } from '../store'
import { Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
interface AdminSectionDef {
key: string
label: string
icon: IconName
desc: string
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
render: () => React.ReactNode
}
function ProfilesPanelWrapper() {
const profiles = useStore(s => s.profiles)
return <ProfilesSection profiles={profiles} />
}
const SECTIONS: AdminSectionDef[] = [
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
]
export function AdminZone() {
const me = useStore(s => s.me)
const [sectionKey, setSectionKey] = useState('users')
// 双保险:入口已在菜单层门控(me?.isAdmin),此处再拦一次(纵深防御)。
if (!me?.isAdmin) return null
const section = SECTIONS.find(s => s.key === sectionKey) ?? SECTIONS[0]
return (
<div className="az" role="dialog" aria-label="系统管理">
<div className="az-head">
<button className="btn ghost" onClick={() => store.setUI({ adminZoneOpen: false })}>
<Icon name="chevron-down" size={14} /> 返回工作台
</button>
<span className="az-title">系统管理</span>
<span className="az-sub">仅管理员可见 · 变更即时生效</span>
<span style={{ flex: 1 }} />
<span className="az-tenant">{me.owner}</span>
</div>
<div className="az-body">
<nav className="az-side">
{SECTIONS.map(s => (
<button key={s.key} className={`az-nav-item ${s.key === sectionKey ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
<Icon name={s.icon} size={15} />
<span>{s.label}</span>
</button>
))}
</nav>
<main className="az-main">
<div className="az-section-head">
<h3>{section.label}</h3>
<p>{section.desc}</p>
</div>
{section.render()}
</main>
</div>
</div>
)
}
+10 -15
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { OverlayHost } from '@ui'
import type { Message, Run, Usage } from '../contracts'
export function AgentTracker() {
@@ -24,17 +25,12 @@ export function AgentTracker() {
const focusMsgs = focus === runId ? messagesAll.filter(m => m.runId === runId) : subMessages[focus] ?? []
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.setUI({ trackerRunId: null }) }}>
<div className="modal">
<div className="modal-header">
🤖 Sub-agent 追踪
<span className="spacer" />
<button className="btn ghost" onClick={() => store.setUI({ trackerRunId: null })}>关闭</button>
</div>
<div className="modal-body">
// 阶段③ 收敛:只保留 OverlayHost 一层壳(此前在其内又嵌 .modal 双层窗框)。
<OverlayHost title="代理追踪" icon="🤖" onClose={() => store.setUI({ trackerRunId: null })}>
<div className="subagent-tree">
{!root && <div>加载中…</div>}
{root && (
<div className="subagent-tree">
<>
<div className="subagent-node">
<div className="head">
<span className={`status-dot status-${root.status}`} />
@@ -50,7 +46,7 @@ export function AgentTracker() {
onClick={() => setSelected(c.id)}>
<span className={`status-dot status-${c.status}`} />
<span style={{ flex: 1 }}>🤖 {c.agentName || c.id}</span>
<span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{c.status}</span>
<span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-xs)' }}>{c.status}</span>
</div>
))}
</div>
@@ -58,11 +54,11 @@ export function AgentTracker() {
</div>
<h5 style={{ margin: '12px 0 6px' }}>中间过程({focus === runId ? '主任务' : '子代理'})</h5>
<div style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, padding: 12, maxHeight: '40vh', overflowY: 'auto' }}>
{focusMsgs.length === 0 && <div style={{ color: 'var(--text-faint)', fontSize: 12.5 }}>(无中间过程消息)</div>}
{focusMsgs.length === 0 && <div style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-sm)' }}>(无中间过程消息)</div>}
{focusMsgs.map(m => (
<div key={m.id} className="part">
{m.parts.map((p, i) => (
<div key={i} style={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7 }}>
<div key={i} style={{ whiteSpace: 'pre-wrap', fontSize: 'var(--fs-ui-caption)', lineHeight: 1.7 }}>
{p.type === 'text' && p.text}
{p.type === 'thinking' && <span style={{ color: 'var(--text-faint)' }}>{p.text}</span>}
{p.type === 'tool_call' && <span className="tool-chip">🛠 {p.toolCall?.name}</span>}
@@ -71,11 +67,10 @@ export function AgentTracker() {
</div>
))}
</div>
</div>
</>
)}
</div>
</div>
</div>
</OverlayHost>
)
}
+256 -57
View File
@@ -1,13 +1,19 @@
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
// 重设计方案 阶段④:折叠区全部迁 @ui Collapsible(<details> 已禁);工具调用块走
// ToolBlock 契约(运行态扫光、完成边沿收起、展开态记忆);live 行扫光 + 已工作计时。
import { Icon, Avatar, BrandMark, Collapsible, ToolBlock } from '@ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { marked } from 'marked'
import { api } from '../api'
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
import type { Message, Part, HITLCard } from '../contracts'
import { UIRenderer } from '@ui'
import { liveToMessage, showProcessInline } from '../live'
import type { Message, Part, HITLCard, ActivityCard } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
import { docBridge } from './Workbench'
import { groupParts, artifactIcon, humanSize } from '../messageparts'
marked.setOptions({ breaks: true })
// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染,
// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
@@ -16,7 +22,9 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
return <div className={live ? 'part-text cursor-blink' : 'part-text'}
dangerouslySetInnerHTML={{ __html: marked.parse(part.text ?? '') }} />
case 'thinking':
return <div className="part-thinking">{part.text}</div>
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
//(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。)
return <ThinkingBlock part={part} idKey={part.text?.slice(0, 24) ?? 't'} defaultOpen={!!live} />
case 'tool_call':
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
case 'tool_result': {
@@ -25,17 +33,51 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
{part.toolCall?.name ?? part.toolResult?.callId} <span className={`status-${st}`}>{st === 'ok' ? '✓' : '✗'}</span>
</span>
}
case 'artifact_ref':
return <span className="artifact-chip" onClick={() => {
const art = findArtifact(part.artifact!.artifactId)
if (art) store.viewArtifact(art)
}}>📄 {part.artifact?.name}</span>
case 'artifact_ref': {
const a = part.artifact
const art = a ? findArtifact(a.artifactId) : undefined
const name = a?.name ?? '工件'
const icon = artifactIcon(name, a?.kind ?? art?.kind)
const size = humanSize(art?.size)
// 交付物卡片(对话内直接给出):整卡点击 = 右栏预览(唯一落位,全屏从
// 右栏放大);动作只保留 下载 一个——与工件面板/预览器同一动作集。
return (
<div className="deliverable-card mk-rise-in" data-artifact-chip={a?.artifactId}
title={`预览 ${name}(右栏,边聊边看)`}
onClick={() => { if (art) store.requestPreview(art) }}>
<span className="dc-icon"><Icon name={icon} size={18} /></span>
<span className="dc-main">
<span className="dc-name">{name}</span>
<span className="dc-meta">{kindLabelOf(a?.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{art ? '' : ' · 生成中'}</span>
</span>
<span className="dc-act">预览</span>
<a className="dc-dl" title={`下载 ${name}`} href={api.artifactDownloadUrl(a?.artifactId ?? '')}
download={name} onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</div>
)
}
case 'hitl':
return <div className="tool-chip">❓ {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
return <div className="tool-chip"><Icon name="message" size={12} /> {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
case 'plan':
return null // 计划在右栏 Plan 标签呈现
case 'card':
return <div className="ui-section"><UIRenderer nodes={part.card?.data ?? []} docBridge={docBridge} /></div>
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}
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
}
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
const m = JSON.stringify(part.card?.data ?? '').match(/card:([A-Za-z0-9_]+)/)
return (
<div className="ui-section card-link" title={m ? '点击在收件箱中定位' : undefined}
onClick={() => m && store.openInbox(m[1])}>
<UIRenderer nodes={part.card?.data ?? []} docBridge={docBridge} />
</div>
)
}
case 'error':
return <div className="part-error">{part.error?.code}: {part.error?.message}</div>
default:
@@ -49,20 +91,124 @@ function findArtifact(id: string) {
return artifactCache[id]
}
// 过程折叠:run 终态后把连续 process 部件折叠为摘要行(可展开回看)。
function ProcessCollapse({ parts }: { parts: Part[] }) {
/** 交付物卡片的类型文案(与工件面板 kind 过滤同一词汇)。 */
function kindLabelOf(kind: string | undefined): string {
switch (kind) {
case 'document': return '文档'
case 'data': return '数据'
case 'image': return '图片'
default: return '文件'
}
}
// 收件箱卡片查询缓存(store.cards 的派生索引;卡流锚点取卡数据用)。
let cardCache: Record<string, ActivityCard> = {}
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>
)
}
// 过程段渲染(配对增强):tool_call 吸收紧随其后的同名 tool_result 组成 ToolBlock
//(运行态扫光、完成边沿收起、展开看结果摘要);其余部件原位 PartView。
function ProcessParts({ parts, live, idKey }: { parts: Part[]; live: boolean; idKey: string }) {
const out: React.ReactNode[] = []
for (let i = 0; i < parts.length; i++) {
const p = parts[i]
if (p.type === 'tool_call') {
const res = parts[i + 1]?.type === 'tool_result' ? parts[i + 1] : null
if (res) i++
const st = res?.toolResult?.status
out.push(
<ToolBlock key={i} toolId={`${idKey}:${i}`} name={`🛠 ${p.toolCall?.name ?? '工具'}`}
running={live && !res} status={st === 'ok' ? 'ok' : st ? 'error' : undefined}
statusDetail={res?.toolResult?.summary}
autoCollapseOnComplete>
{res && <div className="part-text">{res.toolResult?.summary}</div>}
</ToolBlock>)
continue
}
out.push(<div className="part" key={i}><PartView part={p} live={live && p.type === 'text'} /></div>)
}
return <>{out}</>
}
// 过程折叠:run 终态后把连续 process 部件折叠为摘要行(可展开回看;Collapsible 契约)。
function ProcessCollapse({ parts, idKey }: { parts: Part[]; idKey: string }) {
const toolCount = parts.filter(p => p.type === 'tool_call').length
const hasThinking = parts.some(p => p.type === 'thinking' && p.text)
return (
<details className="process-collapse">
<summary>
<span>⚙ 处程输出</span>
<span>{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}(点击展开)</span>
</summary>
<div className="process-collapse">
<Collapsible id={`process:${idKey}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>过程输出</span>
<span className="process-collapse-meta">
{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}
</span>
</>}>
<div className="body">
{parts.map((p, i) => <div className="part" key={i}><PartView part={p} /></div>)}
<ProcessParts parts={parts} live={false} idKey={idKey} />
</div>
</details>
</Collapsible>
</div>
)
}
// 长回复折叠阈值(CF0):终态后超长正文收成摘要行,点击展开全文;live 期间全文可见。
const LONG_REPLY_CHARS = 800
function LongReplyCollapse({ text, idKey }: { text: string; idKey: string }) {
// 摘句取压缩空白后的前 100 字;字数按 Markdown 源码长度计。
const excerpt = text.replace(/\s+/g, ' ').trim().slice(0, 100)
return (
<div className="process-collapse long-reply">
<Collapsible id={`long:${idKey}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>📄 长回复</span>
<span className="process-collapse-meta">{text.length} 字(按 Markdown 源码计)</span>
{excerpt && <span className="process-collapse-excerpt">{excerpt}…</span>}
</>}>
<div className="body">
<div className="part-text" dangerouslySetInnerHTML={{ __html: marked.parse(text) }} />
</div>
</Collapsible>
</div>
)
}
// 已工作计时(ZCode「已工作 X 分 X 秒」摘要行;1s 步进,仅 live 期间挂载)。
function useElapsed(startedAt: number): string {
const [, tick] = useState(0)
useEffect(() => {
const t = window.setInterval(() => tick(n => n + 1), 1000)
return () => window.clearInterval(t)
}, [])
const s = Math.max(0, Math.floor((Date.now() - startedAt) / 1000))
const m = Math.floor(s / 60)
return m > 0 ? `${m} 分 ${s % 60} 秒` : `${s} 秒`
}
// 流式状态行(独立组件:计时 hook 只在 live 行挂载期间运转)。
function LiveBar({ live }: { live: LiveState }) {
const elapsed = useElapsed(live.startedAt)
return (
<span className="live-bar">
<span className="live-dot" />
<span className="mk-sweep">{live.phase}</span>
<span className="live-elapsed">已工作 {elapsed}</span>
</span>
)
}
@@ -70,24 +216,37 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
const parts = msg.parts ?? []
const split = useMemo(() => {
const out: { process: Part[]; content: Part[] } = { process: [], content: [] }
for (const p of parts) (p.process ? out.process : out.content).push(p)
return out
}, [parts])
// 顺序保真(渲染顺序 = 落库顺序):连续过程部件就地聚块,内容部件原位保留——
// 工具调用显示在它真正发生的位置,不被抽到消息头部重排。
const blocks = useMemo(() => groupParts(parts), [parts])
// 流式中:过程部件实时展开;run 终态后:按偏好折叠为摘要行(可回看)。
const showProcessInline = live || !autoCollapse
// 流式期间:工具/思考阶段过程实时展开;一旦进入 responding(最终回复开始流式)
// 立即折叠过程,只显式显示回复(Cursor 式);工具恢复(acting)时重新展开。
// run 终态后:按偏好折叠为摘要行(可回看)。
const showProcess = showProcessInline(live ?? null, autoCollapse)
const lastContentIdx = (() => {
for (let i = blocks.length - 1; i >= 0; i--) if (blocks[i].kind === 'content') return i
return -1
})()
return (
<div className="msg assistant">
<div className="msg assistant" data-msg-id={msg.id}>
<div className="msg-role">
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
<span>助手</span>
{live && <span className="live-bar"><span className="live-dot" />{live.phase}</span>}
{live && <LiveBar live={live} />}
</div>
{split.process.length > 0 && (showProcessInline
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} /></div>)
: <ProcessCollapse parts={split.process} />)}
{split.content.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!(live && i === split.content.length - 1 && p.type === 'text')} /></div>)}
{blocks.map((b, i) => b.kind === 'process'
? (showProcess
? <div className="part-group" key={i}><ProcessParts parts={b.parts} live={!!live} idKey={`${msg.id}:${i}`} /></div>
: <ProcessCollapse key={i} parts={b.parts} idKey={`${msg.id}:${i}`} />)
: (() => {
// CF0:非 live 的超长文本部件折叠为摘要行;live 与短文本不受影响。
if (!live && b.part.type === 'text' && (b.part.text?.length ?? 0) > LONG_REPLY_CHARS)
return <LongReplyCollapse key={i} text={b.part.text ?? ''} idKey={`${msg.id}:${i}`} />
return <div className="part" key={i}>
<PartView part={b.part} live={!!(live && i === lastContentIdx && b.part.type === 'text')} />
</div>
})())}
{live && <LiveAgentNotes live={live} />}
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
</div>
@@ -97,22 +256,25 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
function ContextObsView({ obs }: { obs: ContextObs }) {
return (
<details className="process-collapse">
<summary>
<div className="process-collapse">
<Collapsible id={`ctxobs:${obs.estChars}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>🧩 上下文构成</span>
<span>{obs.items.join(' · ')}(约 {obs.estChars} 字符)</span>
</summary>
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} 字符)</span>
</>}>
<div className="body">
<ul className="ui-list">
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
</ul>
{obs.truncations && obs.truncations.length > 0 && (
<div style={{ color: 'var(--warn)', fontSize: 12.5, marginTop: 6 }}>
<div style={{ color: 'var(--warn)', fontSize: 'var(--fs-ui-sm)', marginTop: 6 }}>
{obs.truncations.map((t, i) => <div key={i}>⚠ {t}</div>)}
</div>
)}
</div>
</details>
</Collapsible>
</div>
)
}
@@ -136,7 +298,7 @@ function HITLCardView({ card }: { card: HITLCard }) {
}
return (
<div className="hitl-card">
<div className="hitl-q">❓ {card.question}</div>
<div className="hitl-q"><Icon name="check" size={15} />{card.question}</div>
{card.choices && (
<div className="hitl-choices">
{card.choices.map(c => (
@@ -161,18 +323,19 @@ function HITLCardView({ card }: { card: HITLCard }) {
}
function CommandMenu({ text, onPick }: { text: string; onPick: (cmd: string) => void }) {
const [commands, setCommands] = useState<{ name: string; description: string }[]>([])
const [commands, setCommands] = useState<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>([])
const [active, setActive] = useState(0)
useEffect(() => { api.listCommands().then(setCommands).catch(() => undefined) }, [])
const q = text.slice(1).toLowerCase()
const hits = commands.filter(c => c.name.slice(1).toLowerCase().startsWith(q))
// 四入口同源(D27):菜单即注册表——有 `/` 别名的可直接补全;无别名的展示说明(对称提示)。
const hits = commands.filter(c => (c.name ?? '').slice(1).toLowerCase().startsWith(q))
if (hits.length === 0) return null
return (
<div className="cmd-menu">
{hits.map((c, i) => (
<div key={c.name} className={`cmd-item ${i === active ? 'active' : ''}`}
onMouseEnter={() => setActive(i)} onClick={() => onPick(c.name + ' ')}>
<span className="name">{c.name}</span><span>{c.description}</span>
onMouseEnter={() => setActive(i)} onClick={() => onPick(c.name! + ' ')}>
<span className="name">{c.name}{c.danger === 'high' ? ' ⚠' : ''}</span><span>{c.description}</span>
</div>
))}
</div>
@@ -181,14 +344,17 @@ function CommandMenu({ text, onPick }: { text: string; onPick: (cmd: string) =>
export function Conversation() {
const currentConvId = useStore(s => s.currentConvId)
const conversation = useStore(s => s.conversations.find(c => c.id === s.currentConvId))
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
const messages = useStore(s => s.messages)
const myName = useStore(s => s.me?.name || s.me?.owner || '你')
const activeHitl = useStore(s => s.activeHitl)
const live = useStore(s => s.live)
const runs = useStore(s => s.runs)
const connected = useStore(s => s.connected)
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 [text, setText] = useState('')
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
@@ -196,12 +362,13 @@ export function Conversation() {
const bodyRef = useRef<HTMLDivElement>(null)
artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a]))
cardCache = Object.fromEntries(cards.map(c => [c.id, c]))
const active = activeRun(runs)
useEffect(() => {
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
}, [messages.length, live?.text, live?.thinking, activeHitl.length])
}, [messages.length, live?.items, live?.phase, activeHitl.length])
const send = async () => {
if (!text.trim() && attachmentIds.length === 0) return
@@ -216,7 +383,7 @@ export function Conversation() {
<div className="chat-body"><div className="empty-state">
<div style={{ fontSize: 40 }}>⌘</div>
<div>选择或创建一个会话开始</div>
<div style={{ fontSize: 12 }}>mock 演示模式可在 设置 → runtime 开启,无需模型密钥</div>
<div style={{ fontSize: 'var(--fs-ui-sm)' }}>mock 演示模式可在 设置 → runtime 开启,无需模型密钥</div>
</div></div>
</div>
)
@@ -225,6 +392,7 @@ export function Conversation() {
return (
<div className="chat">
<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="切换会话画像"
onChange={async e => {
@@ -234,41 +402,70 @@ export function Conversation() {
}}>
{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 }}
title={conversation?.allowCrossRead === false
? '本会话不读取其他会话记录(点击开启)'
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
<Icon name="users" size={13} />跨读{conversation?.allowCrossRead === false ? '·关' : '·开'}
</button>
<span title={connected ? '事件流已连接' : '事件流重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
{active && <button className="btn ghost" onClick={() => store.cancelActive()}>■ 停止</button>}
{active && <button className="btn ghost" onClick={() => store.cancelActive()}>停止</button>}
</div>
<div className="chat-body" ref={bodyRef}>
<div className="chat-body" ref={bodyRef}
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('drag-over') }}
onDragLeave={e => e.currentTarget.classList.remove('drag-over')}
onDrop={async e => {
e.preventDefault(); e.currentTarget.classList.remove('drag-over')
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]) }
}
}}>
{messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent').map(m => {
if (m.role === 'user') {
// 内联确认卡(T3):锚定本消息的危险命令确认,双现于对话流与收件箱。
const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm')
return (
<div className="msg user" key={m.id}>
<div className="msg-role">你</div>
<div className="msg user" key={m.id} data-msg-id={m.id}>
<div className="msg-role">
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
</div>
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
{anchored.map(c => (
<InlineConfirmCard key={c.id} card={c} callbacks={{
onAction: (card, actionId) => store.cardAction(card, actionId),
onLocate: card => store.openInbox(card.id),
}} />
))}
</div>
)
}
return <AssistantMessage key={m.id} msg={m} live={live?.runId === m.runId ? live : null} />
})}
{live && !messages.some(m => m.runId === live.runId) &&
<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">
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
<div>发送消息开始对话</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>
<div style={{ fontSize: 'var(--fs-ui-sm)', color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>
</div>}
</div>
<div className="input-bar">
{attachmentNames.length > 0 && (
<div className="attachments">
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}>📎 {n}
<span style={{ cursor: 'pointer' }} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}>✕</span>
{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>
</span>)}
</div>
)}
{text.startsWith('/') && <CommandMenu text={text} onPick={c => setText(c)} />}
<div className="input-row">
<label className="icon-btn" title="上传附件(文档解析后注入上下文)">
📎<input type="file" multiple style={{ display: 'none' }} onChange={async e => {
<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]) }
@@ -276,7 +473,9 @@ export function Conversation() {
e.target.value = ''
}} />
</label>
<textarea rows={1} value={text} placeholder={artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡'
: active ? '回合进行中——等当前回合结束(或点「停止」)后再发送'
: artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
{active ? (
+35 -29
View File
@@ -1,62 +1,68 @@
// 右栏文档查看器(4.4):markdown 渲染 / 文本编辑保存 / 图片预览 / 其他下载。
// 文档查看器(4.4 → 预览能力收敛):格式渲染统一交给 @ui FilePreview
// (md/图片/pdf/代码/数据/docx/xlsx/pptx/URL 一套分派),这里只保留 OverlayHost
// 容器、工具栏与文本族的在线编辑保存。
import { useEffect, useState } from 'react'
import { marked } from 'marked'
import { api } from '../api'
import { store } from '../store'
import { OverlayHost, Icon, Btn, FilePreview } from '@ui'
import type { Artifact } from '../contracts'
import { humanSize } from '../messageparts'
export function DocViewer({ artifact }: { artifact: Artifact }) {
const [content, setContent] = useState<string | null>(null)
const [editing, setEditing] = useState(false)
const [dirty, setDirty] = useState(false)
const textKind = isTextKind(artifact)
useEffect(() => {
if (artifact.kind === 'image' || isBinaryKind(artifact)) return
if (!textKind) return
setContent(null)
fetch(api.artifactContentUrl(artifact.id)).then(r => r.text()).then(setContent).catch(() => setContent('(读取失败)'))
}, [artifact.id])
const source = {
kind: 'artifact' as const,
id: artifact.id,
name: artifact.name,
mime: artifact.mime,
contentUrl: api.artifactContentUrl(artifact.id),
downloadUrl: api.artifactDownloadUrl(artifact.id),
}
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.viewArtifact(null) }}>
<div className="modal">
<div className="modal-header">
📄 {artifact.name}
<OverlayHost title={artifact.name} icon="📄" onClose={() => store.viewArtifact(null)}>
<div className="docv">
<div className="docv-toolbar">
<span className="docv-meta">{artifact.kind}{humanSize(artifact.size) ? ` · ${humanSize(artifact.size)}` : ''}</span>
<span className="spacer" />
{dirty && <span style={{ color: 'var(--warn)', fontSize: 12, marginRight: 8 }}>未保存</span>}
{isTextKind(artifact) && !isBinaryKind(artifact) && (
{dirty && <span className="docv-dirty">未保存</span>}
{textKind && (
<>
<button className="btn ghost" onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</button>
{editing && <button className="btn" disabled={!dirty} onClick={async () => {
<Btn variant="ghost" icon={editing ? 'eye' : 'list'} onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</Btn>
{editing && <Btn variant="primary" icon="check" disabled={!dirty} onClick={async () => {
await api.saveArtifactContent(artifact.id, content ?? '')
setDirty(false)
store.toast('已保存')
}}>保存</button>
}
}}>保存</Btn>}
</>
)}
<a className="btn ghost" style={{ textDecoration: 'none' }} href={api.artifactContentUrl(artifact.id)} download={artifact.name}>下载</a>
<button className="btn ghost" onClick={() => store.viewArtifact(null)}>关闭</button>
<a className="btn v-ghost docv-dl" href={api.artifactDownloadUrl(artifact.id)} download={artifact.name}>
<Icon name="download" size={14} /> 下载
</a>
</div>
<div className="modal-body">
{artifact.kind === 'image' ? (
<img src={api.artifactContentUrl(artifact.id)} alt={artifact.name} style={{ maxWidth: '100%' }} />
) : isBinaryKind(artifact) ? (
<div style={{ color: 'var(--text-faint)' }}>二进制工件——请下载查看(docx 深度预览为远期项;md/txt 可在线查看)</div>
) : editing ? (
<textarea className="search-input" style={{ height: '60vh', fontFamily: 'var(--mono)', fontSize: 12.5 }}
value={content ?? ''} onChange={e => { setContent(e.target.value); setDirty(true) }} />
<div className="docv-body">
{editing && textKind ? (
<textarea className="search-input docv-editor" value={content ?? ''}
onChange={e => { setContent(e.target.value); setDirty(true) }} />
) : (
<div className="doc-viewer" dangerouslySetInnerHTML={{ __html: marked.parse(content ?? '(加载中…') as string }} />
<FilePreview source={source} />
)}
</div>
</div>
</div>
</OverlayHost>
)
}
function isTextKind(a: Artifact): boolean {
return a.kind === 'document' && /\.(md|txt|csv|log|json)$/i.test(a.name)
}
function isBinaryKind(a: Artifact): boolean {
return /\.(docx|pdf|xlsx|pptx|png|jpg|jpeg|gif|webp)$/i.test(a.name)
}
+154
View File
@@ -0,0 +1,154 @@
// 登录与改密(管理员发放制,R2):
// LoginPage —— 用户名+密码(主)/ API 密钥(兼容程序化客户端)双入口;
// ChangePasswordGate —— 初始/重置密码后的首登强制改密(不完成不放行)。
import { useState } from 'react'
import { auth } from '../auth'
import { api } from '../api'
import { Icon, BrandMark } from '@ui'
function Err({ msg }: { msg: string }) {
if (!msg) return null
return <div className="lg-err" role="alert"><Icon name="alert" size={14} />{msg}</div>
}
/** 宽屏右侧品牌面板(叙事 + 特性清单);窄屏隐藏。 */
function BrandPanel() {
return (
<aside className="lg-panel">
<h2>把研发记忆,<br />变成组织的第二大脑</h2>
<p>文档、决策与知识的沉淀、检索与生成——面向团队的自托管智能体工作台。</p>
<ul>
<li><Icon name="message" size={15} />项目化多任务工作区,收件箱驱动待办</li>
<li><Icon name="shield" size={15} />管理员发放账号,密码策略与密钥分级</li>
<li><Icon name="search" size={15} />知识库混合检索(全文 + 向量)</li>
<li><Icon name="package" size={15} />文档生成流水线与工件管理</li>
</ul>
<div className="lg-foot"><span>自托管 · 多租户</span><span>数据不出域</span></div>
</aside>
)
}
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。 */
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
const [tab, setTab] = useState<'password' | 'apikey'>('password')
const [owner, setOwner] = useState('')
const [password, setPassword] = useState('')
const [key, setKey] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const submitPassword = async () => {
setErr(''); setBusy(true)
try {
const me = await api.login(owner.trim(), password)
if (me.mustChangePassword) {
// 初始/重置密码:先完成强制改密再放行。
onMustChange(me.owner)
return
}
onAuthed()
} catch (e) {
setErr(humanLoginError(String(e)))
} finally { setBusy(false) }
}
const submitKey = async () => {
setErr(''); setBusy(true)
auth.setKey(key)
try {
const r = await auth.fetch('/api/health')
if (r.ok) { onAuthed(); return }
throw new Error(String(r.status))
} catch {
auth.clear()
setErr('密钥无效或已撤销,请重试')
} finally { setBusy(false) }
}
return (
<div className="lg-wrap">
<BrandPanel />
<div className="lg-formside">
<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>
<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>
</div>
{tab === 'password' ? (
<>
<label className="lg-label">用户名</label>
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
<label className="lg-label">密码</label>
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
</>
) : (
<>
<label className="lg-label">API 访问密钥</label>
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="ask_…" />
<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>
</>
)}
</form>
</div>
</div>
)
}
/** 强制改密门:初始密码/管理员重置后的首次登录必须完成改密才可进入系统。 */
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
setErr('')
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
if (next.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.changePassword(current, next)
onDone()
} catch (e) {
setErr(humanLoginError(String(e)).replace('401: ', ''))
} finally { setBusy(false) }
}
return (
<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>
</div>
</div>
)
}
/** 错误文案:把 HTTP 状态码翻成人话。 */
export function humanLoginError(raw: string): string {
if (raw.includes('401')) return '用户名或密码不正确'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
-62
View File
@@ -1,62 +0,0 @@
// 左栏:项目折叠组 → 会话列表;底部账户/设置入口(4.4)。
import { useEffect, useState } from 'react'
import { store, useStore } from '../store'
export function ProjectNav() {
const projects = useStore(s => s.projects)
const currentProjectId = useStore(s => s.currentProjectId)
const conversations = useStore(s => s.conversations)
const currentConvId = useStore(s => s.currentConvId)
const [collapsedProjects, setCollapsedProjects] = useState<Record<string, boolean>>({})
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
useEffect(() => { setNewName(`项目 ${projects.length + 1}`) }, [projects.length])
return (
<>
<div className="nav-header">
<span className="nav-label">Agent-System</span>
<button title="新建项目" onClick={() => setCreating(v => !v)}>✚</button>
</div>
{creating && (
<div style={{ padding: '0 10px 8px', display: 'flex', gap: 6 }}>
<input className="search-input" value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={async e => {
if (e.key === 'Enter' && newName.trim()) { await store.createProject(newName.trim()); setCreating(false) }
}} placeholder="项目名称" autoFocus />
<button className="btn" onClick={async () => { if (newName.trim()) { await store.createProject(newName.trim()); setCreating(false) } }}>建</button>
</div>
)}
<div className="nav-scroll">
{projects.length === 0 && <div style={{ padding: 12, color: 'var(--text-faint)', fontSize: 12.5 }}>尚无项目——点击 ✚ 创建</div>}
{projects.map(p => {
const open = !collapsedProjects[p.id]
return (
<div className="nav-project" key={p.id}>
<div className={`nav-project-name ${open ? 'open' : ''} ${p.id === currentProjectId ? '' : ''}`}
style={p.id === currentProjectId ? { color: 'var(--text)' } : undefined}
onClick={async () => {
setCollapsedProjects(c => ({ ...c, [p.id]: !(c[p.id] ?? p.id === currentProjectId ? false : true) }))
await store.openProject(p.id)
}}>
<span className="chev">▶</span>
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
</div>
{open && p.id === currentProjectId && conversations.map(c => (
<div key={c.id} className={`nav-conv ${c.id === currentConvId ? 'active' : ''}`}
onClick={() => store.openConversation(c.id)} title={c.title}>
{c.title}
</div>
))}
</div>
)
})}
</div>
<div className="nav-footer">
<button className="nav-label" onClick={() => store.createConversation()}>+ 新会话</button>
<button className="nav-label" onClick={() => store.openSettings(true)}>⚙ 设置</button>
</div>
</>
)
}
+86
View File
@@ -0,0 +1,86 @@
// 项目设置面板(提案 2026-09-30-cross-session-reading):首个项目级设置 UI。
// schema 驱动渲染 project scope(4.9 覆盖链:system → user → project),
// 写 putProjectSettings;后续项目级设置项经 settings schema 自动出现,零开发。
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { SettingDesc } from '../contracts'
import { SettingsShell, Btn, EmptyState } from '@ui'
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
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 [schema, setSchema] = useState<SettingDesc[]>([])
const [draft, setDraft] = useState<Record<string, any>>({})
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
if (!projectId) return
setLoaded(false)
api.getSettings(projectId).then(s => {
setSchema(s.schema)
setDraft(flatten(s.project))
setLoaded(true)
}).catch(() => undefined)
}, [projectId])
const groups = useMemo(() => {
const g: Record<string, SettingDesc[]> = {}
for (const d of schema) (g[d.group] ??= []).push(d)
return g
}, [schema])
if (!projectId) return null
const save = async () => {
if (!projectId) return
try {
await api.putProjectSettings(projectId, unflatten(draft))
setSaved(true)
setTimeout(() => setSaved(false), 1500)
} catch (e) {
store.toast(`项目设置保存失败:${e}`)
}
}
return (
<SettingsShell
title={`项目设置 · ${project?.name ?? projectId}`}
active="project"
onSelect={() => undefined}
onClose={() => store.openProjectSettings(null)}
sections={[{ key: 'project', label: '本项目', icon: 'folder' }]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
>
<div>
{!loaded ? (
<EmptyState icon="settings">加载中…</EmptyState>
) : (
<>
<div className="empty-note" style={{ marginBottom: 10 }}>
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
</div>
{Object.entries(groups).map(([g, descs]) => (
<div className="settings-group" key={g}>
<h4>{groupNames[g] ?? g}</h4>
{descs.map(d => (
<div className="setting-row" key={d.key}>
<label title={d.key}>{d.label}</label>
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
</span>
</div>
))}
</div>
))}
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
</>
)}
</div>
</SettingsShell>
)
}
+40 -152
View File
@@ -1,14 +1,23 @@
// 设置中心(4.9):schema 驱动渲染 + 生效时机标注 + 密钥掩码 + 插件管理 + 画像管理。
// 设置中心(4.9 → UI 能力平台迁移):@ui SettingsShell + SettingRow 统一行语义,
// 保存按钮进固定 actions 底栏(不随内容滚动);schema 驱动 + 生效时机标注 + 密钥掩码。
// 重设计方案 阶段②(IA):设置瘦身——只留个人偏好(常规);智能体画像/插件管理/
// 插件工作区迁「系统管理」(AdminZone,管理员专属全屏视图)。
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile, SettingDesc } from '../contracts'
import { store } from '../store'
import type { SettingDesc } from '../contracts'
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
type Section = 'settings' | 'profiles' | 'plugins'
type Section = 'settings'
// 分组显示名(项目设置面板共用)。
export const groupNames: Record<string, string> = {
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
ui: '界面', plugins: '插件目录', crossSession: '会话协作', tools: '工具',
}
export function SettingsModal() {
const profiles = useStore(s => s.profiles)
const [section, setSection] = useState<Section>('settings')
const [section] = useState<Section>('settings')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [, setUser] = useState<Record<string, any>>({})
const [draft, setDraft] = useState<Record<string, any>>({})
@@ -28,11 +37,6 @@ export function SettingsModal() {
return g
}, [schema])
const groupNames: Record<string, string> = {
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
ui: '界面', plugins: '插件目录',
}
const save = async () => {
const values = unflatten(draft)
await api.putUserSettings(values)
@@ -45,50 +49,34 @@ export function SettingsModal() {
}
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.openSettings(false) }}>
<div className="modal">
<div className="modal-header">
⚙ 设置
<div className="tabs" style={{ margin: '0 16px', borderBottom: 'none' }}>
{(['settings', 'profiles', 'plugins'] as Section[]).map(s => (
<button key={s} className={`tab ${section === s ? 'active' : ''}`} onClick={() => setSection(s)}>
{s === 'settings' ? '设置' : s === 'profiles' ? '智能体画像' : '插件'}
</button>
))}
</div>
<span className="spacer" />
{saved && <span style={{ color: 'var(--ok)', marginRight: 10, fontSize: 12.5 }}>已保存</span>}
<button className="btn ghost" onClick={() => store.openSettings(false)}>关闭</button>
</div>
<div className="modal-body">
{section === 'settings' && (
<SettingsShell
title="设置" active={section} onSelect={() => undefined}
onClose={() => store.openSettings(false)}
sections={[
{ key: 'settings', label: '常规', icon: 'settings' },
]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
actions={section === 'settings' && schema.length > 0 ? (
<>
{Object.entries(groups).map(([g, descs]) => (
<div className="settings-group" key={g}>
<h4>{groupNames[g] ?? g}</h4>
{descs.map(d => (
<div className="setting-row" key={d.key}>
<label title={d.key}>{d.label}</label>
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
</span>
</div>
))}
</div>
))}
<button className="btn" onClick={save}>保存设置</button>
{saved && <span style={{ color: 'var(--ok)', fontSize: 'var(--fs-ui-sm)', marginRight: 8 }}>✓ 已保存</span>}
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
</>
)}
{section === 'profiles' && <ProfilesSection profiles={profiles} />}
{section === 'plugins' && <PluginsSection />}
</div>
</div>
</div>
) : undefined}
>
{section === 'settings' && Object.entries(groups).map(([g, descs]) => (
<SettingsGroup key={g} title={groupNames[g] ?? g}>
{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(x => ({ ...x, [d.key]: v }))} />
</SettingRow>
))}
</SettingsGroup>
))}
</SettingsShell>
)
}
function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
if (desc.type === 'boolean') {
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
}
@@ -111,12 +99,12 @@ function flat(m: Record<string, any>, prefix: string, out: Record<string, any>)
else out[key] = v
}
}
function flatten(m: Record<string, any>): Record<string, any> {
export function flatten(m: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
flat(m, '', out)
return out
}
function unflatten(flatMap: Record<string, any>): Record<string, any> {
export function unflatten(flatMap: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(flatMap)) {
if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改)
@@ -128,103 +116,3 @@ function unflatten(flatMap: Record<string, any>): Record<string, any> {
return out
}
// ---- 画像管理(数据化:导入导出 + 编辑)----
function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
const [editing, setEditing] = useState<AgentProfile | null>(null)
const [text, setText] = useState('')
return (
<div>
<div className="doc-toolbar">
<button className="btn" onClick={() => { setEditing(newProfile()); setText('') }}>+ 新建画像</button>
<label className="btn ghost" style={{ cursor: 'pointer' }}>
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
const f = e.target.files?.[0]
if (!f) return
try {
const p = JSON.parse(await f.text())
p.id = p.id || 'prof_imported'
p.builtin = false
await api.saveProfile(p)
store.refreshProfiles()
store.toast('画像已导入')
} catch (err) { store.toast(`导入失败:${err}`) }
}} />
</label>
</div>
{profiles.map(p => (
<div className="setting-row" key={p.id}>
<label>
{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{p.id}{p.builtin ? ' · 内置' : ''}</span>
</label>
<div style={{ color: 'var(--text-faint)', fontSize: 12 }}>{p.description}</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn ghost" onClick={() => {
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob); a.download = `${p.id}.json`; a.click()
}}>导出</button>
{!p.builtin && <button className="btn ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</button>}
{!p.builtin && <button className="btn ghost" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</button>}
</div>
</div>
))}
{editing && (
<div className="settings-group" style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<h4>编辑画像:{editing.name}</h4>
<div className="ui-form">
<label>名称<input value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} /></label>
<label>系统提示<textarea className="search-input" rows={4} style={{ height: 'auto' }} value={text} onChange={e => setText(e.target.value)} /></label>
<label>允许工具(逗号分隔,* 全部)<input value={editing.tools.allow.join(',')} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, allow: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>执行前确认(前缀. 结尾表示族)<input value={editing.tools.confirm?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, confirm: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>拒绝工具<input value={editing.tools.deny?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, deny: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
</div>
<button className="btn" onClick={async () => {
try {
await api.saveProfile({ ...editing, builtin: false, source: 'user', systemPrompt: text })
await store.refreshProfiles()
setEditing(null)
store.toast('画像已保存')
} catch (e) { store.toast(`保存失败:${e}`) }
}}>保存画像</button>
</div>
)}
</div>
)
}
function newProfile(): AgentProfile {
return {
id: '', name: '新画像', systemPrompt: '', model: {}, tools: { allow: ['*'] },
context: { maxHistoryMessages: 40 }, hitl: { askOnAmbiguity: true }, budget: {},
} as AgentProfile
}
// ---- 插件管理 ----
function PluginsSection() {
const [plugins, setPlugins] = useState<any[]>([])
const refresh = () => api.listPlugins().then(setPlugins).catch(() => undefined)
useEffect(() => { refresh() }, [])
return (
<div>
<div className="doc-toolbar">
<button className="btn" onClick={async () => {
await api.reloadPlugins()
await refresh()
store.refreshPlugins()
store.toast('插件已热重载')
}}>⟳ 热重载</button>
<span style={{ color: 'var(--text-faint)', fontSize: 12 }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
</div>
{plugins.length === 0 && <div style={{ color: 'var(--text-faint)' }}>暂无已装载插件(目录:设置 → 插件目录;示例插件见 Dev 仓 plugins/example)</div>}
{plugins.map(p => (
<div className="setting-row" key={p.manifest.id}>
<label>{p.manifest.name} <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>v{p.manifest.version}</span></label>
<div style={{ color: 'var(--text-faint)', fontSize: 12 }}>
{[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}
</div>
<span className="effect-badge">api {p.manifest.apiVersion}</span>
</div>
))}
</div>
)
}
+256
View File
@@ -0,0 +1,256 @@
// 左栏(方案 A「任务中心」,提案 2026-09-30 R1):单一主创建入口(P6)+
// 组织视图(P8:按项目/时间线)+ 置顶/归档 + 账户区(P7)。
// 重设计方案 阶段②(IA 对话优先):工件/代理/插件快捷条撤出普通用户界面——
// 工件走右栏「工件」页签,插件工作区归「系统管理」(管理员专属全屏视图)。
import { useEffect, useRef, useState } from 'react'
import { store, useStore } from '../store'
import { auth } from '../auth'
import { api } from '../api'
import { Icon, BrandMark } from '@ui'
import type { Conversation } from '../contracts'
import { groupByProject, groupByTimeline, relativeTime, filterTasks, type TaskOrg } from '../tasklist'
const GROUPS_KEY = 'as.taskGroups'
function loadCollapsed(): Record<string, boolean> {
try { return JSON.parse(localStorage.getItem(GROUPS_KEY) ?? '{}') } catch { return {} }
}
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClose: () => void; children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!anchor || !ref.current) return
const r = anchor.getBoundingClientRect()
const el = ref.current
let x = r.left, y = r.bottom + 6
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`
const h = (e: MouseEvent) => { if (!el.contains(e.target as Node)) onClose() }
window.addEventListener('mousedown', h)
return () => window.removeEventListener('mousedown', h)
}, [anchor])
if (!anchor) return null
return <div ref={ref} className="tsb-menu" onClick={e => e.stopPropagation()}>{children}</div>
}
function MenuItem({ onClick, danger, icon, children }: { onClick: () => void; danger?: boolean; icon?: Parameters<typeof Icon>[0]['name']; children: React.ReactNode }) {
return <button className={`tsb-mi ${danger ? 'danger' : ''}`} onClick={onClick}>
{icon && <Icon name={icon} size={15} />}{children}
</button>
}
export function TaskSidebar() {
const projects = useStore(s => s.projects)
const tasks = useStore(s => s.tasks)
const me = useStore(s => s.me)
const currentConvId = useStore(s => s.currentConvId)
const currentProjectId = useStore(s => s.currentProjectId)
const org = useStore(s => s.ui.taskOrg)
const showArchived = useStore(s => s.ui.showArchived)
const theme = useStore(s => s.ui.theme)
const pendingBadge = useStore(s =>
s.cards.filter(c => c.status === 'action_required').length +
s.projectCards.filter(c => c.status === 'action_required').length)
const [q, setQ] = useState('')
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed)
const [menu, setMenu] = useState<{ kind: 'new' | 'filter' | 'account' | `row:${string}`; anchor: HTMLElement } | null>(null)
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 tglGroup = (key: string) => {
setCollapsed(c => {
const next = { ...c, [key]: !c[key] }
try { localStorage.setItem(GROUPS_KEY, JSON.stringify(next)) } catch { /* ignore */ }
return next
})
}
const setOrg = (o: TaskOrg) => store.setUI({ taskOrg: o })
const visible = filterTasks(tasks, q)
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
const groups = org === 'project' ? groupByProject(rest, projects) : groupByTimeline(rest)
const currentProject = projects.find(p => p.id === currentProjectId)
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
const rowActions = (t: Conversation) => (
<span className="tsb-row-acts">
<button className="tsb-row-act" title="置顶 / 取消置顶" onClick={e => { e.stopPropagation(); store.pinTask(t.id, !t.pinned) }}><Icon name="pin" size={13} /></button>
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
</button>
<button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
onClick={e => { e.stopPropagation(); store.setCrossFlags(t.id, { crossPrivate: !t.crossPrivate }) }}>
<Icon name="shield" size={13} />
</button>
</span>
)
const taskRow = (t: Conversation, badge?: string) => (
<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>}
{badge && <span className="tsb-pbadge">{badge}</span>}
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
{rowActions(t)}
</div>
)
return (
<>
<div className="tsb-brand">
<BrandMark size={24} />
<span className="tsb-name">Agent-System</span>
<span style={{ flex: 1 }} />
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}><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>
<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)} />
</div>
</div>
{/* 组织视图切换(P8)+ 过滤 */}
<div className="tsb-orgrow">
<div className="tsb-pills">
<button className={`tsb-pill ${org === 'project' ? 'on' : ''}`} onClick={() => setOrg('project')}>按项目</button>
<button className={`tsb-pill ${org === 'timeline' ? 'on' : ''}`} onClick={() => setOrg('timeline')}>时间线</button>
</div>
<button className={`tsb-iconbtn ${showArchived ? 'active' : ''}`} title={showArchived ? '隐藏归档' : '查看归档'}
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
</div>
<div className="tsb-scroll">
{creatingProject && (
<div className="tsb-newproject">
<input className="tsb-search" autoFocus value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
onKeyDown={async e => {
if (e.key === 'Enter' && newProjectName.trim()) {
await store.createProject(newProjectName.trim())
setCreatingProject(false); setNewProjectName('')
}
if (e.key === 'Escape') setCreatingProject(false)
}}
placeholder="项目名称,回车创建" />
</div>
)}
{pinned.length > 0 && (
<>
<div className="tsb-seclabel">置顶</div>
{pinned.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
</>
)}
{groups.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">尚无任务——点击「新建任务」开始<br />(项目内素材上传、检索与生成全程可在对话中完成)</div>
)}
{groups.map(g => (
<div key={g.key} className={`tsb-group ${collapsed[g.key] ? 'closed' : ''}`}>
{org === 'project' ? (
<div className="tsb-group-head" onClick={() => tglGroup(g.key)}>
<span className="tsb-chev"><Icon name="chevron-right" size={13} /></span>
<span className="tsb-group-name" title={g.label}>{g.label}</span>
<span className="tsb-group-cnt">{g.tasks.length}</span>
<span style={{ flex: 1 }} />
<button className="tsb-group-add" title="项目设置(含跨会话读取)" onClick={e => { e.stopPropagation(); if (g.projectId) store.openProjectSettings(g.projectId) }}><Icon name="settings" size={13} /></button>
<button className="tsb-group-add" title="在此项目新建任务" onClick={e => { e.stopPropagation(); store.createTask(g.projectId) }}><Icon name="plus" size={13} /></button>
</div>
) : (
<div className="tsb-seclabel">{g.label}</div>
)}
{!collapsed[g.key] && g.tasks.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
</div>
))}
</div>
{/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */}
<div className="tsb-footer">
<button className="tsb-user" onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'account' ? null : { kind: 'account', anchor: el }) }}>
<span className="tsb-avatar">{(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()}</span>
<span className="tsb-user-info">
<span className="tsb-user-name">{me?.name || me?.owner || '未连接'}</span>
<span className="tsb-user-sub">{me ? (me.auth ? (me.isAdmin ? '管理员 · 已认证' : '已认证') : '本地模式 · 默认租户') : '…'}</span>
</span>
<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.openInbox()}><Icon name="inbox" size={15} />{pendingBadge}</button>}
</div>
{/* 弹出菜单们 */}
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
<MenuItem icon="plus" onClick={() => { store.createTask(); 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>
))}
</Menu>
<Menu anchor={rowMenuAnchor} onClose={() => setMenu(null)}>
{rowTask && <>
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
<MenuItem icon={rowTask.archived ? 'undo' : 'archive'} onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
<div className="tsb-menu-div" />
<MenuItem icon="shield" onClick={() => { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'}</MenuItem>
<MenuItem icon="users" onClick={() => { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}</MenuItem>
<div className="tsb-menu-div" />
<MenuItem icon="clock" onClick={() => { store.setUI({ taskOrg: 'timeline' }); setMenu(null) }}>按时间线查看</MenuItem>
</>}
</Menu>
<Menu anchor={menu?.kind === 'account' ? menu.anchor : null} onClose={() => setMenu(null)}>
<div className="tsb-acc-head">
<span className="tsb-avatar">{(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()}</span>
<span>
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
</span>
</div>
<div className="tsb-menu-div" />
<MenuItem icon="settings" onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
<MenuItem icon="inbox" onClick={() => { store.openInbox(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
<MenuItem icon="key" onClick={() => { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码…</MenuItem>
<MenuItem icon={theme === 'dark' ? 'sun' : 'moon'} onClick={() => { store.setUI({ theme: theme === 'dark' ? 'light' : 'dark' }); setMenu(null) }}>
{theme === 'dark' ? '切换浅色主题' : '切换深色主题'}
</MenuItem>
{me?.isAdmin && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{me?.auth && <>
<div className="tsb-menu-div" />
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
</>}
</Menu>
</>
)
}
+133 -96
View File
@@ -1,127 +1,164 @@
// 右栏工作台:Plan / Todos / Artifacts / Agents + 插件面板(4.4);tab 显隐排序见设置。
import { store, useStore, isTerminal } from '../store'
// 右栏终态(提案 T4/D25 → 预览双落位):收件箱/预览两页签 + 「当前任务」常驻卡。
// 预览页签(裁定 D4)承载右栏轻预览:FilePreview compact 模式,边聊边看。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { UIRenderer, type DocBridge } from '@ui'
import { InboxList, pendingCount, FilePreview, type ActivityCard, type DocBridge } from '@ui'
import { Icon } from '@ui'
import { api } from '../api'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
// 打开语义走 requestPreview 唯一入口(阶段③ 收敛:默认右栏轻预览)。
export const docBridge: DocBridge = {
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
onOpenDoc: id => {
const art = store.getState().artifacts.find(a => a.id === id)
if (art) store.viewArtifact(art)
if (art) store.requestPreview(art)
},
}
export function Workbench() {
const ui = useStore(s => s.ui)
const plan = useStore(s => s.plan)
const todos = useStore(s => s.todos)
const artifacts = useStore(s => s.artifacts)
const runs = useStore(s => s.runs)
const pluginPanels = useStore(s => s.pluginPanels)
const tabs: { id: string; label: string }[] = [
{ id: 'plan', label: '计划' },
{ id: 'todos', label: '待办' },
{ id: 'artifacts', label: `工件${artifacts.length ? ` ${artifacts.length}` : ''}` },
{ id: 'agents', label: '代理' },
...pluginPanels.map(p => ({ id: `plugin:${p.id}`, label: p.title })),
]
const inboxCallbacks = {
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
onArchive: (c: ActivityCard) => store.cardToProject(c),
}
/** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */
function ProjectInbox({ cards }: { cards: ActivityCard[] }) {
const own = cards.filter(c => !c.conversationId)
const cross = cards.filter(c => !!c.conversationId)
if (cards.length === 0) {
return <div className="inbox-empty">项目收件箱为空——「留档到项目」的产物卡与其他会话的待办会聚合在这里。</div>
}
return (
<>
<div className="tabs">
{tabs.map(t => (
<button key={t.id} className={`tab ${ui.rightTab === t.id ? 'active' : ''}`}
onClick={() => store.setUI({ rightTab: t.id })}>{t.label}</button>
))}
<button className="tab" title="收起工作台" onClick={store.toggleRight}>»</button>
</div>
<div className="tab-body">
{ui.rightTab === 'plan' && <PlanTab plan={plan} />}
{ui.rightTab === 'todos' && <TodosTab todos={todos} />}
{ui.rightTab === 'artifacts' && <ArtifactsTab />}
{ui.rightTab === 'agents' && <AgentsTab runs={runs} />}
{ui.rightTab.startsWith('plugin:') && (
<UIRenderer nodes={pluginPanels.find(p => `plugin:${p.id}` === ui.rightTab)?.body ?? []} docBridge={docBridge} />
<div className="inbox-zone-label">📁 项目级卡片(跨会话可见)</div>
{own.length > 0
? <InboxList cards={own} callbacks={inboxCallbacks} docBridge={docBridge} />
: <div className="inbox-empty" style={{ padding: '4px 14px' }}>暂无——会话卡点 🗂 即留档至此</div>}
{cross.length > 0 && (
<>
<div className="inbox-zone-label">🕓 其他会话待办</div>
<InboxList cards={cross} callbacks={inboxCallbacks} docBridge={docBridge} showPinnedZone={false} />
</>
)}
</div>
</>
)
}
function StatusIcon({ status }: { status?: string }) {
export function Workbench() {
const cards = useStore(s => s.cards)
const plan = useStore(s => s.plan)
const todos = useStore(s => s.todos)
const inboxScope = useStore(s => s.inboxScope)
const projectCards = useStore(s => s.projectCards)
const pending = pendingCount(cards) + pendingCount(projectCards)
const rightTab = useStore(s => s.ui.rightTab)
const railId = useStore(s => s.ui.railPreviewId)
const railArtifact = useStore(s => (railId ? s.artifacts.find(a => a.id === railId) : undefined))
const showPreview = rightTab === 'preview'
return (
<>
<div className="tabs">
<button className={`tab ${!showPreview ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'inbox' })}
title="收件箱">收件箱{pending > 0 ? ` ${pending}` : ''}</button>
<button className={`tab ${showPreview ? 'active' : ''}`} onClick={() => store.railPreview(railArtifact ?? null)}
title="预览(右栏轻预览)"><Icon name="eye" size={13} /> 预览</button>
<span className="spacer" style={{ flex: 1 }} />
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
<button className="tab" title="收起收件箱" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
</div>
{showPreview ? (
<div className="tab-body" data-rail-preview="">
{railArtifact ? (
<>
<div className="rail-preview-head">
<span className="rp-name" title={railArtifact.name}>{railArtifact.name}</span>
<span className="spacer" style={{ flex: 1 }} />
<button className="icon-btn" title="全屏预览(唯一大窗落位)" onClick={() => store.requestPreview(railArtifact, 'full')}><Icon name="eye" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(railArtifact.id)} download={railArtifact.name}><Icon name="download" size={14} /></a>
<button className="icon-btn" title="关闭预览" onClick={() => store.railPreview(null)}><Icon name="x" size={14} /></button>
</div>
<FilePreview compact source={{
kind: 'artifact',
id: railArtifact.id,
name: railArtifact.name,
mime: railArtifact.mime,
contentUrl: api.artifactContentUrl(railArtifact.id),
downloadUrl: api.artifactDownloadUrl(railArtifact.id),
}} />
</>
) : <div className="empty-note" style={{ padding: 14 }}>点消息里的工件卡片或右侧工件面板——文件会在这里边聊边看(md/图片/pdf/Office/代码均可)</div>}
</div>
) : (
<div className="tab-body">
<CurrentTaskCard plan={plan} todos={todos} />
<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')}>
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
</button>
</div>
{inboxScope === 'project'
? <ProjectInbox cards={projectCards} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} />}
</div>
)}
</>
)
}
/** 当前任务常驻卡(D25 R4):计划+待办合成一张卡,钉在收件箱顶部——右栏只回答"现在要我做什么"。 */
export function CurrentTaskCard({ plan, todos }: {
plan: { title: string; steps: { id: string; text: string; status: string; agentRun?: string }[] } | null
todos: { items: { id: string; text: string; status: string }[] } | null
}) {
const steps = plan?.steps ?? []
const items = todos?.items ?? []
if (steps.length === 0 && items.length === 0) return null
const done = [...steps, ...items].filter(x => x.status === 'done' || x.status === 'completed').length
const total = steps.length + items.length
return (
<div className="current-task" data-testid="current-task">
<div className="ct-head">
<span className="lamp lamp-ok">●</span>
<span className="ct-title">{plan?.title ?? '当前任务'}</span>
<span className="ct-progress">{done}/{total}</span>
</div>
{steps.map(s => (
<div key={s.id} className={`step ${s.status}`}>
<StatusIcon status={s.status} />
<span style={{ flex: 1 }}>{s.text}</span>
{s.agentRun && <span className="status-dot status-completed" title="子代理执行" />}
</div>
))}
{items.map(t => (
<div key={t.id} className={`step ${t.status}`}>
<StatusIcon status={t.status} />
<span>{t.text}</span>
</div>
))}
</div>
)
}
export function StatusIcon({ status }: { status?: string }) {
if (status === 'done' || status === 'completed') return <span style={{ color: 'var(--ok)' }}>●</span>
if (status === 'in_progress' || status === 'running') return <span style={{ color: 'var(--accent)' }}>◐</span>
if (status === 'skipped') return <span>◌</span>
return <span style={{ color: 'var(--text-faint)' }}>○</span>
}
function PlanTab({ plan }: { plan: { title: string; steps: { id: string; text: string; status: string; agentRun?: string }[] } | null }) {
if (!plan?.steps?.length) return <Empty text="暂无计划——模型可通过 update_plan 工具创建(mock:#agents)" />
return (
<div>
<h5 style={{ margin: '4px 0 10px' }}>{plan.title}</h5>
{plan.steps.map(s => (
<div key={s.id} className={`step ${s.status}`}>
<span className="step-icon"><StatusIcon status={s.status} /></span>
<span style={{ flex: 1 }}>{s.text}</span>
{s.agentRun && <span className="status-dot status-completed" title="子代理执行" />}
</div>
))}
</div>
)
}
function TodosTab({ todos }: { todos: { items: { id: string; text: string; status: string }[] } | null }) {
if (!todos?.items?.length) return <Empty text="暂无待办(mock:#agents)" />
return <>{todos.items.map(t => (
<div key={t.id} className={`step ${t.status}`}>
<span className="step-icon"><StatusIcon status={t.status} /></span>
<span>{t.text}</span>
</div>
))}</>
}
function ArtifactsTab() {
const artifacts = useStore(s => s.artifacts)
if (artifacts.length === 0) return <Empty text="暂无工件——上传附件或让模型产出文档(mock:#doc)" />
return <>{artifacts.map(a => (
<div key={a.id} className="artifact-row" onClick={() => store.viewArtifact(a)}>
<span>{a.kind === 'image' ? '🖼' : a.kind === 'document' ? '📄' : '🗂'}</span>
<span className="name" title={a.name}>{a.name}</span>
<span className="kind">{a.kind}</span>
</div>
))}</>
}
function AgentsTab({ runs }: { runs: Run[] }) {
const convRuns = runs.filter(r => r.kind !== 'tool')
if (convRuns.length === 0) return <Empty text="暂无运行——多代理任务会在此形成任务树(mock:#agents)" />
// 任务树:顶层 run + 其子代理。
const roots = convRuns.filter(r => !r.parentRunId)
return <div className="subagent-tree">
{roots.map(r => (
<div key={r.id}>
<AgentRow run={r} depth={0} />
{convRuns.filter(c => c.parentRunId === r.id).map(c => <AgentRow key={c.id} run={c} depth={1} />)}
</div>
))}
</div>
}
function AgentRow({ run, depth }: { run: Run; depth: number }) {
/** 代理行(工作区弹层复用;点击进 AgentTracker)。 */
export function AgentRow({ run, depth }: { run: Run; depth: number }) {
const label = run.kind === 'subagent' ? `🤖 ${run.agentName || '子代理'}` : '💬 对话任务'
const terminal = isTerminal(run.status)
return (
<div className="agent-row" style={{ paddingLeft: 8 + depth * 18 }} onClick={() => store.openTracker(run.id)}>
<span className={`status-dot status-${run.status}`} />
<span style={{ flex: 1 }}>{label}</span>
<span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{terminal ? run.status : (run.phase || run.status)}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
<span style={{ fontSize: 'var(--fs-ui-xs)', color: 'var(--text-faint)' }}>{run.status}</span>
</div>
)
}
function Empty({ text }: { text: string }) {
return <div style={{ color: 'var(--text-faint)', fontSize: 12.5, padding: '10px 4px', lineHeight: 1.8 }}>{text}</div>
}
+148
View File
@@ -0,0 +1,148 @@
// 工作区弹层(提案 T4/D25):工件/代理 经 OverlayHost 呈现。
// 重设计方案 阶段②(IA 对话优先):插件面板/服务端声明工作区属系统能力,
// 只进「系统管理」分区(AdminZone),不占普通用户的 ⌘1–9;清单按角色收窄。
import { useState } from 'react'
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { api } from '../api'
import { OverlayHost, Icon, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
import { artifactIcon, humanSize } from '../messageparts'
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区仅管理员可见。 */
export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] {
const s = store.getState()
const admin = !!s.me?.isAdmin
const out = [
{ key: 'artifacts', title: '工件', icon: '📦', shortcut: '⌘1' },
{ key: 'agents', title: '代理', icon: '🤖', shortcut: '⌘2' },
...(admin ? [
...s.pluginPanels.map((p, i) => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', shortcut: `⌘${i + 3}` })),
...s.serverWorkspaces.map((w, i) => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', shortcut: w.shortcut ? `⌘${w.shortcut}` : `⌘${i + 3 + s.pluginPanels.length}` })),
] : []),
]
return out.slice(0, 9)
}
export function Workspaces() {
const active = useStore(s => s.ui.activeWorkspace)
const artifacts = useStore(s => s.artifacts)
const runs = useStore(s => s.runs)
const pluginPanels = useStore(s => s.pluginPanels)
const serverWorkspaces = useStore(s => s.serverWorkspaces)
if (!active) return null
const entry = workspaceEntries().find(e => e.key === active)
const close = () => store.closeWorkspace()
if (active === 'artifacts') {
return <OverlayHost title="工件" icon="📦" shortcut="⌘1" badge={artifacts.length || undefined} onClose={close}>
<ArtifactsPanel />
</OverlayHost>
}
if (active === 'agents') {
const convRuns = runs.filter(r => r.kind !== 'tool')
return <OverlayHost title="代理" icon="🤖" shortcut="⌘2" onClose={close}>
<AgentsPanel runs={convRuns} />
</OverlayHost>
}
if (active.startsWith('plugin:')) {
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
nodes={panel?.body ?? []} docBridge={docBridge as DocBridge} onRefresh={() => store.refreshPlugins()} />
}
if (active.startsWith('ws:')) {
const ws = serverWorkspaces.find(w => `ws:${w.id}` === active)
return <OverlayHost title={ws?.title ?? '工作区'} icon={ws?.icon || '🗂'} shortcut={entry?.shortcut} onClose={close}
nodes={ws?.body ?? []} docBridge={docBridge as DocBridge} onRefresh={() => store.refreshPlugins()} />
}
return null
}
/** 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内 深预览/右栏预览/下载。
* 删除是保留能力:DELETE /api/artifacts/{id} 与 api.deleteArtifact 均在(插件可用),
* 系统默认不提供删除入口(提案 2026-09-30-file-preview-and-reserved-capabilities §2.2)。 */
export function ArtifactsPanel() {
const artifacts = useStore(s => s.artifacts)
const [q, setQ] = useState('')
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
const kinds = ['all', 'document', 'data', 'image'] as const
const visible = artifacts.filter(a =>
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
return (
<div className="artifacts-panel">
<div className="ap-toolbar">
<input className="search-input" placeholder="搜索文件名…" value={q} onChange={e => setQ(e.target.value)} />
{kinds.map(k => (
<button key={k} className={`kind-pill ${kind === k ? 'on' : ''}`} onClick={() => setKind(k)}>
{k === 'all' ? '全部' : k === 'document' ? '文档' : k === 'data' ? '数据' : '图片'}
</button>
))}
<label className="btn ghost ap-upload" title="上传文件到当前项目">
<Icon name="paperclip" size={14} /> 上传
<input type="file" multiple style={{ display: 'none' }} onChange={async e => {
for (const f of Array.from(e.target.files ?? [])) await store.uploadAttachment(f)
e.target.value = ''
}} />
</label>
</div>
{artifacts.length === 0
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
: visible.length === 0
? <div className="empty-note">没有匹配「{q}」的文件</div>
: visible.map(a => (
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}(右栏,边聊边看)`}>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
<span className="ar-acts">
<button className="icon-btn" title="全屏预览" onClick={e => { e.stopPropagation(); store.requestPreview(a, 'full') }}><Icon name="eye" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</span>
</div>
))}
</div>
)
}
function shortTime(iso: string): string {
try {
const d = new Date(iso)
const now = new Date()
return d.toDateString() === now.toDateString()
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
} catch { return '' }
}
/** 代理面板(任务树;点击进 AgentTracker 深读)。 */
export function AgentsPanel({ runs }: { runs: Run[] }) {
if (runs.length === 0) return <div className="empty-note">暂无运行——多代理任务会在此形成任务树(mock:#agents)</div>
const roots = runs.filter(r => !r.parentRunId)
return <div className="subagent-tree">
{roots.map(r => (
<div key={r.id}>
<AgentRow run={r} depth={0} />
{runs.filter(c => c.parentRunId === r.id).map(c => <AgentRow key={c.id} run={c} depth={1} />)}
</div>
))}
</div>
}
/** ⌘1–9 / Ctrl+1–9 全局快捷键(挂载于 App)。 */
export function workspaceShortcuts(e: KeyboardEvent) {
if (!(e.metaKey || e.ctrlKey)) return
const n = parseInt(e.key, 10)
if (!Number.isInteger(n) || n < 1 || n > 9) return
const entries = workspaceEntries()
const hit = entries[n - 1]
if (!hit) return
e.preventDefault()
store.openWorkspace(hit.key)
}
+233
View File
@@ -0,0 +1,233 @@
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
import { useEffect, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile } from '../contracts'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { randomPassword } from '../passwords'
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制)----
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
export function UsersPanel() {
const me = useStore(s => s.me)
const [users, setUsers] = useState<AdminUserRow[] | null>(null)
const [err, setErr] = useState('')
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('')
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } 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('')
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)
try {
await api.resetAdminUserPassword(resetFor, resetPw)
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
setResetFor(null); setResetPw('')
await refresh()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
{me?.isAdmin && (
<div className="ad-create">
<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)} />
</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>
</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 ? (
<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>
) : (
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<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>
)}</td>
</tr>
))}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
)}
</div>
)
}
// ---- 智能体画像(原 SettingsModal ProfilesSection:数据化导入导出 + 编辑)----
export function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
const [editing, setEditing] = useState<AgentProfile | null>(null)
const [text, setText] = useState('')
return (
<div>
<div className="doc-toolbar">
<Btn variant="primary" icon="plus" onClick={() => { setEditing(newProfile()); setText('') }}>新建画像</Btn>
<label className="btn ghost" style={{ cursor: 'pointer' }}>
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
const f = e.target.files?.[0]
if (!f) return
try {
const p = JSON.parse(await f.text())
p.id = p.id || 'prof_imported'
p.builtin = false
await api.saveProfile(p)
store.refreshProfiles()
store.toast('画像已导入')
} catch (err) { store.toast(`导入失败:${err}`) }
}} />
</label>
</div>
{profiles.map(p => (
<SettingRow key={p.id}
label={<>{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-xs)' }}>{p.id}{p.builtin ? ' · 内置' : ''}</span></>}
desc={p.description}>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<Btn variant="ghost" icon="download" onClick={() => {
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob); a.download = `${p.id}.json`; a.click()
}}>导出</Btn>
{!p.builtin && <Btn variant="ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</Btn>}
{!p.builtin && <Btn variant="ghost" icon="trash" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</Btn>}
</div>
</SettingRow>
))}
{editing && (
<SettingsGroup title={`编辑画像:${editing.name}`}>
<div className="ui-form">
<label>名称<input value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} /></label>
<label>系统提示<textarea className="search-input" rows={4} style={{ height: 'auto' }} value={text} onChange={e => setText(e.target.value)} /></label>
<label>允许工具(逗号分隔,* 全部)<input value={editing.tools.allow.join(',')} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, allow: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>执行前确认(前缀. 结尾表示族)<input value={editing.tools.confirm?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, confirm: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>拒绝工具<input value={editing.tools.deny?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, deny: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
</div>
<Btn variant="primary" icon="check" onClick={async () => {
try {
await api.saveProfile({ ...editing, builtin: false, source: 'user', systemPrompt: text })
await store.refreshProfiles()
setEditing(null)
store.toast('画像已保存')
} catch (e) { store.toast(`保存失败:${e}`) }
}}>保存画像</Btn>
</SettingsGroup>
)}
</div>
)
}
function newProfile(): AgentProfile {
return {
id: '', name: '新画像', systemPrompt: '', model: {}, tools: { allow: ['*'] },
context: { maxHistoryMessages: 40 }, hitl: { askOnAmbiguity: true }, budget: {},
} as AgentProfile
}
// ---- 插件管理(原 SettingsModal PluginsSection:清单 + 热重载)----
export function PluginsSection() {
const [plugins, setPlugins] = useState<any[]>([])
const refresh = () => api.listPlugins().then(setPlugins).catch(() => undefined)
useEffect(() => { refresh() }, [])
return (
<div>
<div className="doc-toolbar">
<Btn variant="ghost" icon="undo" onClick={async () => {
await api.reloadPlugins()
await refresh()
store.refreshPlugins()
store.toast('插件已热重载')
}}>热重载</Btn>
<span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-sm)' }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
</div>
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
{plugins.map(p => (
<SettingRow key={p.manifest.id}
label={`${p.manifest.name} v${p.manifest.version}`}
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
<span className="effect-badge">api {p.manifest.apiVersion}</span>
</SettingRow>
))}
</div>
)
}
// ---- 插件工作区(插件声明面板 + 服务端声明工作区;声明体经 UIRenderer 内联渲染)----
export function PluginWorkspacesPanel() {
const pluginPanels = useStore(s => s.pluginPanels)
const serverWorkspaces = useStore(s => s.serverWorkspaces)
const [selected, setSelected] = useState<string | null>(null)
const entries = [
...pluginPanels.map(p => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', body: p.body ?? [] })),
...serverWorkspaces.map(w => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', body: (w.body ?? []) as any[] })),
]
const current = entries.find(e => e.key === selected) ?? entries[0]
if (entries.length === 0) {
return <EmptyState icon="grid">暂无插件声明工作区<br />(插件可在 manifest 里声明 panels / workspaces)</EmptyState>
}
return (
<div>
<div className="doc-toolbar" style={{ flexWrap: 'wrap' }}>
{entries.map(e => (
<button key={e.key} className={`kind-pill ${current?.key === e.key ? 'on' : ''}`} onClick={() => setSelected(e.key)}>
{e.icon} {e.title}
</button>
))}
<span style={{ flex: 1 }} />
<Btn variant="ghost" icon="undo" onClick={() => store.refreshPlugins()}>刷新</Btn>
</div>
{current && <UIRenderer nodes={current.body} docBridge={docBridge as DocBridge} />}
</div>
)
}
+77 -3
View File
@@ -14,6 +14,8 @@ export interface Project {
export interface Conversation {
id: ID; projectId: ID; owner?: ID; title: string; profileId?: ID;
pinned?: boolean; archived?: boolean;
allowCrossRead?: boolean; crossPrivate?: boolean;
createdAt: string; updatedAt: string;
}
@@ -31,7 +33,7 @@ export interface Part {
artifact?: { artifactId: ID; name: string; kind?: string };
hitl?: { cardId: ID; question: string; resolved: boolean; answer?: string };
plan?: PlanSnapshot;
card?: { kind: string; data: UINode[] };
card?: { kind: string; data: UINode[]; cardId?: ID }; // cardId 非空 = 收件箱卡片引用部件(submit_card)
error?: { code: string; message: string };
process?: boolean; // run 终态后折叠为摘要行
}
@@ -81,7 +83,8 @@ export type StreamEventType =
| 'run.phase' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update'
| 'agent.spawn' | 'agent.progress' | 'agent.result'
| 'artifact.created' | 'artifact.updated'
| 'run.completed' | 'run.failed' | 'error';
| 'run.completed' | 'run.failed' | 'error'
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved';
export interface StreamEvent<P = unknown> {
seq: number; runId?: ID; conversationId: ID; ts: string; v: number;
@@ -106,6 +109,28 @@ export interface ContextObservation {
}
export interface RunFailedPayload { code: string; message: string }
// ---- 收件箱卡片 ----
export type CardKind =
| 'progress' | 'confirm' | 'compare' | 'review-list'
| 'result' | 'error' | 'status';
export type CardStatus =
| 'active' | 'action_required' | 'done'
| 'resolved' | 'failed' | 'dismissed';
export interface ActivityCard {
id: ID; projectId: ID; conversationId?: ID;
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;
}
export interface CardPayload { card: ActivityCard }
export interface CardStatusPayload { cardId: ID; status: CardStatus; tone?: string; note?: string }
export interface CardResolvedPayload { cardId: ID; actionId?: string; answer?: string }
// ---- HITL ----
export type HITLKind = 'choice' | 'input' | 'confirm' | 'form';
@@ -118,6 +143,7 @@ export interface HITLCard {
id: ID; runId: ID; conversationId: ID; question: string; kind: HITLKind;
choices?: HITLChoice[]; allowFreeInput: boolean; freeInputHint?: string;
timeoutSec?: number | null; form?: UINode[]; createdAt: string; resolved: boolean;
anchorMessageId?: ID; blocked?: boolean; blockReason?: string;
}
export interface HITLResponse {
@@ -127,7 +153,8 @@ export interface HITLResponse {
// ---- UI 词汇 ----
export type UINodeType =
| 'section' | 'list' | 'kv' | 'form' | 'markdown' | 'doc-ref' | 'actions' | 'chart-lite';
| 'section' | 'list' | 'kv' | 'form' | 'markdown' | 'doc-ref' | 'actions' | 'chart-lite'
| 'avatar' | 'progress' | 'compare' | 'tree' | 'table' | 'steps' | 'tabs' | 'timeline';
export interface UINode {
type: UINodeType;
@@ -140,6 +167,26 @@ export interface UINode {
actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger' }[];
chart?: { kind: 'bar' | 'line'; labels: string[]; series: number[] };
children?: UINode[];
avatar?: { name?: string; sub?: string; tone?: string; stack?: { name?: string; sub?: string; tone?: string }[] };
progress?: { label?: string; value: number; tone?: string };
compare?: {
left: { title?: string; tone?: string; nodes?: UINode[] };
right: { title?: string; tone?: string; nodes?: UINode[] };
};
tree?: {
nodes: {
id: string; label: string; tone?: string; badge?: string; count?: number;
selected?: boolean; children?: unknown[];
}[];
};
table?: {
columns: { key: string; label: string }[];
rows: { id?: string; cells: [string, string][]; actions?: UINode['actions'] }[];
};
steps?: { current: number; steps: { label: string; status?: 'pending' | 'active' | 'done' | 'failed'; tone?: string }[] };
tabs?: { active?: string; tabs: { id: string; label: string; nodes?: UINode[] }[] };
timeline?: { items: { time?: string; title: string; tone?: string; markdown?: string; ref?: string }[] };
}
export interface UIField {
@@ -148,11 +195,32 @@ export interface UIField {
options?: string[]; required?: boolean; placeholder?: string; help?: string; default?: string;
}
// ---- 提示词库(prompt-library)----
export interface PromptTrigger {
kind: 'intent' | 'command' | 'attachment' | 'manual';
keywords?: string[]; command?: string;
}
export interface PromptModule {
id: string; layer: 'base' | 'env' | 'task' | 'agent' | 'tool' | 'aux' | 'style';
version: number; title: string; description?: string; priority?: number;
triggers?: PromptTrigger[]; requiresAttachments?: boolean;
body: string; outputContract?: string;
source?: 'builtin' | 'plugin' | 'user'; owner?: string;
basedOnVersion?: number; drifted?: boolean; disabled?: boolean;
}
export interface PromptBlock { moduleId: string; version: number; layer: string; body: string; }
export interface PromptHitRecord { moduleId: string; layer: string; reason: string; injected: boolean; }
// ---- 画像 / 设置 ----
export interface AgentProfile {
id: string; owner?: string; name: string; description?: string; builtin?: boolean; source?: string;
systemPrompt: string;
/** 提示词库模块引用(有序;空 = 默认 base+env 全集)。 */
promptModules?: string[];
model: { chat?: string; lightweight?: string; embedding?: string; rerank?: string };
tools: { allow: string[]; deny?: string[]; confirm?: string[] };
context: {
@@ -179,6 +247,7 @@ export interface PluginManifest {
provides: {
tools?: string[]; workflows?: string[]; intents?: string[];
prompts?: string[]; panels?: string[]; cards?: string[]; settings?: string[];
commands?: string[]; workspaces?: string[];
};
}
@@ -186,6 +255,11 @@ export interface PluginPanel {
id: string; title: string; icon?: string; body: UINode[]; refreshTool?: string;
}
export interface PluginWorkspace {
id: string; title: string; icon?: string; shortcut?: string;
badgeTool?: string; body: UINode[]; refreshTool?: string;
}
// ---- 摄取 / 检索 ----
export interface ParsedDocument {
+105
View File
@@ -0,0 +1,105 @@
// live 流式投影纯逻辑(中栏过程/回复分离):事件按到达序并入 items——thinking/text
// 增量各并入其末段,工具按当前位置落位。SSE 有序且后端每轮按 思考→正文→工具 追加落库
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定,
// 对齐落库的 Process: len(calls) > 0——工具轮解说的 tool.call 一到即并入过程区,
// 无需等下一轮文本段开启。
// 残余边界:某轮若既无正文也无思考、仅静默发起工具,其调用会被并入上一文本段(需协议
// 携带轮次归属才能消除;现行事件流无法区分)。
import type { Message, Part } from './contracts'
import type { LiveState } from './store'
// 到达序部件流的元素。
export type LiveItem =
| { kind: 'thinking'; text: string }
| { kind: 'text'; index: number; text: string }
| { kind: 'tool'; callId?: string; name: string; status?: string; summary?: string }
| { kind: 'artifact'; artifact: { artifactId: string; name: string; kind?: string } }
// applyThinkingDelta 并入思考增量:末段是思考则追加,否则开启新思考段(新轮开始)。
export function applyThinkingDelta(items: LiveItem[], delta: string): LiveItem[] {
const last = items[items.length - 1]
if (last && last.kind === 'thinking') {
const out = items.slice()
out[out.length - 1] = { kind: 'thinking', text: last.text + delta }
return out
}
return [...items, { kind: 'thinking', text: delta }]
}
// applyTextDelta 并入正文增量:末段是同 index 的文本段则追加,否则开启新文本段。
export function applyTextDelta(items: LiveItem[], index: number, delta: string): LiveItem[] {
const last = items[items.length - 1]
if (last && last.kind === 'text' && last.index === index) {
const out = items.slice()
out[out.length - 1] = { kind: 'text', index, text: last.text + delta }
return out
}
return [...items, { kind: 'text', index, text: delta }]
}
export function applyToolCall(items: LiveItem[], name: string, callId?: string): LiveItem[] {
return [...items, { kind: 'tool', callId, name }]
}
// applyArtifactCreated 追加工件引用(artifact.created 事件)。投影时作为
// 交付物钉在消息尾部(内容部件,不进过程折叠)——与落库序同构。
export function applyArtifactCreated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] {
return [...items, { kind: 'artifact', artifact }]
}
// applyToolResult 回填对应工具(优先按 callId 配对,回落到最近一个未决工具——
// 与落库的尾部配对语义一致)。
export function applyToolResult(items: LiveItem[], callId: string | undefined, status: string, summary?: string): LiveItem[] {
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
if (it.kind !== 'tool' || it.status !== undefined) continue
if (callId && it.callId && it.callId !== callId) continue
const out = items.slice()
out[i] = { kind: 'tool', callId: it.callId, name: it.name, status, summary }
return out
}
return items
}
// liveToMessage:run 进行中没有已落库的助手消息,把 LiveState 合成同构临时消息,
// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);终态对账后由真消息替代。
export function liveToMessage(live: LiveState): Message {
// 文本段是否为过程:本段之后、下一个思考/文本段之前出现工具(= 该轮带工具调用)。
const textProcess = new Map<number, boolean>()
for (let i = 0; i < live.items.length; i++) {
if (live.items[i].kind !== 'text') continue
let hasCalls = false
for (let j = i + 1; j < live.items.length && live.items[j].kind === 'tool'; j++) hasCalls = true
textProcess.set(i, hasCalls)
}
const parts: Part[] = []
const deliverables: Part[] = []
for (let i = 0; i < live.items.length; i++) {
const it = live.items[i]
if (it.kind === 'thinking') {
if (it.text) parts.push({ type: 'thinking', text: it.text, process: true })
} else if (it.kind === 'tool') {
parts.push({ type: 'tool_call', process: true, toolCall: { id: it.callId ?? '', name: it.name } })
if (it.status !== undefined) {
parts.push({ type: 'tool_result', process: true, toolCall: { id: it.callId ?? '', name: it.name },
toolResult: { callId: it.callId ?? '', status: it.status === 'error' ? 'error' : 'ok', summary: it.summary } })
}
} else if (it.kind === 'artifact') {
// 交付物卡片:内容部件,投影钉尾(落库侧 llm.go 同序——最终正文之后),
// 流式期间即可在消息底部看到"文件已生成"卡片。
deliverables.push({ type: 'artifact_ref', artifact: it.artifact })
} else {
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
}
}
parts.push(...deliverables)
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
}
// showProcessInline:流式期间过程区是否展开——thinking/acting 实时展开;一旦进入
// responding(最终回复开始流式)立即折叠,只显示回复(Cursor 式),工具恢复再展开。
// 非 live 消息随「自动折叠过程」偏好。
export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean {
return live ? live.phase !== 'responding' : !autoCollapse
}
+5
View File
@@ -1,10 +1,15 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import { wirePreviewCapabilities } from './previewsetup'
import '@ui/tokens.css'
import '@ui/motion.css'
import '@ui/ui.css'
import './styles.css'
// 预览能力装配(D1–D4):Office 解析器与链接预览 fetcher 注入 @ui(动态加载,不进主包)。
wirePreviewCapabilities()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
+46
View File
@@ -0,0 +1,46 @@
// 消息部件顺序保真(渲染顺序 = 落库顺序):后端 llm.go 按时间交错落库 parts
// (text → tool_call → tool_result → artifact_ref → 下一轮 text…),渲染端不得
// 按 process 标志二分重排——只允许把"连续的"过程部件就地聚成一块折叠。
import type { Part } from './contracts'
export type PartBlock =
| { kind: 'process'; parts: Part[] } // 连续过程段(thinking/tool_call/tool_result…)
| { kind: 'content'; part: Part } // 原位内容部件(text/artifact_ref/card/hitl…)
/**
* 按原始顺序把 parts 切成「过程段 / 内容」交替的块序列。
* 例:[text, tool_call, tool_result, text, artifact_ref]
* → [content(text), process(tool_call,tool_result), content(text), content(artifact_ref)]
* 连续的过程部件合并为一个折叠单元,不改变相对顺序。
*/
export function groupParts(parts: Part[]): PartBlock[] {
const out: PartBlock[] = []
for (const p of parts) {
if (p.process) {
const last = out[out.length - 1]
if (last && last.kind === 'process') last.parts.push(p)
else out.push({ kind: 'process', parts: [p] })
} else {
out.push({ kind: 'content', part: p })
}
}
return out
}
/** 文件大小人类可读(工件面板/DocViewer 共用)。 */
export function humanSize(n: number | undefined): string {
if (!n || n <= 0) return ''
const units = ['B', 'KB', 'MB', 'GB']
let v = n
let i = 0
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
const s = v >= 100 || i === 0 ? String(Math.round(v)) : v.toFixed(1).replace(/\.0$/, '')
return `${s} ${units[i]}`
}
/** 工件文件图标名(@ui Icon 名):按扩展名优先,回落 kind。 */
export function artifactIcon(name: string, kind?: string): 'file' | 'image' | 'package' {
if (/\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(name) || kind === 'image') return 'image'
if (/\.(zip|tar|gz|7z|rar)$/i.test(name)) return 'package'
return 'file'
}
+11
View File
@@ -0,0 +1,11 @@
// 浏览器端随机初始密码(管理员未显式给定密码时):
// 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
}
+85
View File
@@ -0,0 +1,85 @@
// 预览能力装配(裁定 D1–D4):把重解析器经 configureFilePreviewLoaders 注入 @ui。
// 全部 dynamic import——对应格式首次预览时才下载 chunk,主 bundle 不含解析器。
// 链接预览 fetcher 走服务端 /api/url-preview(SSRF 防护在服务端执行端)。
import { configureFilePreviewLoaders, configureLinkPreviewFetcher } from '@ui'
import { auth } from './auth'
import type { LinkPreviewData } from '@ui'
const fetchBuf = async (url: string | undefined): Promise<ArrayBuffer> => {
const r = await fetch(url!)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.arrayBuffer()
}
const docxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
const mammoth = await import('mammoth')
const { value } = await mammoth.convertToHtml({ arrayBuffer: await fetchBuf(source.contentUrl) })
el.innerHTML = value
}
const xlsxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
const XLSX = await import('xlsx')
const wb = XLSX.read(await fetchBuf(source.contentUrl), { type: 'array' })
el.innerHTML = ''
const tabs = document.createElement('div')
tabs.className = 'ui-fp-sheet-tabs'
const wrap = document.createElement('div')
wrap.className = 'ui-fp-sheet-wrap'
el.append(tabs, wrap)
wb.SheetNames.forEach((name, i) => {
const tab = document.createElement('button')
tab.className = `ui-fp-sheet-tab ${i === 0 ? 'on' : ''}`
tab.textContent = name
tab.onclick = () => {
tabs.querySelectorAll('.ui-fp-sheet-tab').forEach(t => t.classList.remove('on'))
tab.classList.add('on')
renderSheet(wrap, XLSX.utils.sheet_to_json<unknown[]>(wb.Sheets[name], { header: 1, defval: '' }))
}
tabs.append(tab)
if (i === 0) renderSheet(wrap, XLSX.utils.sheet_to_json<unknown[]>(wb.Sheets[name], { header: 1, defval: '' }))
})
}
function renderSheet(wrap: HTMLElement, rows: unknown[][]) {
wrap.innerHTML = ''
if (rows.length > 200) {
const note = document.createElement('div')
note.className = 'ui-fp-note'
note.textContent = `仅显示前 200 行(共 ${rows.length} 行)——完整内容请下载`
wrap.append(note)
}
const table = document.createElement('table')
table.className = 'ui-fp-sheet'
for (const row of rows.slice(0, 200)) {
const tr = document.createElement('tr')
for (const cell of row) {
const td = document.createElement('td')
td.textContent = cell == null ? '' : String(cell)
td.title = td.textContent
tr.append(td)
}
table.append(tr)
}
wrap.append(table)
}
const pptxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
// pptx2html@0.3.4 的 module 入口引用了大小写错配的 ./txml(Linux 打包必炸),
// 按 D2 备选条款改用 pptx-preview(init(dom).preview(arrayBuffer) 全保真渲染)。
const { init } = await import('pptx-preview')
el.innerHTML = ''
const stage = document.createElement('div')
stage.className = 'ui-fp-slide-stage'
el.append(stage)
const previewer = init(stage, { width: stage.clientWidth || 900 })
await previewer.preview(await fetchBuf(source.contentUrl))
}
export function wirePreviewCapabilities() {
configureFilePreviewLoaders({ docx: docxLoader, xlsx: xlsxLoader, pptx: pptxLoader })
configureLinkPreviewFetcher(async href => {
const r = await fetch(auth.withKey(`/api/url-preview?url=${encodeURIComponent(href)}`))
if (!r.ok) return { url: href, error: `HTTP ${r.status}` }
return (await r.json()) as LinkPreviewData
})
}
+279 -30
View File
@@ -2,9 +2,10 @@
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated, type LiveItem } from './live'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
AgentProfile, StreamEvent, PluginPanel,
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
} from './contracts'
export interface ContextObs {
@@ -16,9 +17,9 @@ export interface ContextObs {
export interface LiveState {
runId: string
phase: string
thinking: string
text: string
toolCalls: { name: string; status?: string; summary?: string }[]
// 到达序部件流(见 live.ts LiveItem):事件到达序即落库序,与落库部件的轮次穿插序
// 同构;文本段是否为过程由「本段内是否出现工具调用」判定,tool.call 一到即改标。
items: LiveItem[]
agentNotes: Record<string, string>
startedAt: number
}
@@ -34,13 +35,26 @@ export interface UIState {
settingsOpen: boolean
trackerRunId: string | null
viewingArtifact: Artifact | null
/** 右栏轻预览的工件(rail 预览页签内容;与弹窗深预览 viewingArtifact 并存)。 */
railPreviewId: string | null
activeWorkspace: string | null
taskOrg: 'project' | 'timeline'
showArchived: boolean
lastProjectId: string | null
/** 系统管理全屏视图(阶段② IA:仅管理员;入口账户菜单,纵深防御在 AdminZone 再拦一次)。 */
adminZoneOpen: boolean
changePwOpen: boolean
projectSettingsId: string | null
}
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }
interface AppState {
contextObs: Record<string, ContextObs>
projects: Project[]
currentProjectId: string | null
conversations: Conversation[]
tasks: Conversation[]
me: Me | null
currentConvId: string | null
messages: Message[]
runs: Run[]
@@ -50,8 +64,12 @@ interface AppState {
plan: PlanSnapshot | null
todos: TodoList | null
artifacts: Artifact[]
cards: ActivityCard[]
projectCards: ActivityCard[]
inboxScope: 'conv' | 'project'
profiles: AgentProfile[]
pluginPanels: PluginPanel[]
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
ui: UIState
connected: boolean
error: string | null
@@ -63,12 +81,20 @@ const DEFAULT_UI: UIState = {
leftCollapsed: false,
rightWidth: 340,
rightCollapsed: false,
rightTab: 'plan',
rightTab: 'inbox',
activeWorkspace: null,
theme: 'dark',
autoCollapseProcess: true,
settingsOpen: false,
trackerRunId: null,
viewingArtifact: null,
railPreviewId: null,
taskOrg: 'project',
showArchived: false,
lastProjectId: null,
adminZoneOpen: false,
changePwOpen: false,
projectSettingsId: null,
}
function loadUI(): UIState {
@@ -81,9 +107,10 @@ function loadUI(): UIState {
let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, conversations: [], currentConvId: null,
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], profiles: [], pluginPanels: [],
plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', profiles: [], pluginPanels: [],
serverWorkspaces: [],
ui: loadUI(), connected: false, error: null, toast: null,
}
@@ -107,6 +134,8 @@ function applyTheme(theme: string) {
}
let sse: { close: () => void } | null = null
let projectSse: { close: () => void } | null = null
let projectSeq = 0
export function useStore<T>(selector: (s: AppState) => T): T {
return useSyncExternalStore(
@@ -121,12 +150,23 @@ export const store = {
async refreshProjects() {
try {
const projects = await api.listProjects()
const current = projects.find(p => p.id === state.currentProjectId) ?? projects[0]
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
set({ projects, currentProjectId: current?.id ?? null, error: null })
if (current) await store.openProject(current.id)
} catch (e) {
set({ error: String(e) })
}
store.refreshTasks()
store.refreshMe()
},
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
async refreshTasks(includeArchived = state.ui.showArchived) {
try { set({ tasks: await api.listTasks(includeArchived) }) } catch { /* 列表竞态,静默 */ }
},
async refreshMe() {
try { set({ me: await api.me() }) } catch { /* 身份不可用时侧栏只显示通用入口 */ }
},
async createProject(name: string) {
@@ -137,21 +177,63 @@ export const store = {
},
async openProject(projectId: string) {
const conversations = await api.listConversations(projectId)
set({ currentProjectId: projectId, conversations })
set({ currentProjectId: projectId })
if (store.getState().inboxScope === 'project') {
store.loadProjectCards()
store.connectProject()
}
store.setUI({ lastProjectId: projectId })
await store.refreshArtifacts(projectId)
},
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。 */
async createTask(projectId?: string) {
const pid = projectId ?? state.currentProjectId
if (!pid) { store.toast('请先创建项目'); return }
const c = await api.createConversation(pid)
await store.refreshTasks()
await store.openTask(c.id)
},
/** 兼容旧入口(⌘/命令总线) */
async createConversation(title?: string) {
if (!state.currentProjectId) return
const c = await api.createConversation(state.currentProjectId, title)
await store.openProject(state.currentProjectId)
await store.openConversation(c.id)
if (title) store.toast('已在当前项目创建任务')
await store.createTask()
},
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
async openTask(convId: string) {
const t = state.tasks.find(x => x.id === convId)
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
await store.openConversation(convId)
},
async pinTask(convId: string, pinned: boolean) {
const tasks = state.tasks.map(t => t.id === convId ? { ...t, pinned } : t)
set({ tasks })
try { await api.setConversationFlags(convId, { pinned }) } catch (e) { store.toast(`置顶失败:${e}`); store.refreshTasks() }
},
async archiveTask(convId: string, archived: boolean) {
const tasks = state.tasks.map(t => t.id === convId ? { ...t, archived } : t)
set({ tasks })
try {
await api.setConversationFlags(convId, { archived })
if (state.currentConvId === convId && archived) store.toast('任务已归档')
} catch (e) { store.toast(`归档失败:${e}`) }
store.refreshTasks()
},
// 跨会话标记(提案 2026-09-30-cross-session-reading):出站 allowCrossRead / 入站 crossPrivate。
async setCrossFlags(convId: string, flags: { allowCrossRead?: boolean; crossPrivate?: boolean }) {
const tasks = state.tasks.map(t => t.id === convId ? { ...t, ...flags } : t)
set({ tasks })
try { await api.setConversationFlags(convId, flags) } catch (e) { store.toast(`跨会话设置失败:${e}`); store.refreshTasks() }
},
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null })
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv' })
await store.reconcile()
await store.connectLive()
},
@@ -172,6 +254,8 @@ export const store = {
})
const [plan, todos] = await Promise.all([api.getPlan(d.conversation.id), api.getTodos(d.conversation.id)])
set({ plan, todos })
store.refreshCards(d.conversation.id)
store.refreshTasks()
} catch (e) {
set({ error: String(e) })
}
@@ -191,26 +275,22 @@ export const store = {
switch (e.type) {
case 'message.delta': {
const p = e.payload as { index: number; text: string }
set({ live: touchLive(e.runId ?? '', l => { l.text += p.text }) })
set({ live: touchLive(e.runId ?? '', l => { l.items = applyTextDelta(l.items, p.index, p.text) }) })
break
}
case 'thinking.delta': {
const p = e.payload as { index: number; text: string }
set({ live: touchLive(e.runId ?? '', l => { l.thinking += p.text }) })
set({ live: touchLive(e.runId ?? '', l => { l.items = applyThinkingDelta(l.items, p.text) }) })
break
}
case 'tool.call': {
const p = e.payload as { callId: string; name: string }
set({ live: touchLive(e.runId ?? '', l => { l.toolCalls.push({ name: p.name }) }) })
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolCall(l.items, p.name, p.callId) }) })
break
}
case 'tool.result': {
const p = e.payload as { callId: string; status: string; summary?: string }
set({ live: touchLive(e.runId ?? '', l => {
for (let i = l.toolCalls.length - 1; i >= 0; i--) {
if (l.toolCalls[i].status === undefined) { l.toolCalls[i] = { ...l.toolCalls[i], status: p.status, summary: p.summary }; break }
}
}) })
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolResult(l.items, p.callId, p.status, p.summary) }) })
break
}
case 'run.phase': {
@@ -252,7 +332,31 @@ export const store = {
set({ todos: p.list })
break
}
case 'card.created':
case 'card.updated': {
const p = e.payload as { card: ActivityCard }
if (p?.card) store.upsertCard(p.card)
break
}
case 'card.status': {
const p = e.payload as { cardId: string; status: ActivityCard['status']; tone?: string }
const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)
set({ cards })
break
}
case 'card.resolved': {
const p = e.payload as { cardId: string }
const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)
set({ cards })
break
}
case 'artifact.created': {
const p = e.payload as { artifact?: { id: string; name: string; kind?: string } }
if (p?.artifact) {
set({ live: touchLive(e.runId ?? '', l => {
l.items = applyArtifactCreated(l.items, { artifactId: p.artifact!.id, name: p.artifact!.name, kind: p.artifact!.kind })
}) })
}
if (state.currentProjectId) store.refreshArtifacts(state.currentProjectId)
break
}
@@ -284,10 +388,12 @@ export const store = {
return
}
if (out.run) {
set({
live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() },
messages: [...state.messages, out.message],
})
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
if (!state.live || state.live.runId !== out.run.id) {
set({ live: { runId: out.run.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() } })
}
set({ messages: [...state.messages, out.message] })
store.connectIfNeeded()
}
} catch (e) {
@@ -319,6 +425,41 @@ export const store = {
}
},
upsertCard(card: ActivityCard) {
const i = state.cards.findIndex(c => c.id === card.id)
const cards = i >= 0 ? state.cards.map(c => c.id === card.id ? card : c) : [card, ...state.cards]
set({ cards })
},
async refreshCards(convId: string) {
try { set({ cards: await api.listCards(convId) }) } catch { /* 会话竞态,静默 */ }
},
// 卡片动作:确认投影卡(来源 hitl:)路由回 HITL 解析(恢复挂起 run,服务端同步销项);
// 其余卡走卡片动作 API,并把服务端持久化的回执消息追加进本地时间线(id 一致,对账不重复)。
async cardAction(card: ActivityCard, actionId: string, freeText?: string) {
const hitlRef = card.body?.find(n => n.type === 'kv')?.kv?.find(([k]) => k === '来源')?.[1]
if (hitlRef?.startsWith('hitl:')) {
await store.resolveHITL(hitlRef.slice(5), actionId ? { choiceId: actionId } : { freeText })
return
}
try {
const out = await api.cardAction(card.id, actionId, freeText)
store.upsertCard(out.card)
if (out.receipt) set({ messages: [...state.messages, out.receipt as Message] })
} catch (e) {
store.toast(`操作失败:${e}`)
}
},
async cardPin(card: ActivityCard, pinned: boolean) {
try { store.upsertCard((await api.cardPin(card.id, pinned)).card) } catch (e) { store.toast(`置顶失败:${e}`) }
},
async cardDismiss(card: ActivityCard) {
try { store.upsertCard((await api.cardDismiss(card.id)).card) } catch (e) { store.toast(`关闭失败:${e}`) }
},
async refreshArtifacts(projectId: string) {
try {
const artifacts = await api.listArtifacts(projectId)
@@ -336,6 +477,10 @@ export const store = {
const panels = list.flatMap(p => p.panels ?? [])
set({ pluginPanels: panels })
} catch { /* ignore */ }
try {
const ws = await api.listWorkspaces()
set({ serverWorkspaces: ws ?? [] })
} catch { /* ignore */ }
},
async uploadAttachment(file: File): Promise<string | null> {
@@ -354,9 +499,113 @@ export const store = {
setUI,
toggleLeft() { setUI({ leftCollapsed: !state.ui.leftCollapsed }) },
toggleRight() { setUI({ rightCollapsed: !state.ui.rightCollapsed }) },
openWorkspace(id: string) { setUI({ activeWorkspace: id }) },
/** 收纳策略(T5):一键收纳已完成卡(done→dismissed;resolved 盖章留痕不动)。 */
// 项目收件箱(CF3/D48-D51):加载聚合视图 + 项目流订阅 + 留档。
async loadProjectCards() {
const pid = store.getState().currentProjectId
if (!pid) return
try { set({ projectCards: await api.listProjectCards(pid) }) } catch { /* 静默:切项目竞态 */ }
},
setInboxScope(scope: 'conv' | 'project') {
set({ inboxScope: scope })
if (scope === 'project') {
store.loadProjectCards()
store.connectProject()
} else if (projectSse) {
projectSse.close()
projectSse = null
}
},
connectProject() {
const pid = store.getState().currentProjectId
if (!pid) return
projectSse?.close()
projectSse = connectEvents('', projectSeq, { onEvent: e => store.applyProjectEvent(e) }, pid)
},
applyProjectEvent(e: StreamEvent) {
projectSeq = Math.max(projectSeq, e.seq)
const upsert = (card: ActivityCard) => {
const list = store.getState().projectCards
const i = list.findIndex(c => c.id === card.id)
set({ projectCards: i >= 0 ? list.map(c => (c.id === card.id ? card : c)) : [card, ...list] })
}
switch (e.type) {
case 'card.created':
case 'card.updated':
if ((e.payload as { card?: ActivityCard })?.card) upsert((e.payload as { card: ActivityCard }).card)
break
case 'card.status': {
const p = e.payload as { cardId: string; status: ActivityCard['status']; tone?: string }
const list = store.getState().projectCards
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)) })
break
}
case 'card.resolved': {
const p = e.payload as { cardId: string }
const list = store.getState().projectCards
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)) })
break
}
}
},
async cardToProject(card: ActivityCard) {
try {
const out = await api.cardToProject(card.id)
store.upsertCard(out.origin) // 会话视图:原卡 dismissed 留痕
const list = store.getState().projectCards
const i = list.findIndex(c => c.id === out.card.id)
set({ projectCards: i >= 0 ? list.map(c => (c.id === out.card.id ? out.card : c)) : [out.card, ...list] })
store.toast('已留档到项目收件箱')
store.setInboxScope('project')
} catch (e) {
store.toast(`留档失败:${e}`)
}
},
async tidyCards() {
const done = state.cards.filter(c => c.status === 'done')
for (const c of done) {
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
}
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
},
closeWorkspace() { setUI({ activeWorkspace: null }) },
openInbox(cardId?: string) {
setUI({ rightCollapsed: false, rightTab: 'inbox' })
if (cardId) setTimeout(() => {
document.querySelector(`[data-card-id="${cardId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 60)
},
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
locateMessage(messageId?: string) {
if (!messageId) return
setTimeout(() => {
document.querySelector(`[data-msg-id="${messageId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 60)
},
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
/** 右栏轻预览:打开指定工件并切到 rail 预览页签(双落位中的"轻"侧)。 */
railPreview(a: Artifact | null) {
if (!a) { setUI({ railPreviewId: null, rightTab: 'inbox' }); return }
setUI({ railPreviewId: a.id, rightTab: 'preview', rightCollapsed: false })
},
/**
* 工件预览唯一入口(重设计方案 阶段③ 收敛,Command 语义):一切预览请求只经这里,
* 落位由本方法派生——默认右栏轻预览(不打断对话),hint 'full' 落位全屏深读
* (DocViewer,全项目唯一"大窗"形态)。下载语义同理由各落位只保留一个按钮。
*/
requestPreview(a: Artifact, hint?: 'full') {
if (hint === 'full') { setUI({ viewingArtifact: a }); return }
setUI({ railPreviewId: a.id, rightTab: 'preview', rightCollapsed: false })
},
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
toast(msg: string) {
set({ toast: msg })
setTimeout(() => { if (state.toast === msg) set({ toast: null }) }, 2600)
@@ -364,8 +613,8 @@ export const store = {
}
function touchLive(_runId: string, mutate: (l: LiveState) => void): LiveState | null {
const base = state.live ?? { runId: _runId, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() }
const next: LiveState = { ...base, toolCalls: [...base.toolCalls], agentNotes: { ...base.agentNotes } }
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() }
const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } }
mutate(next)
return next
}
+394 -127
View File
@@ -1,184 +1,451 @@
/* 设计令牌与 UI 组件样式已迁入 Agent-System-UI(@ui/tokens.css、@ui/ui.css,main.tsx 引入)。 */
/* 宿主样式(R5/Zai 复刻,2026-09-30;权威规则见父仓 DESIGN.md):
- 颜色/圆角/字号只消费 @ui/tokens.css 令牌(--fs-ui-* 标尺);
- 动效只消费 @ui/motion.css 的 mk-* 类(本文件不得出现 @keyframes,守护测试拦截);
- 布局面板化:三栏为浮在 --bg-inset 底座上的圆角面板,间隙 4px; */
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 14px; }
button { font-family: inherit; }
body {
font-family: var(--font); background: var(--bg-inset); color: var(--text);
font-size: var(--fs-ui-base); line-height: 1.6;
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
}
button { font-family: inherit; font-size: inherit; color: inherit; }
::selection { background: var(--accent-soft-2); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
.app { display: flex; height: 100vh; overflow: hidden; }
/* ---- 三栏布局 ---- */
.left-nav { background: var(--bg-panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; flex-shrink: 0; transition: width .08s ease; }
/* ---- 面板化三栏:独立圆角面板 + 4px 间隙(DESIGN.md §6) ---- */
.app { display: flex; height: 100vh; overflow: hidden; gap: 4px; padding: 4px; background: var(--bg-inset); }
.left-nav { background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); display: flex; flex-direction: column; min-width: 0; flex-shrink: 0; transition: width .15s var(--ease); }
.left-nav.collapsed { width: 52px !important; }
.divider { width: 4px; cursor: col-resize; background: transparent; flex-shrink: 0; position: relative; }
.divider:hover, .divider.dragging { background: var(--accent); }
.divider::before { content: ''; position: absolute; left: 1px; top: 12px; bottom: 12px; width: 2px; border-radius: 2px; background: transparent; transition: background .12s; }
.divider:hover::before, .divider.dragging::before { background: var(--border-strong); }
.divider::after { content: ''; position: absolute; inset: 0 -3px; } /* 拖拽热区加宽 */
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); }
.right-panel { background: var(--bg-panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
.right-panel { background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
.right-panel.collapsed { width: 44px !important; }
/* ---- 左栏 ---- */
.nav-header { padding: 14px 14px 8px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
.nav-header button { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px; padding: 4px; border-radius: var(--radius-sm); }
.nav-header button:hover { background: var(--bg-hover); color: var(--text); }
.nav-scroll { flex: 1; overflow-y: auto; padding: 4px 8px 8px; }
.nav-project { margin-bottom: 4px; }
.nav-project-name { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 500; cursor: pointer; user-select: none; }
.nav-project-name:hover { background: var(--bg-hover); }
.nav-project-name .chev { font-size: 10px; transition: transform .12s; }
.nav-project-name.open .chev { transform: rotate(90deg); }
.nav-conv { padding: 5px 8px 5px 26px; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nav-conv:hover { background: var(--bg-hover); color: var(--text); }
.nav-conv.active { background: var(--accent-soft); color: var(--accent); }
.nav-footer { border-top: 1px solid var(--border); padding: 8px; display: flex; gap: 6px; }
.nav-footer button { flex: 1; background: none; border: none; color: var(--text-dim); padding: 7px; border-radius: var(--radius-sm); cursor: pointer; font-size: 12px; }
.nav-footer button:hover { background: var(--bg-hover); color: var(--text); }
.left-nav.collapsed .nav-label { display: none; }
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 10px; }
.icon-only button { background: none; border: none; color: var(--text-dim); font-size: 16px; cursor: pointer; padding: 8px; border-radius: var(--radius-sm); }
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
/* ---- 通用控件 ---- */
.btn {
background: var(--primary); color: var(--primary-foreground); border: none;
border-radius: var(--radius-sm); padding: 8px 16px; cursor: pointer;
font-size: var(--fs-ui-caption); font-weight: 550; white-space: nowrap;
box-shadow: 0 1px 2px rgba(0, 0, 0, .25);
transition: background .12s var(--ease), transform .12s var(--ease);
}
.btn:hover:not(:disabled) { background: color-mix(in srgb, var(--primary) 86%, var(--bg)); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: .42; cursor: not-allowed; box-shadow: none; }
.btn.ghost { background: var(--bg-raised); border: 1px solid var(--border-strong); color: var(--text-dim); box-shadow: none; }
.btn.ghost:hover:not(:disabled) { color: var(--text); border-color: var(--text-faint); background: var(--bg-raised); }
.btn.danger { background: var(--err); }
.icon-btn {
background: none; border: none; color: var(--text-faint);
border-radius: var(--radius-sm); padding: 0; width: 28px; height: 28px;
display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
transition: background .12s, color .12s;
}
.icon-btn:hover { background: var(--bg-hover); color: var(--text); }
.search-input, .cmd-menu input {
background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text); padding: 7px 11px; font-family: inherit; font-size: var(--fs-ui-caption); width: 100%;
transition: border-color .12s, box-shadow .12s;
}
.search-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
/* ---- 中栏对话 ---- */
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.chat-header { padding: 10px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; background: var(--bg-raised); }
.chat-title { font-weight: 600; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-body { flex: 1; overflow-y: auto; padding: 18px max(18px, 12%); }
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 8px; }
.msg { margin-bottom: 18px; }
.msg-role { font-size: 12px; color: var(--text-faint); margin-bottom: 4px; display: flex; gap: 8px; align-items: center; }
.msg.user .bubble { background: var(--accent-soft); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; white-space: pre-wrap; }
/* min-height:0 须随列向 flex 链下传(.main→.chat→.chat-body):flex 项的自动最小
高度是内容高度,缺失时消息会撑破 .chat——chat-body 永不滚动、输入栏被顶出视口。 */
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.chat-header { padding: 12px 22px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 10px; background: var(--bg); border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
.chat-title { font-weight: 600; font-size: var(--fs-ui-base); letter-spacing: -0.01em; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-body { flex: 1; overflow-y: auto; min-height: 0; padding: 22px max(22px, 13%); }
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 10px; font-size: var(--fs-ui-caption); }
.empty-state .es-mark { width: 44px; height: 44px; border-radius: var(--radius-lg); background: var(--bg-raised); border: 1px solid var(--border-soft); display: flex; align-items: center; justify-content: center; color: var(--text-faint); margin-bottom: 4px; }
.empty-state svg { opacity: .5; }
.msg { margin-bottom: 20px; animation: mk-rise-in .18s var(--ease-out); }
.msg-role { font-size: var(--fs-ui-sm); color: var(--text-faint); margin-bottom: 6px; display: flex; gap: 8px; align-items: center; }
.msg-role .role-mark { width: 18px; height: 18px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
.msg.user .bubble {
background: var(--accent-soft); border: 1px solid transparent;
border-radius: var(--radius-lg); padding: 11px 16px; white-space: pre-wrap;
}
.part { margin: 4px 0; }
.part-thinking { color: var(--text-faint); font-size: 13px; border-left: 2px solid var(--border); padding-left: 10px; white-space: pre-wrap; }
.part-tool { font-size: 12.5px; }
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 8px; margin: 2px 4px 2px 0; font-family: var(--mono); }
/* 思考块(Collapsible 契约:触发行 + mk-col 动画体) */
.part-thinking { color: var(--text-dim); font-size: var(--fs-ui-sm); border-left: 2px solid var(--border); padding-left: 10px; margin: 4px 0; }
.part-thinking .part-thinking-summary { color: var(--text-faint); user-select: none; }
.part-thinking .part-thinking-chev { display: inline-flex; color: var(--text-faint); font-size: var(--fs-ui-xs); transition: transform .12s; }
.part-thinking .mk-col-trigger[aria-expanded="true"] .part-thinking-chev { transform: rotate(90deg); }
.part-thinking .part-thinking-body { white-space: pre-wrap; margin-top: 4px; }
.part-tool { font-size: var(--fs-ui-sm); }
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 4px 9px; margin: 2px 4px 2px 0; font-family: var(--mono); }
.tool-chip .status-ok { color: var(--ok); }
.tool-chip .status-error { color: var(--err); }
.part-text { white-space: pre-wrap; line-height: 1.7; }
.part-text :is(h1,h2,h3) { margin: 0.6em 0 0.3em; }
.part-text pre { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; }
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
.part-text :is(p,ul,ol) { margin: 0.4em 0; }
.part-error { color: var(--err); background: color-mix(in srgb, var(--err) 8%, transparent); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
.artifact-chip { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); background: var(--accent-soft); border-radius: var(--radius-sm); padding: 4px 10px; cursor: pointer; font-size: 13px; }
.artifact-chip:hover { filter: brightness(1.15); }
.part-text { line-height: 1.7; overflow-wrap: break-word; }
/* 块级间距只由标签自身控制:禁用 pre-wrap(marked 输出的块间换行会被渲染成空行,
叠加出"列表逐项空一行"的松散排版);行内换行语义交给 Markdown 段落。 */
.part-text > *:first-child { margin-top: 0; }
.part-text > *:last-child { margin-bottom: 0; }
.part-text p { margin: 0.5em 0; }
.part-text :is(h1,h2,h3,h4) { margin: 0.8em 0 0.35em; line-height: 1.35; font-weight: 600; letter-spacing: -0.01em; }
.part-text h1 { font-size: 1.3em; }
.part-text h2 { font-size: 1.18em; }
.part-text h3 { font-size: 1.06em; }
.part-text :is(ul,ol) { margin: 0.4em 0; padding-left: 1.6em; }
.part-text li { margin: 0.15em 0; }
.part-text li > p { margin: 0.2em 0; }
.part-text pre { background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 11px 13px; overflow-x: auto; font-family: var(--mono); font-size: var(--fs-ui-sm); }
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: var(--radius-xs); padding: 1px 5px; font-size: var(--fs-ui-sm); }
.part-text pre code { background: none; padding: 0; }
.part-text blockquote { margin: 0.5em 0; padding: 0.1em 0 0.1em 10px; border-left: 3px solid var(--border-strong); color: var(--text-dim); }
.part-text table { border-collapse: collapse; margin: 0.6em 0; font-size: 0.95em; display: block; max-width: 100%; overflow-x: auto; }
.part-text th, .part-text td { border: 1px solid var(--border); padding: 4px 10px; text-align: left; }
.part-text th { background: var(--bg-raised); font-weight: 600; }
.part-text hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
.part-text a { color: var(--accent); }
.part-error { color: var(--err); background: var(--err-soft); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
.artifact-chip { display: inline-flex; align-items: stretch; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; font-size: var(--fs-ui-caption); margin: 2px 0; }
.artifact-chip .ac-main { display: inline-flex; align-items: center; gap: 7px; color: var(--accent); background: none; border: none; padding: 5px 10px; cursor: pointer; font: inherit; max-width: 340px; }
.artifact-chip .ac-main span, .artifact-chip .ac-main { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.artifact-chip:hover { border-color: var(--accent); }
.artifact-chip .ac-act { display: inline-flex; align-items: center; padding: 5px 8px; color: var(--text-dim); border-left: 1px solid var(--border-soft); background: none; cursor: pointer; }
.artifact-chip .ac-act:hover { color: var(--accent); background: var(--bg-hover); }
/* 过程折叠(run 终态后) */
.process-collapse { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-raised); margin: 6px 0; }
.process-collapse summary { list-style: none; cursor: pointer; padding: 6px 12px; color: var(--text-faint); font-size: 12.5px; display: flex; align-items: center; gap: 8px; user-select: none; }
.process-collapse summary::before { content: '▸'; transition: transform .12s; font-size: 10px; }
.process-collapse[open] summary::before { transform: rotate(90deg); }
/* 交付物卡片(对话内直接给出;整卡点击 = 右栏预览,动作只剩下载) */
.deliverable-card { display: flex; align-items: center; gap: 12px; width: fit-content; max-width: 100%; padding: 10px 12px 10px 10px; margin: 6px 0 2px;
background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); cursor: pointer; user-select: none;
transition: border-color .12s, background .12s; }
.deliverable-card:hover { border-color: var(--accent); background: var(--bg-hover); }
.deliverable-card .dc-icon { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: var(--radius); flex: none;
background: var(--bg-inset); color: var(--accent); border: 1px solid var(--border-soft); }
.deliverable-card .dc-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.deliverable-card .dc-name { color: var(--text); font-weight: 600; font-size: var(--fs-ui-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 380px; }
.deliverable-card .dc-meta { color: var(--text-faint); font-size: var(--fs-ui-sm); white-space: nowrap; }
.deliverable-card .dc-act { flex: none; margin-left: auto; padding: 5px 12px; border-radius: var(--radius-sm); color: var(--accent);
font-size: var(--fs-ui-caption); border: 1px solid var(--border); background: var(--bg-panel); }
.deliverable-card:hover .dc-act { border-color: var(--accent); }
.deliverable-card .dc-dl { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-sm);
color: var(--text-dim); border: 1px solid var(--border-soft); }
.deliverable-card .dc-dl:hover { color: var(--accent); border-color: var(--accent); background: var(--bg-hover); }
/* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */
.part-group { margin: 2px 0; }
/* 过程折叠(run 终态后;Collapsible 契约:触发行 + mk-col 动画体) */
.process-collapse { border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); margin: 6px 0; }
.process-collapse .process-collapse-summary { padding: 7px 12px; color: var(--text-faint); font-size: var(--fs-ui-sm); user-select: none; }
.process-collapse .process-collapse-summary:hover { color: var(--text-dim); }
.process-collapse .process-collapse-chev { display: inline-flex; color: var(--text-faint); font-size: var(--fs-ui-xs); transition: transform .12s; }
.process-collapse .mk-col-trigger[aria-expanded="true"] .process-collapse-chev { transform: rotate(90deg); }
.process-collapse .process-collapse-meta { color: var(--text-faint); }
.process-collapse .process-collapse-excerpt { color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
.process-collapse .body { padding: 4px 12px 10px; }
/* 流式状态行 */
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: 12.5px; margin-bottom: 6px; }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s infinite; }
@keyframes pulse { 0%,100% { opacity: .4 } 50% { opacity: 1 } }
.cursor-blink::after { content: '▍'; animation: pulse 1s infinite; color: var(--accent); }
/* 流式状态行(呼吸/扫光走 mk-* 类;已工作计时弱色等宽) */
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: var(--fs-ui-sm); margin-bottom: 6px; }
.live-elapsed { color: var(--text-faint); font-variant-numeric: tabular-nums; }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: mk-breathe 1.2s infinite; }
.cursor-blink::after { content: '▍'; animation: mk-breathe 1s infinite; color: var(--accent); }
/* HITL 卡片 */
.hitl-card { border: 1px solid var(--accent); background: var(--bg-raised); border-radius: var(--radius); padding: 14px; margin: 10px 0; }
.hitl-q { font-weight: 600; margin-bottom: 10px; }
/* HITL 卡片(确认类交互统一绿色语言) */
.hitl-card { border: 1px solid var(--ok-border); background: var(--ok-soft); border-radius: var(--radius-lg); padding: 14px; margin: 10px 0; }
.hitl-q { font-weight: 600; margin-bottom: 10px; display: flex; gap: 8px; align-items: flex-start; }
.hitl-q svg { color: var(--ok); flex: none; margin-top: 1px; }
.hitl-choices { display: flex; flex-direction: column; gap: 8px; }
.hitl-choice { text-align: left; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: var(--radius-sm); padding: 10px 12px; cursor: pointer; }
.hitl-choice { text-align: left; border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); border-radius: var(--radius); padding: 10px 12px; cursor: pointer; transition: border-color .12s, background .12s; }
.hitl-choice:hover { border-color: var(--accent); background: var(--accent-soft); }
.hitl-choice .label { font-weight: 600; display: flex; gap: 8px; align-items: center; }
.hitl-choice .desc { color: var(--text-dim); font-size: 12.5px; margin-top: 3px; }
.hitl-choice .impact { color: var(--warn); font-size: 12px; margin-top: 3px; }
.hitl-choice .rec { font-size: 10.5px; color: var(--accent); border: 1px solid var(--accent); padding: 0 5px; border-radius: 8px; }
.hitl-choice .desc { color: var(--text-dim); font-size: var(--fs-ui-sm); margin-top: 3px; }
.hitl-choice .impact { color: var(--warn); font-size: var(--fs-ui-sm); margin-top: 3px; }
.hitl-choice .rec { font-size: var(--fs-ui-xs); color: var(--accent); border: 1px solid var(--accent); padding: 0 5px; border-radius: var(--radius-full); }
.hitl-free { display: flex; gap: 8px; margin-top: 10px; }
/* 输入区 */
.input-bar { border-top: 1px solid var(--border); padding: 12px 18px; background: var(--bg-raised); }
/* 输入区:悬浮卡片式输入壳(16px 大圆角 = DESIGN.md 特批例外) */
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
.attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-hover); border-radius: var(--radius-sm); padding: 3px 8px; font-size: 12px; }
.input-row { display: flex; gap: 8px; align-items: flex-end; }
.input-row textarea { flex: 1; resize: none; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); padding: 10px 12px; font-family: inherit; font-size: 14px; max-height: 160px; outline: none; }
.input-row textarea:focus { border-color: var(--accent); }
.btn { background: var(--accent); color: #fff; border: none; border-radius: var(--radius-sm); padding: 9px 16px; cursor: pointer; font-size: 13px; white-space: nowrap; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn.ghost { background: none; border: 1px solid var(--border); color: var(--text-dim); }
.btn.ghost:hover { color: var(--text); border-color: var(--text-faint); }
.btn.danger { background: var(--err); }
.icon-btn { background: none; border: 1px solid var(--border); color: var(--text-dim); border-radius: var(--radius-sm); padding: 8px 10px; cursor: pointer; font-size: 14px; }
.icon-btn:hover { color: var(--text); border-color: var(--text-faint); }
.cmd-menu { border: 1px solid var(--border); background: var(--bg-raised); border-radius: var(--radius-sm); margin-bottom: 6px; max-height: 180px; overflow-y: auto; }
.cmd-item { padding: 7px 12px; cursor: pointer; display: flex; gap: 10px; font-size: 13px; }
.cmd-item:hover, .cmd-item.active { background: var(--accent-soft); }
.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 3px 8px; font-size: var(--fs-ui-sm); }
.input-row { display: flex; gap: 8px; align-items: flex-end; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 8px 8px 8px 14px; box-shadow: var(--shadow-md); transition: border-color .12s, box-shadow .12s; }
.input-row:focus-within { border-color: var(--accent); box-shadow: var(--shadow-md), var(--ring); }
.input-row textarea { flex: 1; resize: none; background: transparent; border: none; color: var(--text); padding: 6px 0; font-family: inherit; font-size: var(--fs-ui-caption); max-height: 160px; outline: none; }
.input-row textarea::placeholder { color: var(--text-faint); }
.cmd-menu { border: 1px solid var(--border); background: var(--bg-raised); border-radius: var(--radius); margin-bottom: 6px; max-height: 180px; overflow-y: auto; box-shadow: var(--shadow-md); }
.cmd-item { padding: 7px 12px; cursor: pointer; display: flex; gap: 10px; font-size: var(--fs-ui-caption); }
.cmd-item:hover, .cmd-item.active { background: var(--bg-hover); }
.cmd-item .name { color: var(--accent); font-family: var(--mono); }
/* ---- 右栏 ---- */
.tabs { display: flex; border-bottom: 1px solid var(--border); overflow-x: auto; }
.tab { background: none; border: none; color: var(--text-dim); padding: 9px 12px; cursor: pointer; font-size: 12.5px; border-bottom: 2px solid transparent; white-space: nowrap; }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tabs { display: flex; border-bottom: 1px solid var(--border-soft); overflow-x: auto; padding: 0 8px; }
.tab { background: none; border: none; color: var(--text-faint); padding: 10px 10px; cursor: pointer; font-size: var(--fs-ui-sm); font-weight: 550; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; }
.tab:hover { color: var(--text-dim); }
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
.tab-body { flex: 1; overflow-y: auto; padding: 12px; }
.step { display: flex; gap: 8px; align-items: flex-start; padding: 6px 4px; font-size: 13px; }
.step { display: flex; gap: 8px; align-items: flex-start; padding: 6px 4px; font-size: var(--fs-ui-caption); }
.step-icon { width: 16px; text-align: center; flex-shrink: 0; }
.step.pending .step-icon { color: var(--text-faint); }
.step.in_progress .step-icon { color: var(--accent); }
.step.done .step-icon { color: var(--ok); }
.step.skipped { color: var(--text-faint); text-decoration: line-through; }
.artifact-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; }
/* ---- 工件面板(工作区文件管理)---- */
.artifacts-panel { display: flex; flex-direction: column; gap: 4px; }
.ap-toolbar { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.ap-toolbar .search-input { flex: 1; min-width: 120px; height: 30px; font-size: var(--fs-ui-sm); }
.kind-pill { border: 1px solid var(--border); background: none; color: var(--text-dim); border-radius: var(--radius-full); padding: 3px 10px; font-size: var(--fs-ui-sm); cursor: pointer; }
.kind-pill:hover { color: var(--text); background: var(--bg-hover); }
.kind-pill.on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.ap-upload { cursor: pointer; }
.artifact-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: var(--radius-sm); }
.artifact-row:hover { background: var(--bg-hover); }
.artifact-row .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.artifact-row .kind { font-size: 11px; color: var(--text-faint); }
.agent-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; }
.artifact-row .ar-icon { display: flex; color: var(--text-dim); cursor: pointer; flex: none; }
.artifact-row .ar-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; cursor: pointer; }
.artifact-row .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--fs-ui-caption); }
.artifact-row .meta { font-size: var(--fs-ui-xs); color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.artifact-row .ar-acts { display: none; align-items: center; gap: 2px; flex: none; }
.artifact-row:hover .ar-acts { display: inline-flex; }
.artifact-row .ar-acts .icon-btn { width: 26px; height: 26px; }
.artifact-row .ar-acts .icon-btn.danger { color: var(--err); }
.empty-note { color: var(--text-faint); font-size: var(--fs-ui-sm); padding: 14px 6px; }
.agent-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--fs-ui-caption); }
.agent-row:hover { background: var(--bg-hover); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.status-running { background: var(--accent); animation: pulse 1.2s infinite; }
.status-running { background: var(--accent); animation: mk-breathe 1.2s infinite; }
.status-completed { background: var(--ok); }
.status-failed, .status-cancelled, .status-interrupted { background: var(--err); }
.status-pending, .status-waiting_hitl { background: var(--warn); }
/* ---- 弹窗 / 模态 ---- */
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: flex; align-items: center; justify-content: center; z-index: 50; }
.modal { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 12px; width: min(860px, 92vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: 0 18px 60px rgba(0,0,0,.4); }
.modal-header { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; font-weight: 600; }
/* ---- 弹窗 / 模态(动画走 mk-* 类) ---- */
.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, .55); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 50; animation: mk-overlay-in .14s var(--ease); }
.modal { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xl); width: min(860px, 92vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: var(--shadow-lg); animation: mk-modal-in .16s var(--ease); }
.modal-header { padding: 16px 20px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; font-weight: 600; font-size: var(--fs-ui-base); letter-spacing: -0.01em; }
.modal-header .spacer { flex: 1; }
.modal-body { flex: 1; overflow-y: auto; padding: 16px 18px; }
.subagent-tree { font-size: 13px; }
.subagent-node { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; margin-bottom: 8px; }
.modal-body { flex: 1; overflow-y: auto; padding: 18px 20px; }
.subagent-tree { font-size: var(--fs-ui-caption); }
.subagent-node { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; margin-bottom: 8px; background: var(--bg-panel); }
.subagent-node .head { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
/* ---- 设置(schema 驱动)---- */
.settings-group { margin-bottom: 18px; }
.settings-group h4 { margin: 0 0 8px; font-size: 13px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; }
.setting-row { display: grid; grid-template-columns: 200px 1fr auto; gap: 10px; align-items: center; padding: 7px 0; border-bottom: 1px dashed var(--border); font-size: 13px; }
.setting-row label { color: var(--text-dim); }
.setting-row input, .setting-row select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 7px 10px; font-family: inherit; }
.effect-badge { font-size: 10.5px; padding: 2px 7px; border-radius: 8px; border: 1px solid var(--border); color: var(--text-faint); white-space: nowrap; }
/* ---- 设置(schema 驱动;行组件来自 @ui SettingRow,这里只补控件样式)---- */
.ui-setting-control { display: flex; align-items: center; justify-content: flex-end; }
.ui-setting-control input, .ui-setting-control select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 7px 10px; font-family: inherit; width: 100%; max-width: 340px; }
.ui-setting-control input:focus, .ui-setting-control select:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
.ui-setting-control input[type="checkbox"] { width: auto; }
.effect-badge { font-size: var(--fs-ui-xs); padding: 2px 7px; border-radius: var(--radius-full); border: 1px solid var(--border); color: var(--text-faint); white-space: nowrap; }
.effect-hot { color: var(--ok); border-color: var(--ok); }
.effect-new-run { color: var(--warn); border-color: var(--warn); }
/* UI schema 渲染器样式(.ui-*)与 .doc-viewer 已迁入 @ui/ui.css。 */
/* ---- 文档查看器(单层 OverlayHost 内)---- */
.docv { display: flex; flex-direction: column; min-height: 0; }
.docv-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 0 10px; border-bottom: 1px solid var(--border-soft); margin-bottom: 10px; }
.docv-toolbar .spacer { flex: 1; }
.docv-meta { font-size: var(--fs-ui-xs); color: var(--text-faint); text-transform: capitalize; }
.docv-dirty { color: var(--warn); font-size: var(--fs-ui-sm); }
.docv-dl { text-decoration: none; }
.docv-body { flex: 1; min-height: 0; overflow: auto; }
.docv-pdf { width: 100%; height: 100%; min-height: 60vh; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg); }
.docv-editor { width: 100%; height: 56vh; font-family: var(--mono); font-size: var(--fs-ui-sm); resize: vertical; }
.docv-pre { font-family: var(--mono); font-size: var(--fs-ui-sm); white-space: pre-wrap; word-break: break-word; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 12px 14px; }
/* ---- 杂项 ---- */
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: 20px; box-shadow: 0 8px 30px rgba(0,0,0,.35); z-index: 90; font-size: 13px; }
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: var(--radius-full); box-shadow: var(--shadow-lg); z-index: 90; font-size: var(--fs-ui-caption); }
.conn-dot { width: 8px; height: 8px; border-radius: 50%; }
.conn-dot.on { background: var(--ok); }
.conn-dot.off { background: var(--err); }
.doc-toolbar { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; }
.search-input { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 6px 10px; font-family: inherit; width: 100%; }
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 5px; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 6px; border: 3px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); background-clip: content-box; border: 3px solid transparent; }
::-webkit-scrollbar-track { background: transparent; }
/* ---- 登录门(API key 认证,PG 档默认开启) ---- */
.login-gate {
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
background: var(--bg, #14161a); z-index: 1000;
/* ---- 品牌标(侧栏 / 登录页 / 头像共用的星芒渐变底;辅色为品牌令牌 --brand-2) ---- */
.brand-mark { background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); display: inline-flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.22); }
/* ---- 左栏:任务中心(方案 A;Zai 复刻重造型) ---- */
.tsb-brand { display: flex; align-items: center; gap: 9px; padding: 14px 14px 8px; }
.tsb-logo { width: 24px; height: 24px; border-radius: var(--radius); background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
.tsb-name { font-weight: 650; font-size: var(--fs-ui-caption); letter-spacing: -0.01em; }
.tsb-iconbtn { width: 26px; height: 26px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: var(--fs-ui-caption); display: inline-flex; align-items: center; justify-content: center; position: relative; }
.tsb-iconbtn:hover { background: var(--bg-hover); color: var(--text); }
.tsb-iconbtn.active { color: var(--accent); background: var(--accent-soft); }
.tsb-actions { padding: 6px 12px 8px; display: flex; flex-direction: column; gap: 7px; }
.tsb-newtask { display: flex; height: 36px; border-radius: var(--radius); background: var(--primary); color: var(--primary-foreground); overflow: hidden; box-shadow: 0 1px 3px rgba(0, 0, 0, .3); transition: background .12s; }
.tsb-newtask:hover { background: color-mix(in srgb, var(--primary) 86%, var(--bg)); }
.tsb-newtask-main { flex: 1; display: flex; align-items: center; gap: 7px; padding: 0 11px; background: none; border: none; color: inherit; font-weight: 600; font-size: var(--fs-ui-caption); cursor: pointer; min-width: 0; }
.tsb-newtask-main svg { flex: none; }
.tsb-newtask-proj { font-weight: 450; font-size: var(--fs-ui-xs); opacity: .75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-newtask-caret { width: 30px; border: none; border-left: 1px solid color-mix(in srgb, var(--primary-foreground) 18%, transparent); background: none; color: inherit; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.tsb-newtask-caret:hover { background: color-mix(in srgb, var(--primary-foreground) 8%, transparent); }
.tsb-search { display: flex; align-items: center; gap: 7px; height: 32px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-faint); padding: 0 10px; transition: border-color .12s, box-shadow .12s; }
.tsb-search:focus-within { border-color: var(--accent); box-shadow: var(--ring); }
.tsb-search svg { flex: none; }
.tsb-search input { flex: 1; border: none; background: none; color: var(--text); font-size: var(--fs-ui-sm); outline: none; min-width: 0; }
.tsb-search input::placeholder { color: var(--text-faint); }
.tsb-orgrow { display: flex; align-items: center; gap: 6px; padding: 0 12px 8px; }
.tsb-pills { flex: 1; display: flex; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 2px; gap: 2px; }
.tsb-pill { flex: 1; height: 22px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: var(--fs-ui-sm); cursor: pointer; transition: all .12s; }
.tsb-pill:hover { color: var(--text-dim); }
.tsb-pill.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
.tsb-scroll { flex: 1; overflow-y: auto; padding: 0 10px 8px; min-height: 0; }
.tsb-seclabel { font-size: var(--fs-ui-xs); color: var(--text-faint); padding: 12px 8px 4px; letter-spacing: .05em; font-weight: 600; }
.tsb-empty { padding: 20px 14px; color: var(--text-faint); font-size: var(--fs-ui-sm); line-height: 1.9; text-align: center; }
.tsb-newproject { padding: 0 2px 8px; }
.tsb-group { margin-bottom: 2px; }
.tsb-group-head { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 6px 0 4px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 550; font-size: var(--fs-ui-caption); cursor: pointer; user-select: none; }
.tsb-group-head:hover { background: var(--bg-hover); }
.tsb-group.closed .tsb-chev { transform: rotate(0deg); }
.tsb-chev { display: inline-flex; color: var(--text-faint); transform: rotate(90deg); transition: transform .12s; }
.tsb-group-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-group-cnt { font-size: var(--fs-ui-xs); color: var(--text-faint); font-weight: 400; font-variant-numeric: tabular-nums; }
.tsb-group-add { visibility: hidden; width: 22px; height: 22px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
.tsb-group-head:hover .tsb-group-add { visibility: visible; }
.tsb-group-add:hover { background: var(--bg-active); color: var(--text); }
.tsb-task { display: flex; align-items: center; gap: 8px; height: 31px; padding: 0 8px; margin: 1px 0; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; position: relative; transition: background .1s, color .1s; }
.tsb-task:hover { background: var(--bg-hover); color: var(--text); }
.tsb-task.active { background: var(--bg-active); color: var(--text); }
.tsb-task.active::before { content: ''; position: absolute; left: -2px; top: 6px; bottom: 6px; width: 2.5px; border-radius: 2px; background: var(--accent); }
.tsb-task.archived .tsb-task-t { text-decoration: line-through; opacity: .6; }
.tsb-task-t { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--fs-ui-caption); }
.tsb-task-tm { font-size: var(--fs-ui-xs); color: var(--text-faint); font-variant-numeric: tabular-nums; }
.tsb-pbadge { font-size: var(--fs-ui-xs); color: var(--text-faint); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 0 5px; line-height: 1.5; white-space: nowrap; }
.tsb-row-acts { display: none; gap: 2px; }
.tsb-task:hover .tsb-row-acts { display: inline-flex; }
.tsb-task:hover .tsb-task-tm { display: none; }
.tsb-row-act { border: none; background: none; color: var(--text-faint); cursor: pointer; padding: 3px; border-radius: var(--radius-xs); display: inline-flex; }
.tsb-row-act:hover { background: var(--bg-active); color: var(--text); }
.tsb-footer { display: flex; align-items: center; gap: 4px; padding: 10px 12px; border-top: 1px solid var(--border-soft); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
.tsb-user { flex: 1; display: flex; align-items: center; gap: 9px; min-width: 0; height: 40px; padding: 0 7px; border: none; border-radius: var(--radius); background: none; cursor: pointer; text-align: left; transition: background .12s; }
.tsb-user:hover { background: var(--bg-hover); }
.tsb-avatar { width: 27px; height: 27px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.2); }
.tsb-user-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.tsb-user-name { font-size: var(--fs-ui-caption); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-user-sub { font-size: var(--fs-ui-xs); color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-user-chev { color: var(--text-faint); display: inline-flex; }
.tsb-menu { position: fixed; z-index: 300; min-width: 216px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius); box-shadow: var(--shadow-lg); padding: 5px; display: flex; flex-direction: column; animation: mk-modal-in .12s var(--ease); }
.tsb-mi { display: flex; align-items: center; gap: 9px; min-height: 31px; padding: 4px 9px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); font-size: var(--fs-ui-caption); cursor: pointer; text-align: left; }
.tsb-mi svg { color: var(--text-faint); flex: none; }
.tsb-mi:hover { background: var(--bg-hover); color: var(--text); }
.tsb-mi:hover svg { color: var(--text-dim); }
.tsb-mi.danger { color: var(--err); }
.tsb-mi.danger:hover { background: var(--err); color: var(--text-on-accent); }
.tsb-mi.danger:hover svg { color: var(--text-on-accent); }
.tsb-mi kbd { margin-left: auto; font-size: var(--fs-ui-xs); font-family: var(--mono); color: var(--text-faint); }
.tsb-menu-div { height: 1px; background: var(--border-soft); margin: 5px 4px; }
.tsb-acc-head { display: flex; gap: 10px; align-items: center; padding: 8px 9px 6px; }
.tsb-acc-name { font-size: var(--fs-ui-caption); font-weight: 600; }
.tsb-acc-mail { font-size: var(--fs-ui-xs); color: var(--text-faint); }
/* ---- 登录页与改密门(Zai 复刻:近单色深底 + 深海军品牌面;品牌面板固定深底浅字) ---- */
.lg-wrap { position: fixed; inset: 0; display: flex; align-items: stretch; background: var(--bg-inset); z-index: 500; }
.lg-panel { flex: none; width: 46%; max-width: 620px; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; padding: 64px; color: #dfe4f2; background: linear-gradient(160deg, #0e1116 0%, #101826 55%, #0b1f38 100%); }
.lg-panel::before { content: ''; position: absolute; width: 560px; height: 560px; border-radius: 50%; background: radial-gradient(closest-side, rgba(64,153,255,.16), transparent 70%); top: -140px; right: -160px; }
.lg-panel::after { content: ''; position: absolute; width: 440px; height: 440px; border-radius: 50%; background: radial-gradient(closest-side, rgba(123,92,229,.12), transparent 70%); bottom: -150px; left: -120px; }
.lg-panel h2 { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.35; margin: 0 0 12px; position: relative; }
.lg-panel p { font-size: var(--fs-ui-base); opacity: .72; line-height: 1.75; margin: 0; position: relative; }
.lg-panel ul { margin: 28px 0 0; list-style: none; display: flex; flex-direction: column; gap: 13px; position: relative; }
.lg-panel li { display: flex; gap: 10px; align-items: flex-start; font-size: var(--fs-ui-caption); opacity: .88; }
.lg-panel li svg { margin-top: 2px; color: var(--accent-strong); flex: none; }
.lg-panel .lg-foot { position: absolute; bottom: 28px; left: 64px; right: 64px; display: flex; justify-content: space-between; font-size: var(--fs-ui-xs); opacity: .45; }
@media (max-width: 960px) { .lg-panel { display: none; } }
.lg-formside { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; min-width: 0; }
.lg-card { width: 392px; max-width: 100%; }
.lg-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: var(--fs-ui-base); letter-spacing: -0.01em; margin-bottom: 26px; }
.lg-title { margin: 0 0 4px; font-size: var(--fs-ui-xl); font-weight: 700; letter-spacing: -0.02em; }
.lg-sub { margin: 0 0 18px; font-size: var(--fs-ui-sm); color: var(--text-faint); line-height: 1.6; }
.lg-tabs { display: flex; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 3px; gap: 3px; margin-bottom: 16px; }
.lg-tab { flex: 1; height: 30px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: var(--fs-ui-sm); cursor: pointer; transition: all .12s; }
.lg-tab:hover { color: var(--text-dim); }
.lg-tab.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
.lg-label { display: block; font-size: var(--fs-ui-sm); font-weight: 550; color: var(--text-dim); margin: 12px 0 6px; }
.lg-input { width: 100%; height: 38px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); padding: 0 12px; font-size: var(--fs-ui-caption); transition: border-color .12s, box-shadow .12s; }
.lg-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
.lg-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
.lg-err { margin-top: 12px; font-size: var(--fs-ui-sm); color: var(--err); background: var(--err-soft); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
.lg-err svg { flex: none; margin-top: 1px; }
.lg-submit {
width: 100%; height: 38px; margin-top: 16px; border: none; border-radius: var(--radius);
background: var(--primary); color: var(--primary-foreground); font-weight: 600; font-size: var(--fs-ui-caption); cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, .3);
transition: background .12s, transform .12s;
}
.login-card {
width: 360px; padding: 28px; border-radius: 12px; text-align: center;
background: #1d2026; border: 1px solid #2c313a; box-shadow: 0 8px 32px rgba(0,0,0,.45);
.lg-submit:disabled { opacity: .45; cursor: default; box-shadow: none; }
.lg-submit:not(:disabled):hover { background: color-mix(in srgb, var(--primary) 86%, var(--bg)); }
.lg-submit:not(:disabled):active { transform: translateY(1px); }
.lg-cancel { width: 100%; margin-top: 8px; border: none; background: none; color: var(--text-faint); font-size: var(--fs-ui-sm); cursor: pointer; }
.lg-cancel:hover { color: var(--text-dim); text-decoration: underline; }
.lg-hint { margin: 14px 0 0; font-size: var(--fs-ui-xs); color: var(--text-faint); line-height: 1.7; }
.lg-hint code { font-family: var(--mono); font-size: var(--fs-ui-xs); background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 1px 5px; }
/* ---- 系统管理(AdminZone,重设计方案 阶段②):管理员专属全屏视图 ---- */
.az { position: fixed; inset: 4px; z-index: 70; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; animation: mk-modal-in .16s var(--ease); }
.az-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border-soft); background: var(--bg-panel); }
.az-title { font-weight: 650; font-size: var(--fs-ui-base); letter-spacing: -0.01em; }
.az-sub { font-size: var(--fs-ui-xs); color: var(--text-faint); }
.az-tenant { font-size: var(--fs-ui-xs); color: var(--text-faint); font-family: var(--mono); border: 1px solid var(--border); border-radius: var(--radius-full); padding: 2px 10px; }
.az-body { flex: 1; min-height: 0; display: flex; }
.az-side { width: 220px; flex: none; border-right: 1px solid var(--border-soft); background: var(--bg-panel); padding: 10px 8px; display: flex; flex-direction: column; gap: 2px; }
.az-nav-item { display: flex; align-items: center; gap: 9px; padding: 9px 11px; border: none; background: none; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); cursor: pointer; text-align: left; }
.az-nav-item:hover { background: var(--bg-hover); color: var(--text); }
.az-nav-item.on { background: var(--bg-active); color: var(--text); font-weight: 550; }
.az-nav-item.on svg { color: var(--accent); }
.az-main { flex: 1; min-width: 0; overflow-y: auto; padding: 20px 24px; }
.az-section-head { margin-bottom: 14px; }
.az-section-head h3 { margin: 0 0 4px; font-size: var(--fs-ui-lg); font-weight: 600; letter-spacing: -0.01em; }
.az-section-head p { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-faint); }
/* ---- 用户管理 / 改密弹层 ---- */
.ad-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; }
.ad-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); }
.ad-row { display: flex; gap: 8px; align-items: center; }
.ad-row .lg-input { flex: 1; min-width: 0; height: 34px; }
.ad-check { display: flex; align-items: center; gap: 5px; font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; }
.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; }
.ad-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
.ad-mini:hover { color: var(--text); border-color: var(--accent); }
.ad-hint { margin: 0; font-size: var(--fs-ui-xs); color: var(--text-faint); }
.ad-notice { font-size: var(--fs-ui-sm); color: var(--ok); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
.ad-notice svg { flex: none; margin-top: 1px; }
.ad-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
.ad-table th { text-align: left; font-weight: 600; color: var(--text-faint); font-size: var(--fs-ui-xs); text-transform: uppercase; letter-spacing: .06em; padding: 8px; border-bottom: 1px solid var(--border); }
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
.ad-table tr:last-child td { border-bottom: none; }
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
.ad-dim { color: var(--text-faint); }
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-faint); }
.ad-chip-admin { background: var(--accent-soft); color: var(--accent); }
.ad-chip-warn { background: var(--warn-soft); color: var(--warn); }
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
/* ---- 收起轨道(左栏收起态) ---- */
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; }
.icon-only button { background: none; border: none; color: var(--text-faint); cursor: pointer; padding: 0; width: 34px; height: 34px; border-radius: var(--radius); display: inline-flex; align-items: center; justify-content: center; position: relative; }
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
.rail-badge { position: absolute; top: 3px; right: 3px; min-width: 14px; height: 14px; border-radius: 7px; background: var(--warn); color: var(--text-on-accent); font-size: var(--fs-ui-xs); font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; }
/* ---- UI 能力平台消费(消息头像列 / 设置外壳 / 管理弹层头像) ---- */
.role-mark { width: 20px; height: 20px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
.role-mark.role-assistant { background: none; border: none; padding: 0; }
.msg.user .msg-role { align-items: center; }
.ad-user-cell { display: flex; align-items: center; gap: 8px; }
/* ---- 右栏轻预览(裁定 D4:右栏 + 弹窗双落位)---- */
.rail-preview-head { display: flex; align-items: center; gap: 6px; padding: 0 2px 8px; border-bottom: 1px solid var(--border-soft); margin-bottom: 8px; }
.rail-preview-head .rp-name { font-size: var(--fs-ui-caption); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 55%; }
.tab-body[data-rail-preview] { display: flex; flex-direction: column; }
.tab-body[data-rail-preview] .ui-fp { flex: 1; }
.artifact-chip .ac-act { border: none; background: none; }
/* 项目收件箱(CF3/D51):会话/项目范围分段控件(令牌对齐 DESIGN.md,阶段④合入主线时归一) */
.inbox-scope {
display: flex; gap: 4px; padding: 6px 14px 2px;
}
.login-card h2 { margin: 0 0 8px; }
.login-hint { font-size: 12px; color: #9aa3b2; margin-bottom: 16px; line-height: 1.6; }
.login-card input {
width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 8px;
border: 1px solid #2c313a; background: #14161a; color: inherit; font-size: 14px;
.inbox-scope button {
border: 1px solid var(--border); background: transparent; color: inherit;
border-radius: var(--radius-full); padding: 2px 12px; font-size: var(--fs-ui-sm); cursor: pointer; opacity: .72;
}
.login-card button {
margin-top: 12px; width: 100%; padding: 9px 0; border: none; border-radius: 8px;
background: #3b82f6; color: #fff; font-size: 14px; cursor: pointer;
}
.login-err { margin-top: 10px; font-size: 12px; color: #f87171; }
.inbox-scope button.active { opacity: 1; font-weight: 600; background: var(--bg-hover); }
+82
View File
@@ -0,0 +1,82 @@
// 任务列表组织(方案 A,提案 2026-09-30):纯函数,供 TaskSidebar 与测试消费。
// 组织是本端视图偏好(P8),不改数据模型——数据源恒为跨项目 tasks(updated_at 倒序)。
import type { Conversation, Project } from './contracts'
export type TaskOrg = 'project' | 'timeline'
export interface TaskGroup {
key: string
label: string
/** 项目分组时携带(组头「在此项目新建」入口与计数)。 */
projectId?: string
tasks: Conversation[]
}
/** 按项目分组:项目按服务端序(更新倒序),组内任务按列表序(置顶已在服务端排首)。 */
export function groupByProject(tasks: Conversation[], projects: Project[]): TaskGroup[] {
const byProject = new Map<string, Conversation[]>()
for (const t of tasks) {
const list = byProject.get(t.projectId) ?? []
list.push(t)
byProject.set(t.projectId, list)
}
const groups: TaskGroup[] = []
for (const p of projects) {
const list = byProject.get(p.id)
if (!list || list.length === 0) continue
groups.push({ key: `prj:${p.id}`, label: p.name, projectId: p.id, tasks: list })
byProject.delete(p.id)
}
// 项目已删但会话仍在(竞态残留):收进"未分组",不静默丢弃。
for (const [pid, list] of byProject) {
groups.push({ key: `prj:${pid}`, label: '未分组', projectId: pid, tasks: list })
}
return groups
}
/** 按时间线分组:今天 / 昨天 / 本周更早 / 更早(按 updatedAt 本地时区)。 */
export function groupByTimeline(tasks: Conversation[], now = Date.now()): TaskGroup[] {
const startOfDay = (ts: number) => { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime() }
const today0 = startOfDay(now)
const dayMs = 86_400_000
const buckets: TaskGroup[] = [
{ key: 'today', label: '今天', tasks: [] },
{ key: 'yesterday', label: '昨天', tasks: [] },
{ key: 'week', label: '本周更早', tasks: [] },
{ key: 'older', label: '更早', tasks: [] },
]
const weekday = (new Date(now).getDay() + 6) % 7 // 周一=0
const weekStart = today0 - weekday * dayMs
for (const t of tasks) {
const ts = Date.parse(t.updatedAt)
if (Number.isNaN(ts)) { buckets[3].tasks.push(t); continue }
if (ts >= today0) buckets[0].tasks.push(t)
else if (ts >= today0 - dayMs) buckets[1].tasks.push(t)
else if (ts >= weekStart) buckets[2].tasks.push(t)
else buckets[3].tasks.push(t)
}
return buckets.filter(g => g.tasks.length > 0)
}
/** 相对时间(侧栏行尾):刚刚 / N 分钟 / N 小时 / 昨天 / N 天 / 月-日。 */
export function relativeTime(iso: string, now = Date.now()): string {
const ts = Date.parse(iso)
if (Number.isNaN(ts)) return ''
const diff = now - ts
if (diff < 60_000) return '刚刚'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时`
if (diff < 172_800_000) return '昨天'
if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天`
const d = new Date(ts)
const nowD = new Date(now)
const md = `${d.getMonth() + 1}/${d.getDate()}`
return d.getFullYear() === nowD.getFullYear() ? md : `${d.getFullYear()}/${md}`
}
/** 标题过滤(不区分大小写;空串返回原列表)。 */
export function filterTasks(tasks: Conversation[], q: string): Conversation[] {
const needle = q.trim().toLowerCase()
if (!needle) return tasks
return tasks.filter(t => t.title.toLowerCase().includes(needle))
}
+88
View File
@@ -0,0 +1,88 @@
// 设计系统守护测试(父仓 DESIGN.md §11 禁止清单 · 宿主侧,2026-09-30)。
// 棘轮基线:现存豁免登记在下方;新增同类违规直接失败。收紧 = 修掉违规后删基线条目。
// 范围:src/styles.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'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url)) // test/
const FE_ROOT = join(HERE, '..')
const SRC = join(FE_ROOT, 'src')
const DEV_ROOT = join(FE_ROOT, '..', '..') // Dev 父仓根(DESIGN.md;单独检出本仓时不存在)
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
if (statSync(p).isDirectory()) walk(p, out)
else out.push(p)
}
return out
}
const read = (p: string) => readFileSync(p, 'utf8')
const stripCssComments = (t: string) => t.replace(/\/\*[\s\S]*?\*\//g, '')
const collectHex = (t: string) => [...t.matchAll(/#[0-9a-fA-F]{3,8}\b/g)].map(m => m[0].toLowerCase())
// ---- 1. 宪法在位(组合检出时) ----
describe('DESIGN.md 宪法在位', () => {
it('DESIGN.md 存在且 AGENTS.md 带强制阅读门禁', () => {
const designPath = join(DEV_ROOT, 'DESIGN.md')
if (!existsSync(designPath)) { console.warn('(单独检出 Frontend 仓:跳过宪法检查)'); return }
expect(read(designPath)).toContain('Agent-System-Dev 设计系统')
const agents = read(join(DEV_ROOT, 'AGENTS.md'))
expect(agents).toContain('DESIGN.md')
})
})
// ---- 2. 动效唯一所有者 ----
describe('动效唯一所有者', () => {
it('styles.css 无 @keyframes(全部走 @ui/motion.css 的 mk-* 类)', () => {
expect(stripCssComments(read(join(SRC, 'styles.css')))).not.toContain('@keyframes')
})
})
// ---- 3. 裸色值棘轮 ----
const HEX_BASELINE_STYLES = [
'#0e1116', '#101826', '#0b1f38', // 登录品牌面板固定深底渐变(面板自带深底,主题无关)
'#dfe4f2', // 同上面板的固定浅字
] as const
describe('裸色值棘轮', () => {
it('styles.css 不引入基线外裸色值', () => {
const found = collectHex(read(join(SRC, 'styles.css')))
const allow = new Set<string>(HEX_BASELINE_STYLES)
const extra = found.filter(h => !allow.has(h))
expect(extra, `新增裸色值 ${[...new Set(extra)].join(', ')}——改用 tokens 令牌;确需豁免更新 HEX_BASELINE_STYLES 并写明理由`).toEqual([])
})
it('组件 TSX 无裸色值', () => {
const extra: string[] = []
for (const p of walk(SRC).filter(p => /\.(tsx|ts)$/.test(p) && !p.endsWith('.d.ts'))) {
for (const h of collectHex(read(p))) extra.push(`${relative(SRC, p)}: ${h}`)
}
expect(extra, '组件里出现裸色值——走 tokens 令牌').toEqual([])
})
})
// ---- 4. 行内字号棘轮(--fs-ui-* 标尺) ----
const FONTSIZE_LITERAL_BASELINE = ['40'] as const // Conversation 空态装饰字形(⌘ 展示字号,非 UI 层级)
describe('行内字号棘轮', () => {
it('组件 TSX 无基线外 fontSize 数值字面量', () => {
const allow = new Set<string>(FONTSIZE_LITERAL_BASELINE)
const extra: string[] = []
for (const p of walk(SRC).filter(p => p.endsWith('.tsx'))) {
const lines = read(p).split('\n')
lines.forEach((l: string, i: number) => {
const m = l.match(/fontSize:\s*['"`]?([\d.]+)/)
if (m && !allow.has(m[1])) extra.push(`${relative(SRC, p)}:${i + 1} (${m[1]})`)
})
}
expect(extra, '行内 fontSize 数值——改用 var(--fs-ui-*);确需豁免更新基线并写明理由').toEqual([])
})
it('组件 TSX 无 <details> 折叠(走 @ui Collapsible,DESIGN.md §11.7)', () => {
const extra: string[] = []
for (const p of walk(SRC).filter(p => p.endsWith('.tsx'))) {
const code = read(p).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
if (/<details[\s>]/.test(code)) extra.push(relative(SRC, p))
}
expect(extra, '<details> 折叠——改用 @ui Collapsible').toEqual([])
})
})
+150
View File
@@ -0,0 +1,150 @@
// live 流式投影纯逻辑测试(中栏过程/回复分离):到达序并段、process 标记时机、
// 轮次穿插同构、过程区折叠谓词。
import { describe, it, expect } from 'vitest'
import {
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated,
liveToMessage, showProcessInline, type LiveItem,
} from '../src/live'
import type { LiveState } from '../src/store'
function live(over: Partial<LiveState> = {}): LiveState {
return { runId: 'r1', phase: 'thinking', items: [], agentNotes: {}, startedAt: 0, ...over }
}
// 两轮流式事件回放:思考→解说文本→工具→思考→最终回复(与后端事件序一致)。
function twoTurnItems(): LiveItem[] {
let items: LiveItem[] = []
items = applyThinkingDelta(items, '先想想')
items = applyThinkingDelta(items, '一下')
items = applyTextDelta(items, 0, '我先更新待办。')
items = applyToolCall(items, 'update_todos', 'c1')
items = applyToolResult(items, 'c1', 'ok', '已更新')
items = applyThinkingDelta(items, '再想想')
items = applyTextDelta(items, 1, '最终')
items = applyTextDelta(items, 1, '回复')
return items
}
describe('applyThinkingDelta / applyTextDelta', () => {
it('merges thinking deltas into the last thinking segment', () => {
let items: LiveItem[] = []
items = applyThinkingDelta(items, '先')
items = applyThinkingDelta(items, '想想')
expect(items).toEqual([{ kind: 'thinking', text: '先想想' }])
})
it('opens a new thinking segment after text/tool (turn boundary)', () => {
let items: LiveItem[] = applyThinkingDelta([], '第一轮思考')
items = applyTextDelta(items, 0, '解说')
items = applyThinkingDelta(items, '第二轮思考')
expect(items).toEqual([
{ kind: 'thinking', text: '第一轮思考' },
{ kind: 'text', index: 0, text: '解说' },
{ kind: 'thinking', text: '第二轮思考' },
])
})
it('appends text to the last segment when index matches', () => {
let items: LiveItem[] = []
items = applyTextDelta(items, 0, '先查')
items = applyTextDelta(items, 0, '一下')
expect(items).toEqual([{ kind: 'text', index: 0, text: '先查一下' }])
})
it('opens a new text segment when index advances (new turn)', () => {
let items: LiveItem[] = applyTextDelta([], 0, '解说')
items = applyTextDelta(items, 1, '最终')
items = applyTextDelta(items, 1, '回复')
expect(items).toEqual([
{ kind: 'text', index: 0, text: '解说' },
{ kind: 'text', index: 1, text: '最终回复' },
])
})
it('fills the most recent pending tool call (callId match, tail fallback)', () => {
let items: LiveItem[] = []
items = applyToolCall(items, 'a', 'ca')
items = applyToolCall(items, 'b', 'cb')
items = applyToolResult(items, 'cb', 'ok', '结果')
expect(items).toEqual([
{ kind: 'tool', callId: 'ca', name: 'a' },
{ kind: 'tool', callId: 'cb', name: 'b', status: 'ok', summary: '结果' },
])
// 无 callId 的事件回落到尾部配对。
items = applyToolResult(items, undefined, 'ok')
expect(items[0]).toEqual({ kind: 'tool', callId: 'ca', name: 'a', status: 'ok' })
})
})
describe('liveToMessage', () => {
it('interleaves parts per turn in arrival order, matching persisted parts', () => {
const msg = liveToMessage(live({ items: twoTurnItems() }))
expect(msg.parts).toEqual([
{ type: 'thinking', text: '先想想一下', process: true },
{ type: 'text', text: '我先更新待办。', process: true },
{ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'update_todos' } },
{ type: 'tool_result', process: true, toolCall: { id: 'c1', name: 'update_todos' },
toolResult: { callId: 'c1', status: 'ok', summary: '已更新' } },
{ type: 'thinking', text: '再想想', process: true },
{ type: 'text', text: '最终回复' },
])
})
it('marks the in-flight segment as process the moment its tool call arrives', () => {
let items: LiveItem[] = []
items = applyTextDelta(items, 0, '我先查一下')
const before = liveToMessage(live({ items }))
expect(before.parts.map(p => p.process)).toEqual([undefined]) // 尚无工具:仍在回复位
items = applyToolCall(items, 'web_search')
const after = liveToMessage(live({ items }))
expect(after.parts.map(p => p.process)).toEqual([true, true]) // tool.call 一到即改标
})
it('single final segment without tools carries no process flag', () => {
let items: LiveItem[] = []
items = applyTextDelta(items, 0, '直接回答')
const msg = liveToMessage(live({ items }))
expect(msg.parts.map(p => p.process)).toEqual([undefined])
})
it('tools between segments keep every non-final segment in the process area', () => {
let items: LiveItem[] = []
items = applyTextDelta(items, 0, '第一轮')
items = applyToolCall(items, 'a')
items = applyToolResult(items, undefined, 'ok')
items = applyTextDelta(items, 1, '第二轮')
items = applyToolCall(items, 'b')
items = applyTextDelta(items, 2, '最终回复')
const msg = liveToMessage(live({ items }))
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, true, undefined])
// 顺序必须与落库穿插序一致:text → tool → text → tool → text。
expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'tool_call', 'text'])
})
it('thinking and tool parts are always process', () => {
let items: LiveItem[] = []
items = applyThinkingDelta(items, '想')
items = applyTextDelta(items, 0, '答')
items = applyToolCall(items, 'web_search')
items = applyToolResult(items, undefined, 'ok', '命中')
const msg = liveToMessage(live({ items }))
for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true)
})
it('deliverable cards are content parts pinned after the reply text', () => {
// 与落库序同构(llm.go 交付物钉尾):artifact_ref 在终轮正文之后、不折叠。
let items: LiveItem[] = []
items = applyTextDelta(items, 0, '生成报表。')
items = applyToolCall(items, 'make_report', 'c1')
items = applyToolResult(items, 'c1', 'ok')
items = applyArtifactCreated(items, { artifactId: 'a1', name: 'report.csv', kind: 'csv' })
items = applyTextDelta(items, 1, '完成。')
const msg = liveToMessage(live({ items }))
expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'artifact_ref'])
expect(msg.parts.find(p => p.type === 'artifact_ref')?.process).toBeFalsy() // 内容部件,不折叠
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined])
})
})
describe('showProcessInline', () => {
it('expands during thinking/acting and collapses the moment responding starts', () => {
expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(true)
expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(true)
expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(false)
})
it('falls back to the auto-collapse preference when no live run', () => {
expect(showProcessInline(null, true)).toBe(false)
expect(showProcessInline(null, false)).toBe(true)
})
})
+76
View File
@@ -0,0 +1,76 @@
// 消息部件顺序保真纯函数测试(渲染顺序 = 落库顺序)。
import { describe, it, expect } from 'vitest'
import { groupParts, humanSize, artifactIcon } from '../src/messageparts'
import type { Part } from '../src/contracts'
function p(partial: Partial<Part> & { type: Part['type'] }): Part {
return partial as Part
}
describe('groupParts(顺序保真)', () => {
it('interleaves tool blocks at their original positions', () => {
// 后端 llm.go 的真实落库序(交付物钉尾):text(过程解说) → tool → tool_result → text(终轮回复) → artifact_ref
const parts: Part[] = [
p({ type: 'text', text: '先找资料', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }),
p({ type: 'text', text: '文档已生成,请查收' }),
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
]
const blocks = groupParts(parts)
expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'content'])
expect((blocks[0] as { parts: Part[] }).parts.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_result'])
expect((blocks[1] as { part: Part }).part.text).toBe('文档已生成,请查收')
expect((blocks[2] as { part: Part }).part.type).toBe('artifact_ref')
})
it('renders deliverable cards as content in legacy inline order too', () => {
// 旧落库序(artifact_ref 夹在过程与正文之间):仍是内容部件,原位渲染不折叠。
const parts: Part[] = [
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
p({ type: 'text', text: '文档已生成' }),
]
const blocks = groupParts(parts)
expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'content'])
expect((blocks[1] as { part: Part }).part.type).toBe('artifact_ref')
})
it('merges only consecutive process parts into one block', () => {
const parts: Part[] = [
p({ type: 'thinking', process: true, text: '思考' }),
p({ type: 'text', text: '第一段' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'a' } }),
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }),
p({ type: 'text', text: '第二段' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'b' } }),
]
const blocks = groupParts(parts)
expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'process', 'content', 'process'])
expect((blocks[2] as { parts: Part[] }).parts).toHaveLength(2)
expect((blocks[4] as { parts: Part[] }).parts).toHaveLength(1)
})
it('handles all-process and all-content messages', () => {
expect(groupParts([p({ type: 'thinking', process: true, text: 'x' }), p({ type: 'tool_call', process: true, toolCall: { id: '', name: '' } })]))
.toEqual([{ kind: 'process', parts: [expect.anything(), expect.anything()] }])
expect(groupParts([p({ type: 'text', text: 'hi' })]).map(b => b.kind)).toEqual(['content'])
expect(groupParts([])).toEqual([])
})
})
describe('humanSize / artifactIcon', () => {
it('formats sizes compactly', () => {
expect(humanSize(undefined)).toBe('')
expect(humanSize(0)).toBe('')
expect(humanSize(512)).toBe('512 B')
expect(humanSize(2048)).toBe('2 KB')
expect(humanSize(1536 * 1024)).toBe('1.5 MB')
})
it('picks icons by extension then kind', () => {
expect(artifactIcon('会议纪要.docx')).toBe('file')
expect(artifactIcon('截图.png')).toBe('image')
expect(artifactIcon('blob', 'image')).toBe('image')
expect(artifactIcon('打包.zip')).toBe('package')
})
})
+71
View File
@@ -0,0 +1,71 @@
// 任务列表组织纯函数测试(方案 A R1)。
import { describe, it, expect } from 'vitest'
import { groupByProject, groupByTimeline, relativeTime, filterTasks } from '../src/tasklist'
import type { Conversation, Project } from '../src/contracts'
const NOW = new Date('2026-09-30T15:00:00').getTime()
const H = 3_600_000
function conv(id: string, projectId: string, updatedAt: string, extra: Partial<Conversation> = {}): Conversation {
return { id, projectId, title: id, createdAt: updatedAt, updatedAt, ...extra }
}
describe('groupByProject', () => {
it('groups tasks under projects in project order and never drops orphans', () => {
const projects: Project[] = [
{ id: 'p2', name: '乙', createdAt: '', updatedAt: '' },
{ id: 'p1', name: '甲', createdAt: '', updatedAt: '' },
]
const tasks = [conv('a', 'p1', '2026-09-30T10:00:00Z'), conv('b', 'p2', '2026-09-29T10:00:00Z'), conv('c', 'pX', '2026-09-28T10:00:00Z')]
const groups = groupByProject(tasks, projects)
expect(groups.map(g => g.label)).toEqual(['乙', '甲', '未分组'])
expect(groups[0].projectId).toBe('p2')
expect(groups[2].tasks.map(t => t.id)).toEqual(['c'])
})
it('skips projects with zero visible tasks', () => {
const projects: Project[] = [{ id: 'p1', name: '甲', createdAt: '', updatedAt: '' }]
expect(groupByProject([], projects)).toEqual([])
})
})
describe('groupByTimeline', () => {
it('buckets by local day and week boundaries', () => {
// NOW=周三 15:00(本地);构造今天/昨天/本周更早(周一)/更早 各一条。
const tasks = [
conv('today', 'p', new Date(NOW - H).toISOString()),
conv('yesterday', 'p', new Date(NOW - 26 * H).toISOString()),
conv('week', 'p', new Date(NOW - 50 * H).toISOString()),
conv('older', 'p', new Date(NOW - 10 * 24 * H).toISOString()),
]
const groups = groupByTimeline(tasks, NOW)
expect(groups.map(g => g.label)).toEqual(['今天', '昨天', '本周更早', '更早'])
expect(groups[0].tasks[0].id).toBe('today')
})
it('hides empty buckets and tolerates bad timestamps', () => {
const groups = groupByTimeline([conv('x', 'p', 'not-a-date')], NOW)
expect(groups).toHaveLength(1)
expect(groups[0].label).toBe('更早')
})
})
describe('relativeTime', () => {
it('formats gradients and year rollover', () => {
expect(relativeTime(new Date(NOW - 30_000).toISOString(), NOW)).toBe('刚刚')
expect(relativeTime(new Date(NOW - 5 * 60_000).toISOString(), NOW)).toBe('5 分钟')
expect(relativeTime(new Date(NOW - 3 * H).toISOString(), NOW)).toBe('3 小时')
expect(relativeTime(new Date(NOW - 30 * H).toISOString(), NOW)).toBe('昨天')
expect(relativeTime(new Date(NOW - 3 * 24 * H).toISOString(), NOW)).toBe('3 天')
expect(relativeTime('2026-05-01T00:00:00', NOW)).toBe('5/1')
expect(relativeTime('2025-12-31T00:00:00', NOW)).toBe('2025/12/31')
expect(relativeTime('bad')).toBe('')
})
})
describe('filterTasks', () => {
it('matches title case-insensitively and passes through on empty query', () => {
const tasks = [conv('a', 'p', '', { title: '周报 Draft' }), conv('b', 'p', '', { title: '接口评审' })]
expect(filterTasks(tasks, '周报')).toEqual([tasks[0]])
expect(filterTasks(tasks, ' ')).toEqual(tasks)
expect(filterTasks(tasks, 'DRAFT')).toEqual([tasks[0]])
})
})
+5 -2
View File
@@ -12,11 +12,14 @@
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"],
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": {
"@ui": ["../ui/src/index.ts"],
"@ui/*": ["../ui/src/*"]
"@ui/*": ["../ui/src/*"],
"react": ["./node_modules/@types/react"],
"react-dom": ["./node_modules/@types/react-dom"],
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"]
}
},
"include": ["src", "test"]
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/store.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/ProjectNav.tsx","./src/components/SettingsModal.tsx","./src/components/Workbench.tsx","./test/auth.test.ts","./test/contracts.test.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/passwords.ts","./src/previewsetup.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AdminZone.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./src/components/adminSections.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/design-guard.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}