Compare commits

...
Author SHA1 Message Date
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
25 changed files with 1606 additions and 588 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` 主机条目,克隆/推送直接可用。
+127 -205
View File
@@ -18,7 +18,7 @@
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vitest": "^3.0.5"
"vitest": "^4.1.11"
}
},
"node_modules/@babel/code-frame": {
@@ -1211,6 +1211,13 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1323,39 +1330,40 @@
}
},
"node_modules/@vitest/expect": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.7",
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
@@ -1367,42 +1375,42 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.7",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.7",
"magic-string": "^0.30.17",
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
@@ -1410,28 +1418,25 @@
}
},
"node_modules/@vitest/spy": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.7",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -1494,16 +1499,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001813",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001813.tgz",
@@ -1526,32 +1521,15 @@
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1584,16 +1562,6 @@
}
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.440",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.440.tgz",
@@ -1602,9 +1570,9 @@
"license": "ISC"
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
@@ -1756,13 +1724,6 @@
"node": ">=6"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -1831,6 +1792,20 @@
"node": ">=18"
}
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -1838,16 +1813,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2015,29 +1980,9 @@
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/strip-literal/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
"dev": true,
"license": "MIT"
},
@@ -2049,11 +1994,14 @@
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
@@ -2072,30 +2020,10 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz",
"integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
"integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2222,89 +2150,80 @@
}
}
},
"node_modules/vite-node": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.4.1",
"es-module-lexer": "^1.7.0",
"pathe": "^2.0.3",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.7",
"@vitest/mocker": "3.2.7",
"@vitest/pretty-format": "^3.2.7",
"@vitest/runner": "3.2.7",
"@vitest/snapshot": "3.2.7",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.2",
"std-env": "^3.9.0",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.14",
"tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
"vite-node": "3.2.4",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.7",
"@vitest/ui": "3.2.7",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*"
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/debug": {
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
@@ -2315,6 +2234,9 @@
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
+1 -1
View File
@@ -20,6 +20,6 @@
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vitest": "^3.0.5"
"vitest": "^4.1.11"
}
}
+32 -36
View File
@@ -1,5 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { auth } from './auth'
import { api } from './api'
import { LoginPage, ChangePasswordGate } from './components/LoginPage'
import { AdminUsersModal, ChangePasswordModal } from './components/AdminUsersModal'
import { Icon } from '@ui'
import { store, useStore } from './store'
import { TaskSidebar } from './components/TaskSidebar'
import { Conversation } from './components/Conversation'
@@ -7,39 +11,13 @@ 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)
@@ -52,14 +30,19 @@ export function App() {
const trackerRunId = useStore(s => s.ui.trackerRunId)
const viewingArtifact = useStore(s => s.ui.viewingArtifact)
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
// authState:checking → ok(放行)| required(登录门)。
const [authState, setAuthState] = useState<'checking' | 'required' | 'ok'>('checking')
// 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') // 网络异常不是认证问题;放行交给业务层报错
}
@@ -103,9 +86,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 (
@@ -120,12 +113,12 @@ export function App() {
) : (
<div className="left-nav collapsed" style={{ width: 52 }}>
<div className="icon-only">
<button title="展开侧栏(⌘B)" onClick={store.toggleLeft}>☰</button>
<button title="新建任务(⌘N)" onClick={() => store.createTask()}>✚</button>
<button title="收件箱" onClick={() => store.openInbox()}>◍
<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)}>⚙</button>
<button title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" /></button>
</div>
</div>
)}
@@ -154,6 +147,9 @@ export function App() {
<Workspaces />
{trackerRunId && <AgentTracker />}
{ui.settingsOpen && <SettingsModal />}
{ui.projectSettingsId && <ProjectSettingsModal />}
{ui.adminUsersOpen && <AdminUsersModal onClose={() => store.setUI({ adminUsersOpen: false })} />}
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
{toast && <div className="toast">{toast}</div>}
</div>
+21 -2
View File
@@ -26,8 +26,21 @@ export const api = {
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 }) => 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 }>(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)),
@@ -65,7 +78,13 @@ 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>,
+162
View File
@@ -0,0 +1,162 @@
// 管理员用户管理弹层 + 主动改密弹窗(R2 管理员发放制)。
// AdminUsersModal:建号(用户名/显示名/初始密码/管理员位)、重置密码、列表状态。
// ChangePasswordModal:登录者主动改密(与强制改密共用 API)。
import { useEffect, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { Icon, Avatar } from '@ui'
import { randomPassword } from '../passwords'
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
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 AdminUsersModal({ onClose }: { onClose: () => void }) {
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 (
<Shell title={`用户管理${me?.isAdmin ? '' : '(只读)'}`} onClose={onClose}>
{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"><Avatar name={u.name || u.owner} size="sm" /><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>
)}
</Shell>
)
}
/** 主动改密(账户菜单入口):当前密码 + 新密码 + 确认。 */
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>
)
}
+104 -48
View File
@@ -1,11 +1,14 @@
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
import { Icon, Avatar, BrandMark } 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, InlineConfirmCard, hasOpenConfirm } 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 } from '../messageparts'
// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染,
// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。
@@ -32,18 +35,36 @@ 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)
return (
<span className="artifact-chip" data-artifact-chip={a?.artifactId}>
<button className="ac-main" title={`打开 ${name}`} onClick={() => { if (art) store.viewArtifact(art) }}>
<Icon name={icon} size={14} /> {name}
</button>
<a className="ac-act" title={`下载 ${name}`} href={api.artifactDownloadUrl(a?.artifactId ?? '')}
download={name} onClick={e => e.stopPropagation()}><Icon name="download" size={13} /></a>
</span>
)
}
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': {
// cardlink 互定位(D32):点击 ↗ 定位收件箱对应卡(从 data 中解析 card:<id>)。
const m = /card:([A-Za-z0-9_]+)/.exec(JSON.stringify(part.card?.data ?? ''))
// 卡流锚点(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])}>
@@ -64,6 +85,12 @@ function findArtifact(id: string) {
return artifactCache[id]
}
// 收件箱卡片查询缓存(store.cards 的派生索引;卡流锚点取卡数据用)。
let cardCache: Record<string, ActivityCard> = {}
function findCard(id: string) {
return cardCache[id]
}
// 过程折叠:run 终态后把连续 process 部件折叠为摘要行(可展开回看)。
function ProcessCollapse({ parts }: { parts: Part[] }) {
const toolCount = parts.filter(p => p.type === 'tool_call').length
@@ -71,7 +98,7 @@ function ProcessCollapse({ parts }: { parts: Part[] }) {
return (
<details className="process-collapse">
<summary>
<span>⚙ 处程输出</span>
<span>过程输出</span>
<span>{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}(点击展开)</span>
</summary>
<div className="body">
@@ -81,50 +108,67 @@ function ProcessCollapse({ parts }: { parts: Part[] }) {
)
}
// 长回复折叠阈值(CF0):终态后超长正文收成摘要行,点击展开全文;live 期间全文可见。
const LONG_REPLY_CHARS = 800
function LongReplyCollapse({ text }: { text: string }) {
// 摘句取压缩空白后的前 100 字;字数按 Markdown 源码长度计。
const excerpt = text.replace(/\s+/g, ' ').trim().slice(0, 100)
return (
<details className="process-collapse long-reply">
<summary>
<span>📄 长回复</span>
<span>{text.length} 字(按 Markdown 源码计)</span>
{excerpt && <span style={{ color: 'var(--text-faint)' }}>{excerpt}…</span>}
</summary>
<div className="body">
<div className="part-text" dangerouslySetInnerHTML={{ __html: marked.parse(text) }} />
</div>
</details>
)
}
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>}
</div>
{split.process.length > 0 && (showProcessInline
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!live} /></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}>{b.parts.map((p, j) => <div className="part" key={j}><PartView part={p} live={!!live} /></div>)}</div>
: <ProcessCollapse key={i} parts={b.parts} />)
: (() => {
// 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 ?? ''} />
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>
)
}
// live 投影:run 进行中没有已落库的助手消息,把 LiveState 合成为同构的临时消息
// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);run 终态对账后由真消息替代。
function liveToMessage(live: LiveState): Message {
const parts: Part[] = []
if (live.thinking) parts.push({ type: 'thinking', text: live.thinking, process: true })
for (const tc of live.toolCalls) {
parts.push({ type: 'tool_call', process: true, toolCall: { id: '', name: tc.name } })
if (tc.status !== undefined) {
parts.push({ type: 'tool_result', process: true, toolCall: { id: '', name: tc.name },
toolResult: { callId: '', status: tc.status === 'error' ? 'error' : 'ok', summary: tc.summary } })
}
}
if (live.text) parts.push({ type: 'text', text: live.text })
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
}
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
function ContextObsView({ obs }: { obs: ContextObs }) {
return (
@@ -167,7 +211,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 => (
@@ -215,6 +259,7 @@ export function Conversation() {
const currentConvId = useStore(s => 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)
@@ -230,12 +275,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, live?.toolCalls.length, live?.phase, activeHitl.length])
}, [messages.length, live?.items, live?.phase, activeHitl.length])
const send = async () => {
if (!text.trim() && attachmentIds.length === 0) return
@@ -269,8 +315,15 @@ 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}
@@ -288,8 +341,10 @@ export function Conversation() {
// 内联确认卡(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={{
@@ -306,7 +361,8 @@ export function Conversation() {
<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">
<div>发送消息开始对话</div>
<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>}
</div>
@@ -314,15 +370,15 @@ export function Conversation() {
<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]) }
+35 -19
View File
@@ -1,10 +1,12 @@
// 右栏文档查看器(4.4):markdown 渲染 / 文本编辑保存 / 图片预览 / 其他下载。
// 文档查看器(4.4 → 工作区文件管理升级):单层 OverlayHost——markdown 渲染 /
// 文本编辑保存 / 图片与 pdf 预览 / csv·json 等宽 / 二进制信息卡 + 下载。
import { useEffect, useState } from 'react'
import { marked } from 'marked'
import { api } from '../api'
import { store } from '../store'
import { OverlayHost } from '@ui'
import { OverlayHost, Icon, Btn, EmptyState } from '@ui'
import type { Artifact } from '../contracts'
import { artifactIcon, humanSize } from '../messageparts'
export function DocViewer({ artifact }: { artifact: Artifact }) {
const [content, setContent] = useState<string | null>(null)
@@ -16,35 +18,49 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
fetch(api.artifactContentUrl(artifact.id)).then(r => r.text()).then(setContent).catch(() => setContent('(读取失败)'))
}, [artifact.id])
const isPdf = /\.pdf$/i.test(artifact.name)
const isCsv = /\.csv$/i.test(artifact.name)
const isJson = /\.json$/i.test(artifact.name)
return (
<OverlayHost title={artifact.name} icon="📄" onClose={() => store.viewArtifact(null)}>
<div className="modal">
<div className="modal-header">
📄 {artifact.name}
<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>}
{isTextKind(artifact) && (
<>
<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">
<div className="docv-body">
{artifact.kind === 'image' ? (
<img src={api.artifactContentUrl(artifact.id)} alt={artifact.name} style={{ maxWidth: '100%' }} />
<img src={api.artifactContentUrl(artifact.id)} alt={artifact.name} style={{ maxWidth: '100%', borderRadius: 8 }} />
) : isPdf ? (
<iframe src={api.artifactContentUrl(artifact.id)} title={artifact.name} className="docv-pdf" />
) : isBinaryKind(artifact) ? (
<div style={{ color: 'var(--text-faint)' }}>二进制工件——请下载查看(docx 深度预览为远期项;md/txt 可在线查看)</div>
<EmptyState icon={artifactIcon(artifact.name, artifact.kind)}>
<b>{artifact.name}</b>
<div>{humanSize(artifact.size) || '二进制文件'} · 浏览器不支持此格式的在线预览</div>
<a className="btn v-primary" href={api.artifactDownloadUrl(artifact.id)} download={artifact.name}>
<Icon name="download" size={14} /> 下载文件
</a>
</EmptyState>
) : editing ? (
<textarea className="search-input" style={{ height: '60vh', fontFamily: 'var(--mono)', fontSize: 12.5 }}
value={content ?? ''} onChange={e => { setContent(e.target.value); setDirty(true) }} />
<textarea className="search-input docv-editor" value={content ?? ''}
onChange={e => { setContent(e.target.value); setDirty(true) }} />
) : isCsv || isJson ? (
<pre className="docv-pre">{content ?? '(加载中…'}</pre>
) : (
<div className="doc-viewer" dangerouslySetInnerHTML={{ __html: marked.parse(content ?? '(加载中…') as string }} />
)}
@@ -59,5 +75,5 @@ function isTextKind(a: Artifact): boolean {
}
function isBinaryKind(a: Artifact): boolean {
return /\.(docx|pdf|xlsx|pptx|png|jpg|jpeg|gif|webp)$/i.test(a.name)
return /\.(docx|pdf|xlsx|pptx|doc|xls|ppt|zip|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*/, '')
}
+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>
)
}
+61 -75
View File
@@ -1,11 +1,19 @@
// 设置中心(4.9):schema 驱动渲染 + 生效时机标注 + 密钥掩码 + 插件管理 + 画像管理。
// 设置中心(4.9 → UI 能力平台迁移):@ui SettingsShell + SettingRow 统一行语义,
// 保存按钮进固定 actions 底栏(不随内容滚动);schema 驱动 + 生效时机标注 + 密钥掩码。
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile, SettingDesc } from '../contracts'
import { SettingsShell, SettingRow, SettingsGroup, Btn, EmptyState } from '@ui'
type Section = 'settings' | 'profiles' | 'plugins'
// 分组显示名(项目设置面板共用)。
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')
@@ -28,11 +36,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 +48,38 @@ 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' && (
<>
{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>
</>
)}
{section === 'profiles' && <ProfilesSection profiles={profiles} />}
{section === 'plugins' && <PluginsSection />}
</div>
</div>
</div>
<SettingsShell
title="设置" active={section} onSelect={k => setSection(k as Section)}
onClose={() => store.openSettings(false)}
sections={[
{ key: 'settings', label: '常规', icon: 'settings' },
{ key: 'profiles', label: '智能体画像', icon: 'bot', badge: profiles.length || undefined },
{ key: 'plugins', label: '插件', icon: 'puzzle' },
]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
actions={section === 'settings' && schema.length > 0 ? (
<>
{saved && <span style={{ color: 'var(--ok)', fontSize: 12.5, marginRight: 8 }}>✓ 已保存</span>}
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
</>
) : 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>
))}
{section === 'profiles' && <ProfilesSection profiles={profiles} />}
{section === 'plugins' && <PluginsSection />}
</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 +102,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 // 空串不覆盖(密钥未修改)
@@ -135,7 +126,7 @@ function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
return (
<div>
<div className="doc-toolbar">
<button className="btn" onClick={() => { setEditing(newProfile()); setText('') }}>+ 新建画像</button>
<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]
@@ -152,25 +143,22 @@ function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
</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={() => {
<SettingRow key={p.id}
label={<>{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{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()
}}>导出</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>}
}}>导出</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>
</div>
</SettingRow>
))}
{editing && (
<div className="settings-group" style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<h4>编辑画像:{editing.name}</h4>
<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>
@@ -178,15 +166,15 @@ function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
<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 () => {
<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}`) }
}}>保存画像</button>
</div>
}}>保存画像</Btn>
</SettingsGroup>
)}
</div>
)
@@ -207,23 +195,21 @@ function PluginsSection() {
return (
<div>
<div className="doc-toolbar">
<button className="btn" onClick={async () => {
<Btn variant="ghost" icon="undo" onClick={async () => {
await api.reloadPlugins()
await refresh()
store.refreshPlugins()
store.toast('插件已热重载')
}}>⟳ 热重载</button>
}}>热重载</Btn>
<span style={{ color: 'var(--text-faint)', fontSize: 12 }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
</div>
{plugins.length === 0 && <div style={{ color: 'var(--text-faint)' }}>暂无已装载插件(目录:设置 → 插件目录;示例插件见 Dev 仓 plugins/example)</div>}
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(目录:常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
{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>
<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>
</div>
</SettingRow>
))}
</div>
)
+45 -27
View File
@@ -4,6 +4,8 @@
import { useEffect, useRef, useState } from 'react'
import { store, useStore } from '../store'
import { auth } from '../auth'
import { api } from '../api'
import { Icon, BrandMark, workspaceIcon } from '@ui'
import type { Conversation } from '../contracts'
import { workspaceEntries } from './Workspaces'
import { groupByProject, groupByTimeline, relativeTime, filterTasks, type TaskOrg } from '../tasklist'
@@ -34,8 +36,10 @@ function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClo
return <div ref={ref} className="tsb-menu" onClick={e => e.stopPropagation()}>{children}</div>
}
function MenuItem({ onClick, danger, children }: { onClick: () => void; danger?: boolean; children: React.ReactNode }) {
return <button className={`tsb-mi ${danger ? 'danger' : ''}`} onClick={onClick}>{children}</button>
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() {
@@ -85,9 +89,13 @@ export function TaskSidebar() {
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) }}>📌</button>
<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) }}>
{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>
)
@@ -97,6 +105,7 @@ export function TaskSidebar() {
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)}
@@ -106,22 +115,25 @@ export function TaskSidebar() {
return (
<>
<div className="tsb-brand">
<span className="tsb-logo">A</span>
<BrandMark size={24} />
<span className="tsb-name">Agent-System</span>
<span style={{ flex: 1 }} />
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}>⇤</button>
<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()}>
+ 新建任务{currentProject ? <span className="tsb-newtask-proj">{currentProject.name}</span> : null}
<Icon name="plus" size={15} />新建任务{currentProject ? <span className="tsb-newtask-proj">{currentProject.name}</span> : null}
</button>
<button className="tsb-newtask-caret" title="更多创建选项"
onClick={e => setMenu(m => m?.kind === 'new' ? null : { kind: 'new', anchor: e.currentTarget })}>▾</button>
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>
<input className="tsb-search" placeholder="搜索任务…" value={q} onChange={e => setQ(e.target.value)} />
</div>
{/* 组织视图切换(P8)+ 过滤 */}
@@ -131,7 +143,7 @@ export function TaskSidebar() {
<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) }}>🗄</button>
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
</div>
<div className="tsb-scroll">
@@ -165,11 +177,12 @@ export function TaskSidebar() {
<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">▶</span>
<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(); store.createTask(g.projectId) }}>+</button>
<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>
@@ -182,31 +195,31 @@ export function TaskSidebar() {
{/* 工作区快捷图标条(D25:与 ⌘1–9 同一清单) */}
<div className="tsb-wsstrip">
{workspaceEntries().map(e => (
<button key={e.key} title={`${e.title}(${e.shortcut})`} onClick={() => store.openWorkspace(e.key)}>{e.icon}</button>
<button key={e.key} title={`${e.title}(${e.shortcut})`} onClick={() => store.openWorkspace(e.key)}><Icon name={workspaceIcon(e.icon)} size={15} /></button>
))}
<span style={{ flex: 1 }} />
<button title={theme === 'dark' ? '切换浅色' : '切换深色'} onClick={() => store.setUI({ theme: theme === 'dark' ? 'light' : 'dark' })}>{theme === 'dark' ? '☀' : '☾'}</button>
<button title={theme === 'dark' ? '切换浅色' : '切换深色'} onClick={() => store.setUI({ theme: theme === 'dark' ? 'light' : 'dark' })}><Icon name={theme === 'dark' ? 'sun' : 'moon'} size={15} /></button>
</div>
{/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */}
<div className="tsb-footer">
<button className="tsb-user" onClick={e => setMenu(m => m?.kind === 'account' ? null : { kind: 'account', anchor: e.currentTarget })}>
<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 ? 'API 密钥 · 已认证' : '本地模式 · 默认租户') : '…'}</span>
<span className="tsb-user-sub">{me ? (me.auth ? (me.isAdmin ? '管理员 · 已认证' : '已认证') : '本地模式 · 默认租户') : '…'}</span>
</span>
<span className="tsb-user-chev">▿</span>
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
</button>
<button className="tsb-iconbtn" title="设置" onClick={() => store.openSettings(true)}>⚙</button>
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.openInbox()}>◍ {pendingBadge}</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 onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
<MenuItem icon="plus" onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
<div className="tsb-menu-div" />
<MenuItem onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
<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>
))}
@@ -214,10 +227,13 @@ export function TaskSidebar() {
<Menu anchor={rowMenuAnchor} onClose={() => setMenu(null)}>
{rowTask && <>
<MenuItem onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
<MenuItem onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
<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 onClick={() => { store.setUI({ taskOrg: 'timeline' }); setMenu(null) }}>按时间线查看</MenuItem>
<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>
@@ -230,11 +246,13 @@ export function TaskSidebar() {
</span>
</div>
<div className="tsb-menu-div" />
<MenuItem onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
<MenuItem onClick={() => { store.openInbox(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
<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>
{me?.isAdmin && <MenuItem icon="users" onClick={() => { store.setUI({ adminUsersOpen: true }); setMenu(null) }}>用户管理…</MenuItem>}
{me?.auth && <>
<div className="tsb-menu-div" />
<MenuItem danger onClick={() => { auth.logout() }}>退出登录</MenuItem>
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
</>}
</Menu>
</>
+5 -4
View File
@@ -3,6 +3,7 @@
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge } from '@ui'
import { Icon } from '@ui'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
export const docBridge: DocBridge = {
@@ -23,16 +24,16 @@ export function Workbench() {
<>
<div className="tabs">
<span className="tab active">收件箱{pending > 0 ? ` ${pending}` : ''}</span>
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}>🧹 收纳</button>
<button className="tab" title="收起收件箱" onClick={store.toggleRight}>»</button>
<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>
<div className="tab-body">
<CurrentTaskCard plan={plan} todos={todos} />
<InboxList cards={cards} callbacks={{
<InboxList cards={cards} docBridge={docBridge} callbacks={{
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.openInbox(c.id),
onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
}} />
</div>
</>
+72 -10
View File
@@ -1,10 +1,13 @@
// 工作区弹层(提案 T4/D25):工件/代理/插件面板/插件声明工作区统一经 OverlayHost 呈现;
// ⌘1–9 快捷键与左栏快捷区共享同一清单(workspaceEntries)。
import { useState } from 'react'
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { OverlayHost, type DocBridge } from '@ui'
import type { Run, Artifact } 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 }[] {
@@ -52,17 +55,76 @@ export function Workspaces() {
return null
}
/** 工件面板(自 Workbench tabs 迁入;点击进 DocViewer 深读)。 */
/** 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内预览/下载/删除(带确认)。 */
export function ArtifactsPanel() {
const artifacts = useStore(s => s.artifacts)
if (artifacts.length === 0) return <div className="empty-note">暂无工件——上传附件或让模型产出文档(mock:#doc)</div>
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>
const [q, setQ] = useState('')
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
const [confirmDel, setConfirmDel] = useState<string | null>(null)
const kinds = ['all', 'document', 'data', 'image'] as const
const visible = artifacts.filter(a =>
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
const del = async (a: Artifact) => {
try {
await api.deleteArtifact(a.id)
if (store.getState().ui.viewingArtifact?.id === a.id) store.viewArtifact(null)
store.toast(`已删除 ${a.name}`)
} catch (e) { store.toast(`删除失败:${e}`) }
if (store.getState().currentProjectId) store.refreshArtifacts(store.getState().currentProjectId!)
}
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}>
<span className="ar-icon" onClick={() => store.viewArtifact(a)}><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main" onClick={() => store.viewArtifact(a)} title={a.name}>
<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={() => store.viewArtifact(a)}><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>
{confirmDel === a.id
? <button className="icon-btn danger" title="确认删除" onClick={() => { setConfirmDel(null); del(a) }}><Icon name="check" size={14} /></button>
: <button className="icon-btn" title="删除" onClick={() => { setConfirmDel(a.id); setTimeout(() => setConfirmDel(c => c === a.id ? null : c), 2500) }}><Icon name="trash" size={14} /></button>}
</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 深读)。 */
+4 -2
View File
@@ -15,6 +15,7 @@ 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;
}
@@ -32,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 终态后折叠为摘要行
}
@@ -153,7 +154,7 @@ export interface HITLResponse {
export type UINodeType =
| 'section' | 'list' | 'kv' | 'form' | 'markdown' | 'doc-ref' | 'actions' | 'chart-lite'
| 'progress' | 'compare' | 'tree' | 'table' | 'steps' | 'tabs' | 'timeline';
| 'avatar' | 'progress' | 'compare' | 'tree' | 'table' | 'steps' | 'tabs' | 'timeline';
export interface UINode {
type: UINodeType;
@@ -167,6 +168,7 @@ export interface UINode {
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[] };
+100
View File
@@ -0,0 +1,100 @@
// 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[] = []
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') {
parts.push({ type: 'artifact_ref', process: true, artifact: it.artifact })
} else {
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
}
}
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
}
// showProcessInline:流式期间过程区是否展开——thinking/acting 实时展开;一旦进入
// responding(最终回复开始流式)立即折叠,只显示回复(Cursor 式),工具恢复再展开。
// 非 live 消息随「自动折叠过程」偏好。
export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean {
return live ? live.phase !== 'responding' : !autoCollapse
}
+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
}
+39 -15
View File
@@ -2,6 +2,7 @@
// 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, ActivityCard,
@@ -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
}
@@ -38,9 +39,12 @@ export interface UIState {
taskOrg: 'project' | 'timeline'
showArchived: boolean
lastProjectId: string | null
adminUsersOpen: boolean
changePwOpen: boolean
projectSettingsId: string | null
}
export interface Me { owner: string; name: string; auth: boolean }
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }
interface AppState {
contextObs: Record<string, ContextObs>
@@ -82,6 +86,9 @@ const DEFAULT_UI: UIState = {
taskOrg: 'project',
showArchived: false,
lastProjectId: null,
adminUsersOpen: false,
changePwOpen: false,
projectSettingsId: null,
}
function loadUI(): UIState {
@@ -205,6 +212,13 @@ export const store = {
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, cards: [] })
@@ -249,26 +263,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': {
@@ -329,6 +339,12 @@ export const store = {
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
}
@@ -363,7 +379,7 @@ export const store = {
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
if (!state.live || state.live.runId !== out.run.id) {
set({ live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() } })
set({ live: { runId: out.run.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() } })
}
set({ messages: [...state.messages, out.message] })
store.connectIfNeeded()
@@ -487,9 +503,17 @@ export const store = {
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 }) },
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)
@@ -497,8 +521,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
}
+263 -141
View File
@@ -1,71 +1,118 @@
/* 设计令牌与 UI 组件样式已迁入 Agent-System-UI(@ui/tokens.css、@ui/ui.css,main.tsx 引入)。 */
/* 设计令牌与 UI 组件样式已迁入 Agent-System-UI(@ui/tokens.css、@ui/ui.css,main.tsx 引入)。
R4 视觉收敛(2026-09-30):三级表面阶梯 + 发丝边框 + 圆角标尺 + 克制阴影 + 统一线性图标。 */
* { 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); color: var(--text);
font-size: 13.5px; line-height: 1.55;
-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; }
/* ---- 三栏布局:层次靠背景阶梯(bg-panel 侧栏 / bg 主区),边框只发丝勾边 ---- */
.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 .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: 1.5px; top: 0; bottom: 0; width: 1px; background: transparent; transition: background .12s; }
.divider:hover::before, .divider.dragging::before { background: var(--accent); }
.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; }
.right-panel.collapsed { width: 44px !important; }
/* ---- 左栏:任务中心(方案 A)见文末 tsb-* ---- */
/* ---- 通用控件 ---- */
.btn {
background: var(--accent); color: var(--text-on-accent); border: none;
border-radius: var(--radius); padding: 8px 16px; cursor: pointer;
font-size: 13px; font-weight: 550; white-space: nowrap;
box-shadow: inset 0 1px 0 rgba(255,255,255,.14), 0 1px 2px rgba(8,9,12,.3);
transition: filter .12s var(--ease), transform .12s var(--ease);
}
.btn:hover:not(:disabled) { filter: brightness(1.09); }
.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); filter: none; }
.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: 13px; width: 100%;
transition: border-color .12s, box-shadow .12s;
}
.search-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
/* ---- 中栏对话 ---- */
/* 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: 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; min-height: 0; 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; }
.chat-header { padding: 12px 22px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 10px; background: var(--bg); }
.chat-title { font-weight: 600; font-size: 14.5px; 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: 13.5px; }
.empty-state .es-mark { width: 44px; height: 44px; border-radius: 12px; 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; }
.msg-role { font-size: 12px; 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-dim); font-size: 12.5px; border-left: 2px solid var(--border); padding-left: 10px; margin: 4px 0; }
.part-thinking summary { cursor: pointer; color: var(--text-faint); user-select: none; }
.part-thinking .part-thinking-body { white-space: pre-wrap; margin-top: 4px; }
.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); }
.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 { line-height: 1.65; overflow-wrap: break-word; }
.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; }
.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-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 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: 12.5px; }
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: var(--radius-xs); padding: 1px 5px; font-size: 12.5px; }
.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); color: var(--text-dim); }
.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: 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-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: 13px; 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); }
/* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */
.part-group { margin: 2px 0; }
/* 过程折叠(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 { border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); margin: 6px 0; }
.process-collapse summary { list-style: none; cursor: pointer; padding: 7px 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); }
.process-collapse .body { padding: 4px 12px 10px; }
@@ -76,41 +123,37 @@ button { font-family: inherit; }
@keyframes pulse { 0%,100% { opacity: .4 } 50% { opacity: 1 } }
.cursor-blink::after { content: '▍'; animation: pulse 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 .rec { font-size: 10.5px; 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); }
/* 输入区:悬浮卡片式输入壳(外圆角大、聚焦发光) */
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); }
.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; }
.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: 12px; }
.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: 13.5px; 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: 13px; }
.cmd-item:hover, .cmd-item.active { background: var(--accent-soft); }
.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: 12.5px; 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-icon { width: 16px; text-align: center; flex-shrink: 0; }
@@ -118,10 +161,25 @@ button { font-family: inherit; }
.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: 12.5px; }
.kind-pill { border: 1px solid var(--border); background: none; color: var(--text-dim); border-radius: var(--radius-full); padding: 3px 10px; font-size: 12px; 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); }
.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: 13px; }
.artifact-row .meta { font-size: 11px; 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: 12.5px; padding: 14px 6px; }
.agent-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; }
.agent-row:hover { background: var(--bg-hover); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
@@ -131,147 +189,211 @@ button { font-family: inherit; }
.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; }
.overlay { position: fixed; inset: 0; background: rgba(8, 9, 12, .6); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 50; animation: overlay-in .14s var(--ease); }
@keyframes overlay-in { from { opacity: 0 } }
.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: modal-in .16s var(--ease); }
@keyframes modal-in { from { opacity: 0; transform: translateY(6px) scale(.99) } }
.modal-header { padding: 16px 20px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; font-weight: 600; font-size: 14.5px; letter-spacing: -0.01em; }
.modal-header .spacer { flex: 1; }
.modal-body { flex: 1; overflow-y: auto; padding: 16px 18px; }
.modal-body { flex: 1; overflow-y: auto; padding: 18px 20px; }
.subagent-tree { font-size: 13px; }
.subagent-node { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; margin-bottom: 8px; }
.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: 10.5px; 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: 11.5px; color: var(--text-faint); text-transform: capitalize; }
.docv-dirty { color: var(--warn); font-size: 12px; }
.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: 12.5px; resize: vertical; }
.docv-pre { font-family: var(--mono); font-size: 12.5px; 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: 13px; }
.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;
}
.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);
}
.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;
}
.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; }
/* ---- 品牌标(侧栏 / 登录页 / 头像共用的星芒渐变底) ---- */
.brand-mark { background: linear-gradient(135deg, #6d8dff, #9d7bff); color: #fff; display: inline-flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.22); }
/* 工作区快捷区(T4/D26 左栏)与当前任务常驻卡(D25 R4) */
.current-task{border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:8px;background:rgba(120,160,255,.05)}
.ct-head{display:flex;align-items:center;gap:8px;font-weight:600;margin-bottom:6px}
.ct-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ct-progress{font-size:11px;color:var(--text-faint)}
.project-badge{font-size:11px;padding:1px 8px;border-radius:8px;background:rgba(120,160,255,.15);color:var(--accent);white-space:nowrap}
.tab-hint{font-size:10px;color:var(--text-faint);align-self:center}
.empty-note{color:var(--text-faint);font-size:12px;padding:12px 4px}
/* 收纳策略(T5):收起态角标 + 收纳按钮 */
.rail-badge{position:absolute;top:-4px;right:-6px;font-size:10px;background:var(--warn,#d29922);color:#fff;border-radius:8px;padding:0 5px;min-width:14px}
.right-panel.collapsed .icon-only button{position:relative}
.tab-action{font-size:11px;color:var(--text-faint)}
/* ---- 左栏:任务中心(方案 A,提案 2026-09-30) ---- */
.tsb-brand { display: flex; align-items: center; gap: 8px; padding: 12px 12px 6px; }
.tsb-logo { width: 22px; height: 22px; border-radius: 6px; background: var(--accent); color: #fff; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
.tsb-name { font-weight: 600; font-size: 13.5px; }
/* ---- 左栏:任务中心(方案 A;R4 重造型) ---- */
.tsb-brand { display: flex; align-items: center; gap: 9px; padding: 14px 14px 8px; }
.tsb-logo { width: 24px; height: 24px; border-radius: 8px; background: linear-gradient(135deg, #6d8dff, #9d7bff); color: #fff; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
.tsb-name { font-weight: 650; font-size: 13.5px; 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: 13px; 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: 4px 10px 8px; display: flex; flex-direction: column; gap: 6px; }
.tsb-newtask { display: flex; height: 34px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-soft); overflow: hidden; }
.tsb-newtask-main { flex: 1; display: flex; align-items: center; gap: 6px; padding: 0 10px; background: none; border: none; color: var(--accent); font-weight: 600; font-size: 13px; cursor: pointer; min-width: 0; }
.tsb-newtask-main:hover { filter: brightness(1.08); }
.tsb-newtask-proj { font-weight: 400; font-size: 11.5px; opacity: .8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-newtask-caret { width: 28px; border: none; border-left: 1px solid var(--accent); background: none; color: var(--accent); cursor: pointer; font-size: 10px; }
.tsb-newtask-caret:hover { background: var(--accent-soft); }
.tsb-search { height: 30px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); padding: 0 10px; font-size: 12.5px; width: 100%; }
.tsb-search:focus { border-color: var(--accent); outline: none; }
.tsb-search::placeholder { color: var(--text-faint); }
.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(--accent); color: var(--text-on-accent); overflow: hidden; box-shadow: inset 0 1px 0 rgba(255,255,255,.16), 0 2px 6px rgba(8,9,12,.28); transition: filter .12s; }
.tsb-newtask:hover { filter: brightness(1.08); }
.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: 13px; cursor: pointer; min-width: 0; }
.tsb-newtask-main svg { flex: none; }
.tsb-newtask-proj { font-weight: 450; font-size: 11.5px; opacity: .75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-newtask-caret { width: 30px; border: none; border-left: 1px solid rgba(255,255,255,.18); background: none; color: inherit; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.tsb-newtask-caret:hover { background: rgba(255,255,255,.08); }
.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: 12.5px; outline: none; min-width: 0; }
.tsb-search input::placeholder { color: var(--text-faint); }
.tsb-orgrow { display: flex; align-items: center; gap: 6px; padding: 0 10px 8px; }
.tsb-pills { flex: 1; display: flex; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 2px; gap: 2px; }
.tsb-pill { flex: 1; height: 22px; border: none; border-radius: 4px; background: none; color: var(--text-faint); font-size: 12px; cursor: pointer; }
.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: 12px; cursor: pointer; transition: all .12s; }
.tsb-pill:hover { color: var(--text-dim); }
.tsb-pill.on { background: var(--bg-active); color: var(--text); font-weight: 500; }
.tsb-pill.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(8,9,12,.25); }
.tsb-scroll { flex: 1; overflow-y: auto; padding: 0 8px 8px; min-height: 0; }
.tsb-seclabel { font-size: 11px; color: var(--text-faint); padding: 10px 8px 4px; letter-spacing: .3px; }
.tsb-empty { padding: 18px 12px; color: var(--text-faint); font-size: 12.5px; line-height: 1.8; text-align: center; }
.tsb-scroll { flex: 1; overflow-y: auto; padding: 0 10px 8px; min-height: 0; }
.tsb-seclabel { font-size: 11px; 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: 12.5px; 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 8px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 500; font-size: 13px; cursor: pointer; user-select: none; }
.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: 13px; cursor: pointer; user-select: none; }
.tsb-group-head:hover { background: var(--bg-hover); }
.tsb-group.closed .tsb-chev { transform: rotate(-90deg); }
.tsb-chev { font-size: 9px; color: var(--text-faint); transition: transform .12s; }
.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: 11px; color: var(--text-faint); font-weight: 400; }
.tsb-group-add { visibility: hidden; width: 20px; height: 20px; border: none; border-radius: 4px; background: none; color: var(--text-faint); font-size: 12px; cursor: pointer; }
.tsb-group-cnt { font-size: 11px; 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: 30px; padding: 0 8px; margin: 1px 0; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; }
.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: 13px; }
.tsb-task-tm { font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
.tsb-pbadge { font-size: 10.5px; color: var(--text-faint); border: 1px solid var(--border); border-radius: 4px; padding: 0 5px; line-height: 1.5; white-space: nowrap; }
.tsb-pbadge { font-size: 10.5px; 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); font-size: 11px; cursor: pointer; padding: 2px 3px; border-radius: 4px; }
.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-wsstrip { display: flex; align-items: center; gap: 2px; padding: 6px 10px; border-top: 1px solid var(--border); }
.tsb-wsstrip button { border: none; background: none; color: var(--text-faint); font-size: 13px; cursor: pointer; padding: 4px 5px; border-radius: var(--radius-sm); }
.tsb-wsstrip { display: flex; align-items: center; gap: 2px; padding: 6px 12px; border-top: 1px solid var(--border-soft); }
.tsb-wsstrip button { border: none; background: none; color: var(--text-faint); cursor: pointer; padding: 5px; border-radius: var(--radius-sm); display: inline-flex; }
.tsb-wsstrip button:hover { background: var(--bg-hover); color: var(--text); }
.tsb-footer { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-top: 1px solid var(--border); }
.tsb-user { flex: 1; display: flex; align-items: center; gap: 8px; min-width: 0; height: 38px; padding: 0 7px; border: none; border-radius: var(--radius-sm); background: none; cursor: pointer; text-align: left; }
.tsb-footer { display: flex; align-items: center; gap: 4px; padding: 10px 12px; border-top: 1px solid var(--border-soft); }
.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: 26px; height: 26px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 12px; font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; }
.tsb-avatar { width: 27px; height: 27px; border-radius: 50%; background: linear-gradient(135deg, #6d8dff, #9d7bff); color: #fff; font-size: 12px; 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: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-user-sub { font-size: 11px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-user-chev { color: var(--text-faint); font-size: 10px; }
.tsb-user-chev { color: var(--text-faint); display: inline-flex; }
.tsb-menu { position: fixed; z-index: 300; min-width: 210px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 12px 32px rgba(0,0,0,.35); padding: 5px; display: flex; flex-direction: column; }
.tsb-mi { display: flex; align-items: center; gap: 8px; min-height: 30px; padding: 4px 9px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); font-size: 13px; cursor: pointer; text-align: left; }
.tsb-menu { position: fixed; z-index: 300; min-width: 216px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); padding: 5px; display: flex; flex-direction: column; animation: 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: 13px; 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: #fff; }
.tsb-mi kbd { margin-left: auto; font-size: 10.5px; color: var(--text-faint); }
.tsb-menu-div { height: 1px; background: var(--border); margin: 5px 4px; }
.tsb-mi.danger:hover svg { color: #fff; }
.tsb-mi kbd { margin-left: auto; font-size: 10.5px; 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: 13.5px; font-weight: 600; }
.tsb-acc-mail { font-size: 11.5px; color: var(--text-faint); }
/* ---- 登录页与改密门(R4 重造型:宽屏右侧品牌面板) ---- */
.lg-wrap { position: fixed; inset: 0; display: flex; align-items: stretch; background: var(--bg); 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, #171e38 0%, #1d2145 55%, #251d4d 100%); }
.lg-panel::before { content: ''; position: absolute; width: 560px; height: 560px; border-radius: 50%; background: radial-gradient(closest-side, rgba(109,141,255,.22), 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(157,123,255,.18), 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: 14px; 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: 13.5px; opacity: .88; }
.lg-panel li svg { margin-top: 2px; color: #8fa8ff; flex: none; }
.lg-panel .lg-foot { position: absolute; bottom: 28px; left: 64px; right: 64px; display: flex; justify-content: space-between; font-size: 11.5px; 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: 15.5px; letter-spacing: -0.01em; margin-bottom: 26px; }
.lg-title { margin: 0 0 4px; font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
.lg-sub { margin: 0 0 18px; font-size: 12.5px; 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: 12.5px; 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(8,9,12,.25); }
.lg-label { display: block; font-size: 12px; 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: 13.5px; 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: 12.5px; }
.lg-err { margin-top: 12px; font-size: 12.5px; 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(--accent); color: var(--text-on-accent); font-weight: 600; font-size: 13.5px; cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,.16), 0 2px 8px var(--accent-glow);
transition: filter .12s, transform .12s;
}
.lg-submit:disabled { opacity: .45; cursor: default; box-shadow: none; }
.lg-submit:not(:disabled):hover { filter: brightness(1.09); }
.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: 12.5px; cursor: pointer; }
.lg-cancel:hover { color: var(--text-dim); text-decoration: underline; }
.lg-hint { margin: 14px 0 0; font-size: 11.5px; color: var(--text-faint); line-height: 1.7; }
.lg-hint code { font-family: var(--mono); font-size: 10.5px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 1px 5px; }
/* ---- 用户管理 / 改密弹层 ---- */
.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: 12.5px; color: var(--text-dim); white-space: nowrap; }
.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.14); }
.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: 12px; 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: 11.5px; color: var(--text-faint); }
.ad-notice { font-size: 12.5px; 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: 12.5px; }
.ad-table th { text-align: left; font-weight: 600; color: var(--text-faint); font-size: 11px; 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: 12px; }
.ad-dim { color: var(--text-faint); }
.ad-chip { font-size: 10px; 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: 12.5px; }
.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: #fff; font-size: 9.5px; 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; }
+149
View File
@@ -0,0 +1,149 @@
// 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('artifact refs land after their tool call and are process', () => {
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', 'artifact_ref', 'text'])
expect(msg.parts.find(p => p.type === 'artifact_ref')).toMatchObject({ process: true })
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)
})
})
+65
View File
@@ -0,0 +1,65 @@
// 消息部件顺序保真纯函数测试(渲染顺序 = 落库顺序)。
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 → artifact_ref → text
const parts: Part[] = [
p({ type: 'text', text: '先找资料' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }),
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
p({ type: 'text', text: '文档已生成,请查收' }),
]
const blocks = groupParts(parts)
expect(blocks.map(b => b.kind)).toEqual(['content', 'process', 'content', 'content'])
expect((blocks[0] as { part: Part }).part.text).toBe('先找资料')
expect((blocks[1] as { parts: Part[] }).parts.map(x => x.type)).toEqual(['tool_call', 'tool_result'])
expect((blocks[2] as { part: Part }).part.type).toBe('artifact_ref')
expect((blocks[3] as { part: Part }).part.text).toBe('文档已生成,请查收')
})
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')
})
})
+4 -1
View File
@@ -16,7 +16,10 @@
"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/tasklist.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/tasklist.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/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.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","./test/auth.test.ts","./test/contracts.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}