Compare commits

..
Author SHA1 Message Date
weichen 24d9f632ca Merge remote-tracking branch 'origin/main' into feature/sillytavern-review 2026-10-04 19:42:54 +00:00
weichenandCursor eb6502f0ef Refresh the frontend incremental build info
Why: tsc -b rewrote the tracked build info after the contract mirror changed.
Evidence: make build completed and left only tsconfig.tsbuildinfo dirty.
Impact: The feature branch working tree matches the production build.
Refs: .task/feature-sillytavern-review.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 18:40:50 +00:00
weichenandCursor 13a1c63280 Sync the contract mirror after hiding ContextSection
Why: Core is the authority for the shared types, and the section shape no longer needs its own export.
Evidence: make contracts copied modules/core/ts/types.ts. Frontend knip stays at 43.
Impact: Callers still read sections on the observation object.
Refs: .task/feature-sillytavern-review.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 18:36:23 +00:00
weichen 02cebc06ce Keep empty HTML clears out of the sink ratchet
Why: Preview setup clears a node with innerHTML = '' before appending sanitized children, and the ratchet treated that space as a write.
Evidence: npx vitest run test/html-sinks.test.ts. Non-empty assignments still match.
Impact: The preview path can keep clearing nodes. Writing HTML strings in components still fails the test.
Refs: docs/records/2026-10-04-sillytavern-review.md
2026-10-04 18:25:01 +00:00
weichen 36b92ede88 Add secret, injection, and message controls to the workbench
Why: Keys must be added without writing masks back, and a turn needs copy, exclude, and regenerate without deleting history.
Evidence: npx tsc -b. Session notes and candidate switches call the new conversation and message routes.
Impact: Settings gains key, lore, and slash-template panels. Excluded messages stay visible and are dimmed.
Refs: docs/records/2026-10-04-sillytavern-review.md
2026-10-04 18:20:10 +00:00
weichenandCursor 1f42edf80a Render chat and previews through the shared HTML cleaner
Why: The conversation and office fallbacks wrote marked output straight into the page, including converted docx HTML.
Evidence: html-sinks ratchet forbids new innerHTML writes outside @ui safeHtml.
Impact: Conversation keeps line-break rendering. Unsafe links are stripped after docx and pptx preview. dompurify is deduped with the UI package.
Refs: feature/sillytavern-review
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 17:28:46 +00:00
weichen b25979401a Merge pull request 'Add a file rewind confirm on user messages' (#42) from feature/durable-rewind into main 2026-10-03 17:40:16 +00:00
weichenandCursor e26119db89 Add a file rewind confirm on user messages
Why: tracked workspace writes need a visible way to return files to the state before a user message.
Evidence: the confirm lists the files the rewind API would restore or skip.
Impact: the control only calls the new checkpoints and rewind endpoints. Usage may include the model that served.
Refs: feature/durable-rewind
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 17:33:05 +00:00
weichen 82d216b324 Merge pull request 'Switch session permission mode beside the composer' (#41) from feature/permission-hooks into main 2026-10-03 17:06:49 +00:00
weichenandCursor 0eaced4d5a Switch session permission mode beside the composer
Why: Plan, default, and auto-edit have to be visible where the user sends a message, and confirmation cards need to show the arguments.
Evidence: The mode control and card detail only use existing color, radius, and type tokens.
Impact: An empty mode still renders as 默认. Confirm cards use the wait color instead of the success color.
Refs: feature/permission-hooks
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 17:02:00 +00:00
weichen 3edae0212c Merge pull request 'Drive the composer from one shell run phase' (#40) from feature/runstate-shell into main 2026-10-03 16:41:39 +00:00
weichenandCursor 5961b6e9bd Use the shell phase type in the conversation header
Why: The phase type has to be consumed by the screen that branches on it, or the unused-export ratchet fails.
Evidence: knip stays at the existing baseline after the conversation annotates ShellPhase.
Impact: No behavior change.
Refs: feature/runstate-shell
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 16:37:32 +00:00
weichenandCursor dcf9b6b847 Drive the composer from one shell run phase
Why: The send button, stop button, and queued sends each looked at a different slice of run state, so a reconnect or a late assistant message could disagree.
Evidence: A depth-4 walk of send, HTTP results, SSE, reconnect, conversation switch, and flush keeps busy aligned with an in-flight run, hides the live bubble once an assistant message exists, and keeps one user message per request id.
Impact: The event listener list is exhaustive over the stream contract, including run.check. A disconnected stream shows 重连中.
Refs: feature/runstate-shell
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 16:35:27 +00:00
weichen 799f3d30fe Merge pull request 'Keep unused exports from growing and state the async-state rule' (#39) from feature/architecture-baseline into main 2026-10-03 16:04:35 +00:00
weichenandCursor d5f59f1811 Keep unused exports from growing and state the async-state rule
Why: Dead exports were unchecked, and async run state needs one owner.
Evidence: knip unused exports stay at 43. design-guard passed.
Impact: A ratchet on export count. No runtime behavior change.
Refs: feature/architecture-baseline
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 16:02:19 +00:00
weichen 8be98b4cbe Merge pull request 'Show a run as active as soon as the send returns' (#38) from fix/live-run-visibility into main 2026-10-03 15:25:37 +00:00
weichen 3936fff664 Merge origin/main into fix/live-run-visibility
Why: The pull request cannot merge while the head is behind main.
Evidence: origin/main is 539aae6, one commit ahead of this branch.
Impact: Keeps the live-run fix and the retried request-id fix together.
Refs: Frontend #38
2026-10-03 15:25:12 +00:00
weichenandCursor e4ca2acfa6 Show a run as active as soon as the send returns
Why: Stamping the user message with the run id made the live bubble look settled, and the composer stayed on 发送 until the next reconcile.
Evidence: npx tsc -b passed. vitest 75, including hasSettledReply and mergeStartedRun. On a mock server the button became 停止/排队 within 250ms and the reply grew while the run was open.
Impact: Composer, live bubble, and agent tracker only. A stored user message no longer hides the stream. Reopening replays from resumeSeq when the server sends it.
Refs: Dev fix/live-run-visibility
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 15:21:28 +00:00
weichen 539aae69b9 Merge pull request 'Send the same request id when an outbox message is retried' (#37) from feature/pi-durable-borrow into main
Send the same request id when an outbox message is retried
2026-10-03 14:44:39 +00:00
weichenandCursor 763c4b4804 Send the same request id when an outbox message is retried
Why: A busy 409 used to enqueue a new id, so the retry created a second user message after the server had already stored the first.
Evidence: vitest run passed, 13 files and 69 tests, including the outbox retry case.
Impact: Data layer only. sendMessage and outbox flush reuse the queued message id as requestId. No screen changes.
Refs: Dev feature/pi-durable-borrow
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 14:37:50 +00:00
weichen e5555b194a Merge pull request 'Show the final reply apart from the work timeline' (#36) from feature/final-reply-compose into main 2026-10-02 14:15:17 +00:00
weichenandCursor 794c3e1657 Show the final reply apart from the work timeline
Why: Deliverable cards appeared as soon as a file was registered, and the last draft was shown as the answer before the run had finished composing one.
Evidence: vitest run test/live.test.ts test/toolkind.test.ts test/messageparts.test.ts test/design-guard.test.ts
Impact: Cards wait until the run ends. A finalizing phase keeps earlier text in the process block, and check rows render there.
Refs: feature/final-reply-compose
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 13:41:36 +00:00
weichen ed95f64890 Merge pull request 'Show tool progress and keep the final reply visible' (#35) from feature/stream-display-overhaul into main 2026-10-02 05:05:02 +00:00
weichenandCursor eca1665d91 Show tool progress and keep the final reply visible
Why: Streaming tool arguments looked like a frozen cursor, and a failed run folded the only narration into the collapsed process block.
Evidence: npx vitest run in modules/frontend, 13 files, 66 passed. npx tsc -b reports only a pre-existing missing docx-preview module.
Impact: Live tools move composing to running to done with arguments and output; finished runs lift the last narration; same-run errors and duplicate files collapse into the reply.
Refs: feature/stream-display-overhaul
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 02:26:54 +00:00
weichen bca7795218 Merge pull request 'Rebuild the workbench shell around projects and card flow' (#34) from feature/s4-shell-overhaul into main 2026-10-02 01:04:36 +00:00
weichen c108209923 Merge main into the shell branch
Why: The frontend branch has to contain the workflow contract sync before it can merge.
Evidence: origin/main is aedcf50, which already contains the P4 contract commit.
Impact: contracts.ts picks up the workflow fields; the shell changes stay.
Refs: feature/s4-shell-overhaul
2026-10-02 01:04:22 +00:00
weichen aedcf50fc9 Merge pull request 'Sync workflow definition types from Core' (#33) from feature/agent-harness-p4 into main
Merge pull request #33
2026-10-02 01:03:56 +00:00
weichenandCursor 1b4666c613 Refresh confirm cards after a decision
Why: The command bus returns the pre-decision card, so the inline card never saw the status change and stayed open.
Evidence: npx tsc -b passed; the live mock confirm card folded about 1.6s after cancel once the list was refetched.
Impact: confirm and cancel refetch the conversation cards; recheck does not.
Refs: feature/s4-shell-overhaul
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 23:48:03 +00:00
weichenandCursor d1ea616b08 Rebuild the workbench shell around projects and card flow
Why: The first S4 pass makes the project the navigation context, keeps deep views in one overlay, and leaves domain panels empty until a plugin declares them.
Evidence: npx tsc -b and npm test (61 passed) in modules/frontend.
Impact: Top bar, project sidebar, inbox all/todo filter, one-way scroll sync, drag-and-drop on the whole chat column, and local molv/system themes.
Refs: feature/s4-shell-overhaul
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 23:33:06 +00:00
weichenandCursor 7d796ee507 Sync workflow definition types from Core
Why: The frontend contract mirror has to include script steps and critic fields before a workflow can be described to the client.
Evidence: make contracts copied modules/core/ts/types.ts. No screen or event changed.
Impact: contracts.ts only. The existing plan, inbox, and HITL cards stay the UI.
Refs: feature/agent-harness-p4
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 16:22:12 +00:00
weichen 83357b5537 Merge pull request 'Show background notices without blocking the composer' (#32) from feature/agent-harness-p3b into main 2026-10-01 15:51:55 +00:00
weichenandCursor f718095d78 Show background notices without blocking the composer
Why: A background child should not look like the conversation is busy, and its completion note needs a compact row instead of a full assistant bubble.
Evidence: vitest run passed, including design-guard. tsc -b passed.
Impact: The stop button cancels the conversation run. System notices fold with the shared Collapsible.
Refs: feature/agent-harness-p3b
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 15:30:50 +00:00
weichen bcc9f1b5db Load plugin ESM pages and record command-pair checks
Why: Plugin pages must mount through a host bridge, and button versus chat command parity needs a check that vitest does not execute as a unit test.

Evidence: vitest run passed. The Playwright file lives under e2e and is outside the tsconfig include.

Impact: @playwright/test is not a required dependency yet.

Refs: Agent-System-Dev feature/base-extension-roadmap
2026-10-01 10:47:12 +00:00
44 changed files with 2738 additions and 315 deletions
+1
View File
@@ -17,6 +17,7 @@
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。
- 跨仓 gitlink 变更须经组合验证后在 Dev 仓推进;受保护分支经 PR 合入。
- 验证命令见 PROJECT.md「验证」节;完成声明须附实际命令输出证据。
- 异步状态只有一个所有者。事件按 seq 处理,过期事件丢弃。
## Gitea 与 Git 访问事实(2026-09-30 实测)
+1 -1
View File
@@ -10,4 +10,4 @@
- `npx tsc -b` — 类型检查;`npm run build` — 产物 dist/(Dev Makefile 拷贝至 backend web/dist 内嵌)。
- `npm test` — vitest:契约形状测试(Core 黄金样本驱动的判别联合/事件信封/HITL impact/工具三清单断言)。
- 开发:`npm run dev`(Vite 代理 /api → 127.0.0.1:8787)。
- 依赖刻意最小:react + marked;无 UI 框架(设计令牌手写,见 styles.css)。
- 依赖刻意最小:react + marked + dompurify(模型输出与文档预览先清洗再写入页面);无 UI 框架(设计令牌手写,见 styles.css)。
+3
View File
@@ -0,0 +1,3 @@
{
"exports": 43
}
+4
View File
@@ -0,0 +1,4 @@
{
"entry": ["src/main.tsx", "src/**/*.test.ts", "src/**/*.test.tsx", "test/**/*.ts", "test/**/*.tsx"],
"project": ["src/**/*.{ts,tsx}"]
}
+815
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"docx-preview": "^0.4.1",
"dompurify": "^3.4.16",
"jszip": "^3.10.2",
"mammoth": "^1.13.0",
"marked": "^15.0.7",
@@ -22,6 +23,7 @@
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^4.3.4",
"knip": "^5.88.1",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vitest": "^4.1.11"
@@ -309,6 +311,40 @@
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -821,6 +857,361 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz",
"integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.3"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4",
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@oxc-resolver/binding-android-arm-eabi": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz",
"integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@oxc-resolver/binding-android-arm64": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz",
"integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@oxc-resolver/binding-darwin-arm64": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz",
"integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oxc-resolver/binding-darwin-x64": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz",
"integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oxc-resolver/binding-freebsd-x64": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz",
"integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz",
"integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-arm-musleabihf": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz",
"integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-arm64-gnu": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz",
"integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-arm64-musl": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz",
"integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-ppc64-gnu": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz",
"integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-riscv64-gnu": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz",
"integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-riscv64-musl": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz",
"integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-s390x-gnu": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz",
"integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-x64-gnu": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz",
"integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-x64-musl": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz",
"integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-openharmony-arm64": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz",
"integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@oxc-resolver/binding-wasm32-wasi": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz",
"integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.11.2",
"@emnapi/runtime": "1.11.2",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@oxc-resolver/binding-win32-arm64-msvc": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz",
"integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@oxc-resolver/binding-win32-x64-msvc": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz",
"integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1224,6 +1615,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
"integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1324,6 +1726,13 @@
"@types/react": "^19.3.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1528,6 +1937,19 @@
"node": ">=6.0.0"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": {
"version": "4.29.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz",
@@ -1680,6 +2102,15 @@
"jszip": ">=3.0.0"
}
},
"node_modules/dompurify": {
"version": "3.4.16",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
@@ -1791,6 +2222,43 @@
"node": ">=12.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fastq": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
"integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fd-package-json": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz",
"integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"walk-up-path": "^4.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1809,6 +2277,35 @@
}
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/formatly": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz",
"integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"fd-package-json": "^2.0.0"
},
"bin": {
"formatly": "bin/index.mjs"
},
"engines": {
"node": ">=18.3.0"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
@@ -1843,6 +2340,19 @@
"node": ">=6.9.0"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -1855,12 +2365,55 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1906,6 +2459,49 @@
"setimmediate": "^1.0.5"
}
},
"node_modules/knip": {
"version": "5.88.1",
"resolved": "https://registry.npmjs.org/knip/-/knip-5.88.1.tgz",
"integrity": "sha512-tpy5o7zu1MjawVkLPuahymVJekYY3kYjvzcoInhIchgePxTlo+api90tBv2KfhAIe5uXh+mez1tAfmbv8/TiZg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/webpro"
},
{
"type": "opencollective",
"url": "https://opencollective.com/knip"
}
],
"license": "ISC",
"dependencies": {
"@nodelib/fs.walk": "^1.2.3",
"fast-glob": "^3.3.3",
"formatly": "^0.3.0",
"jiti": "^2.6.0",
"minimist": "^1.2.8",
"oxc-resolver": "^11.19.1",
"picocolors": "^1.1.1",
"picomatch": "^4.0.1",
"smol-toml": "^1.5.2",
"strip-json-comments": "5.0.3",
"unbash": "^2.2.0",
"yaml": "^2.8.2",
"zod": "^4.1.11"
},
"bin": {
"knip": "bin/knip.js",
"knip-bun": "bin/knip-bun.js"
},
"engines": {
"node": ">=18.18.0"
},
"peerDependencies": {
"@types/node": ">=18",
"typescript": ">=5.0.4 <7"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
@@ -1986,6 +2582,53 @@
"node": ">= 18"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2042,6 +2685,37 @@
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/oxc-resolver": {
"version": "11.24.2",
"resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz",
"integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxc-resolver/binding-android-arm-eabi": "11.24.2",
"@oxc-resolver/binding-android-arm64": "11.24.2",
"@oxc-resolver/binding-darwin-arm64": "11.24.2",
"@oxc-resolver/binding-darwin-x64": "11.24.2",
"@oxc-resolver/binding-freebsd-x64": "11.24.2",
"@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2",
"@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2",
"@oxc-resolver/binding-linux-arm64-gnu": "11.24.2",
"@oxc-resolver/binding-linux-arm64-musl": "11.24.2",
"@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2",
"@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2",
"@oxc-resolver/binding-linux-riscv64-musl": "11.24.2",
"@oxc-resolver/binding-linux-s390x-gnu": "11.24.2",
"@oxc-resolver/binding-linux-x64-gnu": "11.24.2",
"@oxc-resolver/binding-linux-x64-musl": "11.24.2",
"@oxc-resolver/binding-openharmony-arm64": "11.24.2",
"@oxc-resolver/binding-wasm32-wasi": "11.24.2",
"@oxc-resolver/binding-win32-arm64-msvc": "11.24.2",
"@oxc-resolver/binding-win32-x64-msvc": "11.24.2"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
@@ -2123,6 +2797,27 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
@@ -2169,6 +2864,17 @@
"util-deprecate": "~1.0.1"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.63.5",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz",
@@ -2215,6 +2921,30 @@
"fsevents": "~2.3.2"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
@@ -2250,6 +2980,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/smol-toml": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
"integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2301,6 +3044,19 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/strip-json-comments": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -2345,6 +3101,19 @@
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -2365,6 +3134,16 @@
"node": ">=14.17"
}
},
"node_modules/unbash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/unbash/-/unbash-2.2.0.tgz",
"integrity": "sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
}
},
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
@@ -2594,6 +3373,16 @@
}
}
},
"node_modules/walk-up-path": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz",
"integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==",
"dev": true,
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -2666,6 +3455,32 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.6.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
+2
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"docx-preview": "^0.4.1",
"dompurify": "^3.4.16",
"jszip": "^3.10.2",
"mammoth": "^1.13.0",
"marked": "^15.0.7",
@@ -24,6 +25,7 @@
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^4.3.4",
"knip": "^5.88.1",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vitest": "^4.1.11"
+23
View File
@@ -0,0 +1,23 @@
import { readFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
const run = spawnSync('npx', ['knip', '--reporter', 'json', '--include', 'exports,nsExports,types,nsTypes'], {
encoding: 'utf8',
})
if (!run.stdout) {
process.stderr.write(run.stderr || 'knip produced no output\n')
process.exit(1)
}
const data = JSON.parse(run.stdout)
let count = 0
for (const issue of data.issues ?? []) {
for (const key of ['exports', 'types', 'nsExports', 'nsTypes']) {
count += (issue[key] ?? []).length
}
}
const limit = JSON.parse(readFileSync(new URL('../knip-baseline.json', import.meta.url))).exports
if (count > limit) {
console.error(`knip unused exports ${count} exceed baseline ${limit}`)
process.exit(1)
}
console.log(`knip unused exports ${count} <= ${limit}`)
+10 -1
View File
@@ -15,6 +15,8 @@ import { AgentTracker } from './components/AgentTracker'
import { SettingsModal } from './components/SettingsModal'
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
import { DocViewer } from './components/DocViewer'
import { TopBar } from './components/TopBar'
import { workspaceEntries } from './components/Workspaces'
// 布局:左(项目会话)/ 中(对话)/ 预览区(有标签时)/ 右(工作台)。
const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
@@ -113,8 +115,11 @@ export function App() {
return <div className="login-gate"><div className="login-card"><p>连接服务中…</p></div></div>
}
const tools = workspaceEntries()
return (
<div className="app">
<TopBar />
<div className="app-body">
{!ui.leftCollapsed ? (
<>
<div className="left-nav" style={{ width: ui.leftWidth }}>
@@ -127,9 +132,12 @@ export function App() {
<div className="icon-only">
<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" />
<button title="收件箱" onClick={() => store.focusPending()}><Icon name="inbox" />
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
</button>
{tools.map(entry => (
<button key={entry.key} title={`${entry.title}(${entry.shortcut})`} onClick={() => store.openWorkspace(entry.key)}>{entry.icon}</button>
))}
<button title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" /></button>
</div>
</div>
@@ -164,6 +172,7 @@ export function App() {
</div>
</div>
)}
</div>
<Workspaces />
{trackerRunId && <AgentTracker />}
+50 -8
View File
@@ -3,7 +3,7 @@
import { auth } from './auth'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, PlanSnapshot, TodoList, UploadInfo, PromptModule,
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
ActivityCard,
} from './contracts'
@@ -27,6 +27,9 @@ export const api = {
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(r)),
setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
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 => {
@@ -46,10 +49,10 @@ export const api = {
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)),
conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r =>
json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number }>(r)),
json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
sendMessage: (convId: string, text: string, attachmentIds: string[]) =>
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds }) })
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) =>
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId }) })
.then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ message: Message; run?: Run; command?: string; reply?: string; conversation?: Conversation }>
@@ -107,6 +110,20 @@ export const api = {
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
excludeMessage: (id: string, excluded: boolean) => auth.fetch(`${BASE}/api/messages/${id}/exclude`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ excluded }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
selectVariant: (id: string) => auth.fetch(`${BASE}/api/messages/${id}/variant`, { method: 'POST' }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
regenerate: (convId: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/regenerate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
listSecrets: () => auth.fetch(`${BASE}/api/secrets`).then(r => json<{ id: string; service: string; label: string; active: boolean; mask: string }[]>(r)),
createSecret: (service: string, label: string, value: string) => auth.fetch(`${BASE}/api/secrets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service, label, value }) }).then(r => json(r)),
activateSecret: (id: string) => auth.fetch(`${BASE}/api/secrets/${id}/activate`, { method: 'POST' }),
deleteSecret: (id: string) => auth.fetch(`${BASE}/api/secrets/${id}`, { method: 'DELETE' }),
listContextEntries: () => auth.fetch(`${BASE}/api/context-entries`).then(r => json<{ id: string; keys: string[]; secondary?: string; logic: string; content: string; constant: boolean; scanDepth: number; budgetChars: number; enabled: boolean }[]>(r)),
saveContextEntry: (body: Record<string, unknown>) => auth.fetch(`${BASE}/api/context-entries`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)),
deleteContextEntry: (id: string) => auth.fetch(`${BASE}/api/context-entries/${id}`, { method: 'DELETE' }),
listPromptCommands: () => auth.fetch(`${BASE}/api/prompt-commands`).then(r => json<{ id: string; name: string; title: string; template: string }[]>(r)),
savePromptCommand: (body: { name: string; title: string; template: string }) => auth.fetch(`${BASE}/api/prompt-commands`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)),
deletePromptCommand: (id: string) => auth.fetch(`${BASE}/api/prompt-commands/${id}`, { method: 'DELETE' }),
listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>,
}
@@ -118,6 +135,34 @@ export interface SSEHandlers {
onError?: (err: Error) => void
}
// 契约里每一种事件都要有监听。漏掉一种时 tsc 会报缺字段。
const streamEventListeners: Record<StreamEventType, true> = {
'message.delta': true,
'thinking.delta': true,
'tool.call.delta': true,
'tool.call': true,
'tool.output': true,
'tool.result': true,
'run.phase': true,
'run.check': true,
'hitl.request': true,
'hitl.resolved': true,
'plan.update': true,
'todo.update': true,
'agent.spawn': true,
'agent.progress': true,
'agent.result': true,
'artifact.created': true,
'artifact.updated': true,
'run.completed': true,
'run.failed': true,
'error': true,
'card.created': true,
'card.updated': true,
'card.status': true,
'card.resolved': true,
}
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers, projectId?: string): { close: () => void } {
let closed = false
let es: EventSource | null = null
@@ -137,10 +182,7 @@ export function connectEvents(conversationId: string, since: number, handlers: S
}
es.onmessage = ev => handle('message', ev.data)
// 同时监听具名事件与默认(服务端 event: 字段)。
const types = ['message.delta', 'thinking.delta', 'tool.call', 'tool.result', 'run.phase',
'hitl.request', 'hitl.resolved', 'plan.update', 'todo.update', 'agent.spawn', 'agent.progress',
'agent.result', 'artifact.created', 'artifact.updated', 'run.completed', 'run.failed', 'error',
'card.created', 'card.updated', 'card.status', 'card.resolved']
const types = Object.keys(streamEventListeners) as StreamEventType[]
for (const t of types) es.addEventListener(t, ev => handle(t, (ev as MessageEvent).data))
}
+4 -2
View File
@@ -19,10 +19,12 @@ export function AgentTracker() {
// 中间过程:该会话消息树中归属子代理 run 的消息(run_id 过滤)。
const subMessages: Record<string, Message[]> = {}
for (const m of messagesAll) {
if (m.runId && m.runId !== runId) (subMessages[m.runId] ??= []).push(m)
if (m.role === 'assistant' && m.runId && m.runId !== runId) (subMessages[m.runId] ??= []).push(m)
}
const focus = selected ?? children[0]?.id ?? runId
const focusMsgs = focus === runId ? messagesAll.filter(m => m.runId === runId) : subMessages[focus] ?? []
const focusMsgs = focus === runId
? messagesAll.filter(m => m.role === 'assistant' && m.runId === runId)
: subMessages[focus] ?? []
return (
// 阶段③ 收敛:只保留 OverlayHost 一层壳(此前在其内又嵌 .modal 双层窗框)。
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useState } from 'react'
import { Btn, SettingRow, SettingsGroup } from '@ui'
import { api } from '../api'
const services = [
['models.chat', '主模型'],
['models.lightweight', '轻量模型'],
['models.embedding', 'Embedding'],
['models.rerank', 'Reranker'],
['tools.tavily', 'Tavily'],
] as const
type Secret = { id: string; service: string; label: string; active: boolean; mask: string }
type Entry = { id: string; keys: string[]; content: string; constant: boolean; logic: string }
type Cmd = { id: string; name: string; title: string; template: string }
export function BorrowPanels() {
const [secrets, setSecrets] = useState<Secret[]>([])
const [entries, setEntries] = useState<Entry[]>([])
const [cmds, setCmds] = useState<Cmd[]>([])
const [service, setService] = useState<string>(services[0][0])
const [label, setLabel] = useState('')
const [value, setValue] = useState('')
const [keys, setKeys] = useState('')
const [content, setContent] = useState('')
const [constant, setConstant] = useState(false)
const [cmdName, setCmdName] = useState('/summary')
const [cmdTitle, setCmdTitle] = useState('总结')
const [cmdBody, setCmdBody] = useState('请总结以上内容。\n{{input}}')
const reload = () => {
api.listSecrets().then(setSecrets).catch(() => undefined)
api.listContextEntries().then(setEntries).catch(() => undefined)
api.listPromptCommands().then(setCmds).catch(() => undefined)
}
useEffect(() => { reload() }, [])
return (
<>
<SettingsGroup title="密钥">
<SettingRow label="新增密钥" desc="每个服务同时只有一把生效。列表只显示掩码。">
<select value={service} onChange={e => setService(e.target.value)}>
{services.map(([id, name]) => <option key={id} value={id}>{name}</option>)}
</select>
</SettingRow>
<SettingRow label="标签与密钥" desc="保存后立即生效,并停用同服务的其他密钥。">
<input className="search-input" value={label} placeholder="标签" onChange={e => setLabel(e.target.value)} />
<input className="search-input" type="password" autoComplete="new-password" value={value} placeholder="密钥" onChange={e => setValue(e.target.value)} />
<Btn variant="primary" onClick={async () => {
if (!value.trim()) return
await api.createSecret(service, label || '未命名', value.trim())
setValue(''); setLabel(''); reload()
}}>添加</Btn>
</SettingRow>
{secrets.map(s => (
<SettingRow key={s.id} label={s.label} desc={`${s.service} · ${s.mask}${s.active ? ' · 生效' : ''}`}>
{!s.active && <Btn onClick={async () => { await api.activateSecret(s.id); reload() }}>启用</Btn>}
<Btn onClick={async () => { await api.deleteSecret(s.id); reload() }}>删除</Btn>
</SettingRow>
))}
</SettingsGroup>
<SettingsGroup title="条件注入">
<SettingRow label="关键词" desc="逗号分隔。用 /正则/ 表示正则。勾选后变为常驻。">
<input className="search-input" value={keys} placeholder="关键词" onChange={e => setKeys(e.target.value)} />
<label><input type="checkbox" checked={constant} onChange={e => setConstant(e.target.checked)} /> 常驻</label>
</SettingRow>
<SettingRow label="注入正文" desc="命中后附在上下文末尾,不改系统前缀。">
<textarea rows={3} value={content} onChange={e => setContent(e.target.value)} />
<Btn variant="primary" onClick={async () => {
if (!content.trim()) return
await api.saveContextEntry({
keys: keys.split(/[,,]/).map(s => s.trim()).filter(Boolean),
content: content.trim(), constant, logic: 'and', enabled: true, scanDepth: 4, budgetChars: 2000,
})
setContent(''); setKeys(''); reload()
}}>添加</Btn>
</SettingRow>
{entries.map(e => (
<SettingRow key={e.id} label={e.constant ? '常驻' : (e.keys.join(' / ') || '未命名')} desc={e.content}>
<Btn onClick={async () => { await api.deleteContextEntry(e.id); reload() }}>删除</Btn>
</SettingRow>
))}
</SettingsGroup>
<SettingsGroup title="用户命令">
<SettingRow label="命令" desc="发送 /名称 时展开成提示词。不会自动触发。{{input}} 换成命令后面的文字。">
<input className="search-input" value={cmdName} onChange={e => setCmdName(e.target.value)} />
<input className="search-input" value={cmdTitle} onChange={e => setCmdTitle(e.target.value)} />
</SettingRow>
<SettingRow label="模板" desc="名称需为小写字母、数字或连字符,例如 /summary。">
<textarea rows={3} value={cmdBody} onChange={e => setCmdBody(e.target.value)} />
<Btn variant="primary" onClick={async () => {
await api.savePromptCommand({ name: cmdName, title: cmdTitle, template: cmdBody })
reload()
}}>添加</Btn>
</SettingRow>
{cmds.map(c => (
<SettingRow key={c.id} label={c.name} desc={c.title}>
<Btn onClick={async () => { await api.deletePromptCommand(c.id); reload() }}>删除</Btn>
</SettingRow>
))}
</SettingsGroup>
</>
)
}
+281 -58
View File
@@ -1,27 +1,32 @@
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
// 重设计方案 阶段④:折叠区全部迁 @ui Collapsible(<details> 已禁);工具调用块走
// ToolBlock 契约(运行态扫光、完成边沿收起、展开态记忆);live 行扫光 + 已工作计时。
import { Icon, Avatar, BrandMark, Collapsible } from '@ui'
import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpen, SafeMarkdown } 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 { liveToMessage, showProcessInline } from '../live'
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
import { liveToMessage, livePhaseLabel, showProcessInline } from '../live'
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
import { docBridge } from './Workbench'
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, workedLabel } from '../messageparts'
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染,
// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。
function hasFiles(dt: DataTransfer | null): boolean {
return !!dt && Array.from(dt.types).includes('Files')
}
// 对话正文沿用 breaks:卡片渲染器曾经把全局 marked 设成 breaks,
// 去重后的同一份 marked 让对话也按行内换行显示。这里显式保持这个效果。
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
switch (part.type) {
case 'text':
return <div className={live ? 'part-text cursor-blink' : 'part-text'}
dangerouslySetInnerHTML={{ __html: marked.parse(markMissingArtifacts(part.text ?? '', id => !!findArtifact(id))) as string }} />
return <SafeMarkdown className={live ? 'part-text cursor-blink' : 'part-text'} breaks
markdown={markMissingArtifacts(part.text ?? '', id => !!findArtifact(id))} />
case 'thinking':
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
//(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。)
@@ -58,8 +63,21 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
</div>
)
}
case 'error':
return <div className="part-error">{part.error?.code}: {part.error?.message}</div>
case 'error': {
const view = humanError(part.error?.code, part.error?.message)
return (
<div className="part-error">
<div>{view.title}</div>
{view.detail && (
<Collapsible id={`err:${part.error?.code ?? 'e'}:${view.detail.length}`} triggerClassName="process-collapse-summary"
trigger={<><span className="process-collapse-chev">▸</span>详情</>}>
<div className="part-error-detail">{view.detail}</div>
</Collapsible>
)}
<button type="button" className="btn ghost" onClick={() => { void store.sendMessage('继续', []) }}>继续执行</button>
</div>
)
}
default:
return null
}
@@ -100,7 +118,7 @@ function ThinkingBlock({ part, idKey, defaultOpen }: { part: Part; idKey: string
}
// 交付物卡片:主区域和「预览」打开预览区;下载收进菜单,避免和预览抢同一个按钮。
function DeliverableCard({ artifact }: { artifact: { artifactId: string; name: string; kind?: string } }) {
function DeliverableCard({ artifact, updates = 0 }: { artifact: { artifactId: string; name: string; kind?: string }; updates?: number }) {
const [menu, setMenu] = useState(false)
const art = findArtifact(artifact.artifactId)
const name = artifact.name || art?.name || '文件'
@@ -113,7 +131,7 @@ function DeliverableCard({ artifact }: { artifact: { artifactId: string; name: s
<span className="dc-icon"><Icon name={icon} size={18} /></span>
<span className="dc-main">
<span className="dc-name">{name}</span>
<span className="dc-meta">{kindLabelOf(artifact.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{art ? '' : ' · 生成中'}</span>
<span className="dc-meta">{kindLabelOf(artifact.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{updates > 0 ? ` · 已更新 ${updates} 次` : ''}{art ? '' : ' · 生成中'}</span>
</span>
</button>
<button type="button" className="dc-act" onClick={open} disabled={!art}>预览</button>
@@ -144,6 +162,15 @@ function citedArtifactCards(parts: Part[]): Artifact[] {
}
// 已工作计时(ZCode「已工作 X 分 X 秒」摘要行;1s 步进,仅 live 期间挂载)。
function useLiveClock(active: boolean) {
const [, tick] = useState(0)
useEffect(() => {
if (!active) return
const t = window.setInterval(() => tick(n => n + 1), 1000)
return () => window.clearInterval(t)
}, [active])
}
function useElapsed(startedAt: number): string {
const [, tick] = useState(0)
useEffect(() => {
@@ -161,59 +188,116 @@ function LiveBar({ live }: { live: LiveState }) {
return (
<span className="live-bar">
<span className="live-dot" />
<span className="mk-sweep">{live.phase}</span>
<span className="mk-sweep">{livePhaseLabel(live)}</span>
<span className="live-elapsed">已工作 {elapsed}</span>
</span>
)
}
function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null }) {
// 用户在运行中手动折过的 run:结束后沿用这次选择,不强制再收起。
const touchedProcess = new Set<string>()
function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveState | null; convId?: string }) {
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
const run = useStore(s => (msg.runId ? s.runs.find(r => r.id === msg.runId) : undefined))
const parts = msg.parts ?? []
const split = useMemo(() => splitTimeline(parts), [parts])
// 运行中时间线一直展开。终轮确认没有后续工具调用之后,才按偏好收成「已工作 X」。
useLiveClock(!!live)
const finished = !live && !!run && run.status !== 'running' && run.status !== 'pending' && run.status !== 'waiting_hitl'
const split = useMemo(() => splitTimeline(parts, finished), [parts, finished])
const showProcess = showProcessInline(live ?? null, autoCollapse)
const extras = citedArtifactCards(split.final)
const memKey = `process:${msg.runId || msg.id}`
const touched = !!(msg.runId && touchedProcess.has(msg.runId))
const [open, setOpen] = useState(() => {
if (live && !touched) return true
if (touched) return getPersistedOpen(memKey)
return showProcess
})
useEffect(() => {
if (live && msg.runId && !touchedProcess.has(msg.runId)) setOpen(true)
}, [live, msg.runId])
const extras = live ? [] : citedArtifactCards(split.final)
const cards = live ? [] : dedupeDeliverables(split.final)
const lastText = (() => {
for (let i = split.final.length - 1; i >= 0; i--) if (split.final[i].type === 'text') return i
return -1
})()
const streamingText = !!(live && live.phase === 'responding' && lastText >= 0
&& !live.items.some(it => it.kind === 'tool' && it.state !== 'done'))
const toolCount = split.timeline.filter(p => p.type === 'tool_call').length
const headline = live ? livePhaseLabel(live) : processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)
const timeline = (
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
)
const toggleProcess = (next: boolean) => {
if (msg.runId) touchedProcess.add(msg.runId)
setPersistedOpen(memKey, next)
setOpen(next)
}
return (
<div className="msg assistant" data-msg-id={msg.id}>
<div className={`msg assistant${msg.excluded ? ' is-excluded' : ''}`} data-msg-id={msg.id}>
<div className="msg-role">
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
<span>助手</span>
{live && <LiveBar live={live} />}
</div>
{split.timeline.length > 0 && (showProcess
? <div className="part-group">{timeline}</div>
: (
<div className="process-collapse">
<Collapsible id={`process:${msg.id}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>{workedLabel(run?.createdAt, run?.endedAt)}</span>
</>}>
<div className="body">{timeline}</div>
</Collapsible>
</div>
))}
{split.final.map((part, i) => (
<div className="part" key={i}>
<PartView part={part} live={!!(live && i === lastText && part.type === 'text')} />
{split.timeline.length > 0 && (
<div className="process-collapse">
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span className={live ? 'mk-sweep' : ''}>{headline}</span>
</>}>
<div className="body">{timeline}</div>
</Collapsible>
</div>
)}
{split.final.map((part, i) => part.type === 'artifact_ref' ? null : (
<div className="part" key={i}>
<PartView part={part} live={!!(streamingText && i === lastText && part.type === 'text')} />
</div>
))}
{cards.map(card => (
<DeliverableCard key={card.artifact.artifactId} artifact={card.artifact} updates={card.updates} />
))}
{extras.map(art => (
<DeliverableCard key={art.id} artifact={{ artifactId: art.id, name: art.name, kind: art.kind }} />
))}
{live && <LiveAgentNotes live={live} />}
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
{convId && <MessageTools msg={msg} convId={convId} />}
</div>
)
}
function SessionNoteField({ convId, note }: { convId: string; note: string }) {
const [value, setValue] = useState(note)
useEffect(() => { setValue(note) }, [note, convId])
return (
<input className="search-input session-note" value={value} placeholder="本会话要求"
title="附在上下文末尾,不改系统前缀"
onChange={e => setValue(e.target.value)}
onBlur={() => { if (value !== note) void api.setSessionNote(convId, value).then(() => store.openConversation(convId)) }} />
)
}
function MessageTools({ msg, convId }: { msg: Message; convId: string }) {
const text = msg.parts.filter(p => p.type === 'text').map(p => p.text ?? '').join('\n')
const refresh = () => { void store.openConversation(convId) }
return (
<div className="msg-tools">
<button className="btn ghost" type="button" onClick={() => { void navigator.clipboard.writeText(text) }}>复制</button>
<button className="btn ghost" type="button" onClick={() => { void api.excludeMessage(msg.id, !msg.excluded).then(refresh) }}>
{msg.excluded ? '纳入上下文' : '排除出上下文'}
</button>
{msg.role === 'assistant' && (
<button className="btn ghost" type="button" onClick={() => { void api.regenerate(convId, msg.id).then(refresh) }}>重新生成</button>
)}
{msg.variantGroup && msg.excluded && (
<button className="btn ghost" type="button" onClick={() => { void api.selectVariant(msg.id).then(refresh) }}>使用此候选</button>
)}
{msg.variantGroup && <span className="process-collapse-meta">候选 {(msg.variantIndex ?? 0) + 1}</span>}
</div>
)
}
@@ -226,12 +310,18 @@ function ContextObsView({ obs }: { obs: ContextObs }) {
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>🧩 上下文构成</span>
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} 字符)</span>
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} token)</span>
</>}>
<div className="body">
<ul className="ui-list">
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
</ul>
{obs.sections && obs.sections.length > 0 && (
<div className="process-collapse-meta">
{obs.sections.map(s => `${s.name} ${s.estTokens}`).join(' · ')}
{obs.prefixHash ? ` · 前缀 ${obs.prefixHash}` : ''}
</div>
)}
{obs.truncations && obs.truncations.length > 0 && (
<div style={{ color: 'var(--warn)', fontSize: 'var(--fs-ui-sm)', marginTop: 6 }}>
{obs.truncations.map((t, i) => <div key={i}>⚠ {t}</div>)}
@@ -262,8 +352,9 @@ function HITLCardView({ card }: { card: HITLCard }) {
setSubmitting(false)
}
return (
<div className="hitl-card">
<div className={`hitl-card${card.kind === 'confirm' ? ' wait' : ''}${card.kind === 'choice' || card.kind === 'input' ? ' ask' : ''}`}>
<div className="hitl-q"><Icon name="check" size={15} />{card.question}</div>
{card.detail && <pre className="hitl-detail">{card.detail}</pre>}
{card.choices && (
<div className="hitl-choices">
{card.choices.map(c => (
@@ -341,6 +432,19 @@ function OutboxPanel() {
)
}
export function NoticeMessage({ msg }: { msg: Message }) {
const text = msg.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')
const preview = text.replace(/<\/?system-reminder>/g, '').trim().split('\n').find(line => line.trim()) ?? '系统通知'
return (
<div className="msg notice" data-msg-id={msg.id}>
<Collapsible id={`notice:${msg.id}`} triggerClassName="notice-summary"
trigger={<span className="notice-label">通知 · {preview}</span>}>
<div className="notice-body">{text}</div>
</Collapsible>
</div>
)
}
export function Conversation() {
const currentConvId = useStore(s => s.currentConvId)
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
@@ -349,23 +453,36 @@ export function Conversation() {
const activeHitl = useStore(s => s.activeHitl)
const live = useStore(s => s.live)
const runs = useStore(s => s.runs)
const posting = useStore(s => s.posting)
const connected = useStore(s => s.connected)
const artifacts = useStore(s => s.artifacts)
const profiles = useStore(s => s.profiles)
const cards = useStore(s => s.cards)
const projectName = useStore(s => s.projects.find(p => p.id === s.currentProjectId)?.name ?? '项目')
const runtimeMock = useStore(s => s.runtimeMock)
const [text, setText] = useState('')
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
const [dragDepth, setDragDepth] = useState(0)
const bodyRef = useRef<HTMLDivElement>(null)
const stickRef = useRef(true)
const [showJump, setShowJump] = useState(false)
artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a]))
cardCache = Object.fromEntries(cards.map(c => [c.id, c]))
const active = activeRun(runs)
const phase: ShellPhase = deriveShellPhase({
posting,
openRun: !!activeRun(runs),
liveRunId: live?.runId ?? null,
assistantRunIds: messages.filter(m => m.role === 'assistant' && m.runId).map(m => m.runId as string),
})
const busy = isShellBusy(phase)
const showStop = phase === 'running'
useEffect(() => {
if (!stickRef.current) return
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
}, [messages.length, live?.items, live?.phase, activeHitl.length])
@@ -375,7 +492,7 @@ export function Conversation() {
const ids = attachmentIds
const names = attachmentNames
setText(''); setAttachmentIds([]); setAttachmentNames([])
if (active) {
if (busy) {
store.enqueueOutbox(t, ids, names)
return
}
@@ -388,14 +505,29 @@ export function Conversation() {
<div className="chat-body"><div className="empty-state">
<div style={{ fontSize: 40 }}>⌘</div>
<div>选择或创建一个会话开始</div>
<div style={{ fontSize: 'var(--fs-ui-sm)' }}>mock 演示模式可在 设置 → runtime 开启,无需模型密钥</div>
{runtimeMock
? <div style={{ fontSize: 'var(--fs-ui-sm)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error</div>
: <div style={{ fontSize: 'var(--fs-ui-sm)' }}>演示模式可在设置里打开,无需模型密钥</div>}
</div></div>
</div>
)
}
return (
<div className="chat">
<div className={`chat ${dragDepth > 0 ? 'is-dragging' : ''}`}
onDragEnter={e => { if (!hasFiles(e.dataTransfer)) return; e.preventDefault(); setDragDepth(d => d + 1) }}
onDragOver={e => { if (hasFiles(e.dataTransfer)) e.preventDefault() }}
onDragLeave={() => setDragDepth(d => Math.max(0, d - 1))}
onDrop={async e => {
if (!e.dataTransfer?.files?.length) return
e.preventDefault()
setDragDepth(0)
for (const f of Array.from(e.dataTransfer.files)) {
const id = await store.uploadAttachment(f)
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
}
}}>
{dragDepth > 0 && <div className="drop-veil">释放以上传资料</div>}
<div className="chat-header">
<span className="project-badge" title="当前项目">{projectName}</span>
<div className="chat-title">{conversation?.title ?? '会话'}</div>
@@ -414,30 +546,40 @@ export function Conversation() {
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>}
<SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} />
<span title={connected ? '事件流已连接' : '重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
{!connected && <span className="conn-label">重连中</span>}
{showStop && <button className="btn ghost" onClick={() => store.cancelActive()}>停止</button>}
</div>
<div className="chat-body" ref={bodyRef}
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('drag-over') }}
onDragLeave={e => e.currentTarget.classList.remove('drag-over')}
onDrop={async e => {
e.preventDefault(); e.currentTarget.classList.remove('drag-over')
for (const f of Array.from(e.dataTransfer.files ?? [])) {
const id = await store.uploadAttachment(f)
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
}
onScroll={() => {
const src = bodyRef.current
if (!src) return
const dist = src.scrollHeight - src.scrollTop - src.clientHeight
const stick = dist < 80
stickRef.current = stick
setShowJump(prev => (prev === !stick ? prev : !stick))
const ratio = scrollRatio(src)
if (ratio === null) return
const dst = document.querySelector('[data-scroll-sync="inbox"]')
if (!dst) return
const top = scrollTopForRatio(dst, ratio)
if (top === null) return
dst.scrollTop = top
}}>
{messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent').map(m => {
{foldRunErrors(messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent')).map(m => {
if (m.role === 'user') {
// 内联确认卡(T3):锚定本消息的危险命令确认,双现于对话流与收件箱。
const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm')
return (
<div className="msg user" key={m.id} data-msg-id={m.id}>
<div className={`msg user${m.excluded ? ' is-excluded' : ''}`} key={m.id} data-msg-id={m.id}>
<div className="msg-role">
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
</div>
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
{currentConvId && <RewindControl convId={currentConvId} messageId={m.id} />}
{anchored.map(c => (
<InlineConfirmCard key={c.id} card={c} callbacks={{
onAction: (card, actionId) => store.cardAction(card, actionId),
@@ -447,17 +589,25 @@ export function Conversation() {
</div>
)
}
return <AssistantMessage key={m.id} msg={m} live={live?.runId === m.runId ? live : null} />
if (m.role === 'system') return <NoticeMessage key={m.id} msg={m} />
return <AssistantMessage key={m.id} msg={m} convId={currentConvId ?? undefined} live={live?.runId === m.runId ? live : null} />
})}
{live && !messages.some(m => m.runId === live.runId) &&
{showLiveBubble(live?.runId ?? null, messages.filter(m => m.role === 'assistant' && m.runId).map(m => m.runId as string)) && live &&
<AssistantMessage key={`live-${live.runId}`} msg={liveToMessage(live)} live={live} />}
{activeHitl.map(c => <HITLCardView key={c.id} card={c} />)}
{messages.length === 0 && <div className="empty-state">
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
<div>发送消息开始对话</div>
<div style={{ fontSize: 'var(--fs-ui-sm)', color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>
{runtimeMock && <div style={{ fontSize: 'var(--fs-ui-sm)', color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>}
</div>}
</div>
{showJump && (
<button type="button" className="jump-bottom" onClick={() => {
stickRef.current = true
setShowJump(false)
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
}}>回到底部</button>
)}
<div className="input-bar">
<OutboxPanel />
@@ -470,6 +620,19 @@ export function Conversation() {
)}
{text.startsWith('/') && <CommandMenu text={text} onPick={c => setText(c)} />}
<div className="input-row">
<label className="mode-select" title="本会话的权限模式">
<span>权限</span>
<select value={conversation?.permissionMode || 'default'} onChange={e => {
const mode = e.target.value
if (mode === 'default' || mode === 'plan' || mode === 'auto_edit') {
if (currentConvId) void store.setPermissionMode(currentConvId, mode)
}
}}>
<option value="default">默认</option>
<option value="plan">计划</option>
<option value="auto_edit">自动改文件</option>
</select>
</label>
<label className="icon-btn" title="上传附件(文档解析后注入上下文)">
<Icon name="paperclip" size={16} /><input type="file" multiple style={{ display: 'none' }} onChange={async e => {
for (const f of Array.from(e.target.files ?? [])) {
@@ -480,16 +643,76 @@ export function Conversation() {
}} />
</label>
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡'
: active ? '继续输入以排队'
: busy ? '继续输入以排队'
: artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
{active && (
{showStop && (
<button className="btn danger" onClick={() => store.cancelActive()}>停止</button>
)}
<button className="btn" onClick={send} disabled={!text.trim() && attachmentIds.length === 0}>{active ? '排队' : '发送'}</button>
<button className="btn" onClick={send} disabled={!text.trim() && attachmentIds.length === 0}>{busy ? '排队' : '发送'}</button>
</div>
</div>
</div>
)
}
function RewindControl({ convId, messageId }: { convId: string; messageId: string }) {
const [open, setOpen] = useState(false)
const [files, setFiles] = useState<{ restored: string[]; skipped: string[] } | null>(null)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
async function preview() {
setErr('')
setOpen(true)
setFiles(null)
try {
const plan = await api.checkpoints(convId, messageId)
setFiles({ restored: plan.restored ?? [], skipped: plan.skipped ?? [] })
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
}
}
async function confirm() {
setBusy(true)
setErr('')
try {
await api.rewindFiles(convId, messageId)
setOpen(false)
await store.openTask(convId)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
const canRewind = !!files && files.restored.length > 0
return (
<div className="rewind-row">
<button type="button" className="btn ghost" onClick={preview}>回滚文件</button>
{open && (
<div className="rewind-panel" role="dialog" aria-label="回滚文件">
{!files && !err && <div>正在列出文件…</div>}
{files && files.restored.length === 0 && files.skipped.length === 0 && <div>没有需要回滚的文件。</div>}
{files && files.restored.length > 0 && (
<>
<div>将恢复这些文件:</div>
<ul>{files.restored.map(p => <li key={p}>{p}</li>)}</ul>
</>
)}
{files && files.skipped.length > 0 && (
<>
<div>这些文件在追踪之外被改过,会跳过:</div>
<ul>{files.skipped.map(p => <li key={`skip-${p}`}>{p}</li>)}</ul>
</>
)}
{err && <div className="rewind-error">{err}</div>}
<div className="rewind-actions">
<button type="button" className="btn" disabled={busy || !canRewind} onClick={confirm}>确认回滚</button>
<button type="button" className="btn ghost" onClick={() => setOpen(false)}>取消</button>
</div>
</div>
)}
</div>
)
}
+40 -3
View File
@@ -1,10 +1,10 @@
// 工作时间线:思考、对用户的解说、同类工具合并成一行。运行中展开;
// 终轮结束后由调用方套进「已工作 X」折叠。
import type { ReactNode } from 'react'
import { Collapsible } from '@ui'
import { Collapsible, ToolBlock } from '@ui'
import type { Part } from '../contracts'
import { formatDuration } from '../messageparts'
import { timelineEntries, toolGroupLabel } from '../toolkind'
import { formatDuration, humanSize } from '../messageparts'
import { timelineEntries, toolGroupLabel, toolTail, type ToolGroupItem } from '../toolkind'
export function ProcessTimeline({ parts, live, idKey, renderPart }: {
parts: Part[]
@@ -33,6 +33,13 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
if (row.kind === 'text') {
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
}
if (row.kind === 'check') {
const text = row.passed ? '终轮检查通过' : `终轮检查未通过:${row.reason}`
return <div className="tl-check" key={`${idKey}:ck:${i}`}>{text}</div>
}
if (row.kind === 'toolblock') {
return <ToolCallBlock key={`${idKey}:tb:${i}`} item={row.item} />
}
if (row.kind === 'tools') {
const running = row.items.some(it => it.running)
return (
@@ -55,3 +62,33 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
</div>
)
}
function sizeLabel(n: number): string {
if (n < 1024) return `${n} 字符`
return humanSize(n)
}
function ToolCallBlock({ item }: { item: ToolGroupItem }) {
const composing = item.phase === 'composing'
const tail = item.running ? toolTail(item.arguments, item.output) : ''
const dur = !item.running ? formatDuration(item.durationMs) : ''
const detail = composing ? undefined : (dur ? `${item.headline} · ${dur}` : item.headline)
return (
<div className="tl-toolblock">
<ToolBlock
toolId={item.id}
name={item.name}
detail={detail}
running={item.running}
status={item.status}
statusText={composing ? `编写中 · ${sizeLabel(item.arguments?.length ?? 0)}` : undefined}
statusDetail={item.summary}
autoCollapseOnComplete
>
{item.arguments && <pre className="tool-preview">{item.arguments}</pre>}
{item.output && <pre className="tool-preview">{item.output}</pre>}
</ToolBlock>
{tail && <pre className="tool-live-preview">{tail}</pre>}
</div>
)
}
+28 -4
View File
@@ -4,9 +4,11 @@
// 插件工作区迁「系统管理」(AdminZone,管理员专属全屏视图)。
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { store } from '../store'
import { store, useStore } from '../store'
import type { ThemeChoice } from '../theme'
import type { SettingDesc } from '../contracts'
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
import { BorrowPanels } from './BorrowPanels'
type Section = 'settings'
@@ -22,6 +24,7 @@ export function SettingsModal() {
const [, setUser] = useState<Record<string, any>>({})
const [draft, setDraft] = useState<Record<string, any>>({})
const [saved, setSaved] = useState(false)
const theme = useStore(s => s.ui.theme)
useEffect(() => {
api.getSettings().then(s => {
@@ -63,15 +66,36 @@ export function SettingsModal() {
</>
) : undefined}
>
{section === 'settings' && Object.entries(groups).map(([g, descs]) => (
{section === 'settings' && (
<SettingsGroup title="外观">
<SettingRow label="主题" desc="墨绿是可选身份皮肤。跟随系统只在深色与浅色之间切换。深色和浅色会随设置保存" effect="hot">
<select value={theme} onChange={e => {
const next = e.target.value as ThemeChoice
store.setUI({ theme: next })
if (next === 'dark' || next === 'light') setDraft(x => ({ ...x, 'ui.theme': next }))
}}>
<option value="dark">深色</option>
<option value="light">浅色</option>
<option value="molv">墨绿</option>
<option value="system">跟随系统</option>
</select>
</SettingRow>
</SettingsGroup>
)}
{section === 'settings' && Object.entries(groups).map(([g, descs]) => {
const rows = descs.filter(d => d.key !== 'ui.theme')
if (rows.length === 0) return null
return (
<SettingsGroup key={g} title={groupNames[g] ?? g}>
{descs.map(d => (
{rows.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 === 'settings' && <BorrowPanels />}
</SettingsShell>
)
}
+61 -60
View File
@@ -1,20 +1,14 @@
// 左栏(方案 A「任务中心」,提案 2026-09-30 R1):单一主创建入口(P6)+
// 组织视图(P8:按项目/时间线)+ 置顶/归档 + 账户区(P7)。
// 重设计方案 阶段②(IA 对话优先):工件/代理/插件快捷条撤出普通用户界面——
// 工件走右栏「工件」页签,插件工作区归「系统管理」(管理员专属全屏视图)。
// 左栏:项目列表、当前项目的会话、体系工具、账户。
// 创建、搜索、置顶与归档仍在会话段里;领域工具由服务端工作区声明,没有时装上说明行。
import { useEffect, useRef, useState } from 'react'
import { store, useStore } from '../store'
import { auth } from '../auth'
import { api } from '../api'
import { Icon, BrandMark } from '@ui'
import { Icon } from '@ui'
import type { Conversation } from '../contracts'
import { groupByProject, groupByTimeline, relativeTime, filterTasks, type TaskOrg } from '../tasklist'
const GROUPS_KEY = 'as.taskGroups'
function loadCollapsed(): Record<string, boolean> {
try { return JSON.parse(localStorage.getItem(GROUPS_KEY) ?? '{}') } catch { return {} }
}
import { relativeTime, filterTasks } from '../tasklist'
import { projectStage } from '../projectmeta'
import { buildWorkspaceEntries } from '../workspaces'
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClose: () => void; children: React.ReactNode }) {
@@ -48,16 +42,15 @@ export function TaskSidebar() {
const me = useStore(s => s.me)
const currentConvId = useStore(s => s.currentConvId)
const currentProjectId = useStore(s => s.currentProjectId)
const org = useStore(s => s.ui.taskOrg)
const showArchived = useStore(s => s.ui.showArchived)
const serverWorkspaces = useStore(s => s.serverWorkspaces)
const theme = useStore(s => s.ui.theme)
const pendingBadge = useStore(s =>
s.cards.filter(c => c.status === 'action_required').length +
s.projectCards.filter(c => c.status === 'action_required').length)
const [q, setQ] = useState('')
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed)
const [menu, setMenu] = useState<{ kind: 'new' | 'filter' | 'account' | `row:${string}`; anchor: HTMLElement } | null>(null)
const [menu, setMenu] = useState<{ kind: 'new' | 'account' | `row:${string}`; anchor: HTMLElement } | null>(null)
const [creatingProject, setCreatingProject] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
@@ -72,19 +65,10 @@ export function TaskSidebar() {
return () => window.removeEventListener('keydown', h, true)
}, [])
const tglGroup = (key: string) => {
setCollapsed(c => {
const next = { ...c, [key]: !c[key] }
try { localStorage.setItem(GROUPS_KEY, JSON.stringify(next)) } catch { /* ignore */ }
return next
})
}
const setOrg = (o: TaskOrg) => store.setUI({ taskOrg: o })
const visible = filterTasks(tasks, q)
const tools = buildWorkspaceEntries(serverWorkspaces)
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
const groups = org === 'project' ? groupByProject(rest, projects) : groupByTimeline(rest)
const currentProject = projects.find(p => p.id === currentProjectId)
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
@@ -102,13 +86,12 @@ export function TaskSidebar() {
</span>
)
const taskRow = (t: Conversation, badge?: string) => (
const taskRow = (t: Conversation) => (
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
onClick={() => store.openTask(t.id)}
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
<span className="tsb-task-t" title={t.title}>{t.title || '未命名任务'}</span>
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
{badge && <span className="tsb-pbadge">{badge}</span>}
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
{rowActions(t)}
</div>
@@ -117,8 +100,7 @@ export function TaskSidebar() {
return (
<>
<div className="tsb-brand">
<BrandMark size={24} />
<span className="tsb-name">Agent-System</span>
<span className="tsb-name">项目</span>
<span style={{ flex: 1 }} />
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
</div>
@@ -138,12 +120,9 @@ export function TaskSidebar() {
</div>
</div>
{/* 组织视图切换(P8)+ 过滤 */}
<div className="tsb-orgrow">
<div className="tsb-pills">
<button className={`tsb-pill ${org === 'project' ? 'on' : ''}`} onClick={() => setOrg('project')}>按项目</button>
<button className={`tsb-pill ${org === 'timeline' ? 'on' : ''}`} onClick={() => setOrg('timeline')}>时间线</button>
</div>
<span className="tsb-seclabel" style={{ padding: 0 }}>本项目会话</span>
<span style={{ flex: 1 }} />
<button className={`tsb-iconbtn ${showArchived ? 'active' : ''}`} title={showArchived ? '隐藏归档' : '查看归档'}
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
</div>
@@ -164,34 +143,55 @@ export function TaskSidebar() {
</div>
)}
<div className="tsb-seclabel">项目</div>
{projects.map(p => {
const stage = projectStage(p.settings)
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
return (
<button key={p.id} className={`tsb-project ${p.id === currentProjectId ? 'active' : ''}`}
onClick={() => {
const next = tasks.find(t => t.projectId === p.id && t.id === currentConvId)
?? tasks.find(t => t.projectId === p.id && !t.archived)
if (next) store.openTask(next.id)
else store.openProject(p.id)
}}>
<span className="tsb-project-name">{p.name}</span>
<span className="tsb-project-meta">{stage ? `${stage} · ` : ''}{count} 个会话</span>
</button>
)
})}
{projects.length === 0 && <div className="tsb-empty">还没有项目——从「新建任务」旁的菜单创建</div>}
{pinned.length > 0 && (
<>
<div className="tsb-seclabel">置顶</div>
{pinned.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
{pinned.map(t => taskRow(t))}
</>
)}
{groups.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">尚无任务——点击「新建任务」开始<br />(项目内素材上传、检索与生成全程可在对话中完成)</div>
{rest.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">这个项目还没有会话——点击「新建任务」开始</div>
)}
{rest.map(t => taskRow(t))}
{groups.map(g => (
<div key={g.key} className={`tsb-group ${collapsed[g.key] ? 'closed' : ''}`}>
{org === 'project' ? (
<div className="tsb-group-head" onClick={() => tglGroup(g.key)}>
<span className="tsb-chev"><Icon name="chevron-right" size={13} /></span>
<span className="tsb-group-name" title={g.label}>{g.label}</span>
<span className="tsb-group-cnt">{g.tasks.length}</span>
<span style={{ flex: 1 }} />
<button className="tsb-group-add" title="项目设置(含跨会话读取)" onClick={e => { e.stopPropagation(); if (g.projectId) store.openProjectSettings(g.projectId) }}><Icon name="settings" size={13} /></button>
<button className="tsb-group-add" title="在此项目新建任务" onClick={e => { e.stopPropagation(); store.createTask(g.projectId) }}><Icon name="plus" size={13} /></button>
</div>
) : (
<div className="tsb-seclabel">{g.label}</div>
)}
{!collapsed[g.key] && g.tasks.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
</div>
<div className="tsb-seclabel">
体系工具
{pendingBadge > 0 && <span className="tsb-tool-badge" title="待处理">{pendingBadge}</span>}
</div>
{tools.map(entry => (
<button key={entry.key} className="tsb-tool" title={entry.shortcut} onClick={() => store.openWorkspace(entry.key)}>
<span className="tsb-tool-icon">{entry.icon}</span>
<span className="tsb-tool-name">{entry.title}</span>
<kbd>{entry.shortcut}</kbd>
</button>
))}
{serverWorkspaces.length === 0 && <div className="tsb-tool-note">领域工具由系统级插件提供</div>}
{currentProject && (
<button className="tsb-tool" onClick={() => store.openProjectSettings(currentProject.id)}>
<span className="tsb-tool-icon"><Icon name="settings" size={14} /></span>
<span className="tsb-tool-name">项目设置</span>
</button>
)}
</div>
{/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */}
@@ -205,7 +205,7 @@ export function TaskSidebar() {
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
</button>
<button className="tsb-iconbtn" title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" size={15} /></button>
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.openInbox()}><Icon name="inbox" size={15} />{pendingBadge}</button>}
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.focusPending()}><Icon name="inbox" size={15} />{pendingBadge}</button>}
</div>
{/* 弹出菜单们 */}
@@ -226,7 +226,7 @@ export function TaskSidebar() {
<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>
<MenuItem icon="settings" onClick={() => { if (rowTask) store.openProjectSettings(rowTask.projectId); setMenu(null) }}>项目设置</MenuItem>
</>}
</Menu>
@@ -240,11 +240,12 @@ export function TaskSidebar() {
</div>
<div className="tsb-menu-div" />
<MenuItem icon="settings" onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
<MenuItem icon="inbox" onClick={() => { store.openInbox(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
<MenuItem icon="inbox" onClick={() => { store.focusPending(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
<MenuItem icon="key" onClick={() => { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码…</MenuItem>
<MenuItem icon={theme === 'dark' ? 'sun' : 'moon'} onClick={() => { store.setUI({ theme: theme === 'dark' ? 'light' : 'dark' }); setMenu(null) }}>
{theme === 'dark' ? '切换浅色主题' : '切换深色主题'}
</MenuItem>
<MenuItem icon="moon" onClick={() => { store.setUI({ theme: 'dark' }); setMenu(null) }}>深色主题{theme === 'dark' ? ' · 当前' : ''}</MenuItem>
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
{me?.isAdmin && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{me?.auth && <>
<div className="tsb-menu-div" />
+24
View File
@@ -0,0 +1,24 @@
// 顶栏:产品标识、当前项目与阶段徽章、待处理铃铛。不参与具体操作。
import { BrandMark, Icon, pendingCount } from '@ui'
import { store, useStore } from '../store'
import { projectStage } from '../projectmeta'
export function TopBar() {
const project = useStore(s => s.projects.find(p => p.id === s.currentProjectId))
const pending = useStore(s => pendingCount(s.cards) + pendingCount(s.projectCards))
const stage = projectStage(project?.settings)
return (
<header className="topbar">
<BrandMark size={22} />
<span className="topbar-name">Agent-System</span>
{project && <span className="topbar-project" title="当前项目">{project.name}</span>}
{stage && <span className="topbar-stage" title="当前阶段">{stage}</span>}
<span className="topbar-spacer" />
<button className="topbar-bell" title={pending > 0 ? `待处理 ${pending},打开收件箱` : '收件箱'}
onClick={() => store.focusPending()}>
<Icon name="inbox" size={16} />
{pending > 0 && <span className="rail-badge" data-testid="topbar-pending">{pending}</span>}
</button>
</header>
)
}
+24 -31
View File
@@ -1,9 +1,9 @@
// 右栏:收件箱与文件。文件预览在对话右侧的预览区,不再占右栏页签。
// 右栏:卡片流。全部 / 待处理筛选、收纳、本会话与项目范围。
// 文件不占右栏页签——工件走体系工具(⌘)与预览区,一个对象一个入口。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge } from '@ui'
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
import { Icon } from '@ui'
import { ArtifactsPanel } from './ArtifactsPanel'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
// 打开语义走 requestPreview 唯一入口(预览区)。
@@ -24,7 +24,7 @@ const inboxCallbacks = {
}
/** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */
function ProjectInbox({ cards }: { cards: ActivityCard[] }) {
function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxFilter }) {
const own = cards.filter(c => !c.conversationId)
const cross = cards.filter(c => !!c.conversationId)
if (cards.length === 0) {
@@ -34,12 +34,12 @@ function ProjectInbox({ cards }: { cards: ActivityCard[] }) {
<>
<div className="inbox-zone-label">📁 项目级卡片(跨会话可见)</div>
{own.length > 0
? <InboxList cards={own} callbacks={inboxCallbacks} docBridge={docBridge} />
? <InboxList cards={own} callbacks={inboxCallbacks} docBridge={docBridge} filter={filter} />
: <div className="inbox-empty" style={{ padding: '4px 14px' }}>暂无——会话卡点 🗂 即留档至此</div>}
{cross.length > 0 && (
<>
<div className="inbox-zone-label">🕓 其他会话待办</div>
<InboxList cards={cross} callbacks={inboxCallbacks} docBridge={docBridge} showPinnedZone={false} />
<InboxList cards={cross} callbacks={inboxCallbacks} docBridge={docBridge} showPinnedZone={false} filter={filter} />
</>
)}
</>
@@ -52,39 +52,32 @@ export function Workbench() {
const todos = useStore(s => s.todos)
const inboxScope = useStore(s => s.inboxScope)
const projectCards = useStore(s => s.projectCards)
const pending = pendingCount(cards) + pendingCount(projectCards)
const rightTab = useStore(s => s.ui.rightTab)
const showFiles = rightTab === 'files'
const filter = useStore(s => s.ui.inboxFilter)
const visible = inboxScope === 'project' ? projectCards : cards
const pending = pendingCount(visible)
return (
<>
<div className="tabs">
<button className={`tab ${!showFiles ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'inbox' })}
title="收件箱">收件箱{pending > 0 ? ` ${pending}` : ''}</button>
<button className={`tab ${showFiles ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'files', rightCollapsed: false })}
title="文件(与 ⌘1 工件面板同一份列表)">文件</button>
<button className={`tab ${filter === 'all' ? 'active' : ''}`} onClick={() => store.setUI({ inboxFilter: 'all' })}
title="全部卡片">全部</button>
<button className={`tab ${filter === 'todo' ? 'active' : ''}`} onClick={() => store.setUI({ inboxFilter: 'todo' })}
title="只看待处理">待处理{pending > 0 ? ` ${pending}` : ''}</button>
<span className="spacer" style={{ flex: 1 }} />
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
<button className="tab" title="收起收件箱" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
</div>
{showFiles ? (
<div className="tab-body">
<ArtifactsPanel />
<div className="tab-body" data-scroll-sync="inbox">
<CurrentTaskCard plan={plan} todos={todos} />
<div className="inbox-scope" role="tablist" aria-label="收件箱范围">
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
</button>
</div>
) : (
<div className="tab-body">
<CurrentTaskCard plan={plan} todos={todos} />
<div className="inbox-scope" role="tablist" aria-label="收件箱范围">
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
</button>
</div>
{inboxScope === 'project'
? <ProjectInbox cards={projectCards} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} />}
</div>
)}
{inboxScope === 'project'
? <ProjectInbox cards={projectCards} filter={filter} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
</div>
</>
)
}
+10 -19
View File
@@ -1,26 +1,16 @@
// 工作区弹层(提案 T4/D25):工件/代理 经 OverlayHost 呈现。
// 重设计方案 阶段②(IA 对话优先):插件面板/服务端声明工作区属系统能力,
// 只进「系统管理」分区(AdminZone),不占普通用户的 ⌘1–9;清单按角色收窄。
// 工作区弹层(提案 T4/D25):深读经 OverlayHost 单层呈现。
// 服务端声明的工作区(领域插件槽位)对所有用户可见;插件管理面板仍只在系统管理区。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { OverlayHost, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
import { ArtifactsPanel } from './ArtifactsPanel'
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区仅管理员可见。 */
export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] {
const s = store.getState()
const admin = !!s.me?.isAdmin
const out = [
{ key: 'artifacts', title: '工件', icon: '📦', shortcut: '⌘1' },
{ key: 'agents', title: '代理', icon: '🤖', shortcut: '⌘2' },
...(admin ? [
...s.pluginPanels.map((p, i) => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', shortcut: `⌘${i + 3}` })),
...s.serverWorkspaces.map((w, i) => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', shortcut: w.shortcut ? `⌘${w.shortcut}` : `⌘${i + 3 + s.pluginPanels.length}` })),
] : []),
]
return out.slice(0, 9)
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
export function workspaceEntries(): WorkspaceEntry[] {
return buildWorkspaceEntries(store.getState().serverWorkspaces)
}
export function Workspaces() {
@@ -34,13 +24,13 @@ export function Workspaces() {
const close = () => store.closeWorkspace()
if (active === 'artifacts') {
return <OverlayHost title="工件" icon="📦" shortcut="⌘1" badge={artifacts.length || undefined} onClose={close}>
return <OverlayHost title="工件" icon="📦" shortcut={entry?.shortcut ?? '⌘1'} sayHint="打开工件" badge={artifacts.length || undefined} onClose={close}>
<ArtifactsPanel />
</OverlayHost>
}
if (active === 'agents') {
const convRuns = runs.filter(r => r.kind !== 'tool')
return <OverlayHost title="代理" icon="🤖" shortcut="⌘2" onClose={close}>
return <OverlayHost title="代理" icon="🤖" shortcut={entry?.shortcut ?? '⌘2'} sayHint="打开代理" onClose={close}>
<AgentsPanel runs={convRuns} />
</OverlayHost>
}
@@ -51,7 +41,8 @@ export function Workspaces() {
}
if (active.startsWith('ws:')) {
const ws = serverWorkspaces.find(w => `ws:${w.id}` === active)
return <OverlayHost title={ws?.title ?? '工作区'} icon={ws?.icon || '🗂'} shortcut={entry?.shortcut} onClose={close}
const title = ws?.title ?? '工作区'
return <OverlayHost title={title} icon={ws?.icon || '🗂'} shortcut={entry?.shortcut} sayHint={`打开${title}`} onClose={close}
nodes={ws?.body ?? []} docBridge={docBridge as DocBridge} onRefresh={() => store.refreshPlugins()} />
}
return null
+56 -4
View File
@@ -16,6 +16,8 @@ export interface Conversation {
id: ID; projectId: ID; owner?: ID; title: string; profileId?: ID;
pinned?: boolean; archived?: boolean;
allowCrossRead?: boolean; crossPrivate?: boolean;
permissionMode?: 'default' | 'plan' | 'auto_edit' | '';
sessionNote?: string;
createdAt: string; updatedAt: string;
}
@@ -23,7 +25,7 @@ export type Role = 'user' | 'assistant' | 'system';
export type PartType =
| 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'artifact_ref'
| 'hitl' | 'plan' | 'error' | 'card';
| 'hitl' | 'plan' | 'error' | 'card' | 'check';
export interface Part {
type: PartType;
@@ -35,6 +37,7 @@ export interface Part {
plan?: PlanSnapshot;
card?: { kind: string; data: UINode[]; cardId?: ID }; // cardId 非空 = 收件箱卡片引用部件(submit_card)
error?: { code: string; message: string };
check?: { passed: boolean; reason?: string }; // 终轮自动检查,过程区一行
process?: boolean; // run 终态后折叠为摘要行
durationMs?: number; // thinking 部件的思考时长(毫秒);工具耗时在 toolCall.durationMs
}
@@ -42,17 +45,21 @@ export interface Part {
export interface Message {
id: ID; conversationId: ID; runId?: ID; role: Role;
parts: Part[]; createdAt: string;
excluded?: boolean;
variantGroup?: string;
variantIndex?: number;
}
export type RunKind = 'conversation' | 'subagent' | 'tool';
export type RunStatus =
| 'pending' | 'running' | 'waiting_hitl' | 'completed'
| 'failed' | 'cancelled' | 'interrupted';
export type RunPhase = 'thinking' | 'acting' | 'responding' | 'done';
export type RunPhase = 'thinking' | 'acting' | 'responding' | 'finalizing' | 'done';
export interface Usage {
promptTokens: number; completionTokens: number; totalTokens: number;
durationMs: number; toolCalls: number;
model?: string;
}
export interface Run {
@@ -80,8 +87,9 @@ export interface TodoList {
// ---- 流式事件 ----
export type StreamEventType =
| 'message.delta' | 'thinking.delta' | 'tool.call' | 'tool.result'
| 'run.phase' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update'
| 'message.delta' | 'thinking.delta'
| 'tool.call.delta' | 'tool.call' | 'tool.output' | 'tool.result'
| 'run.phase' | 'run.check' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update'
| 'agent.spawn' | 'agent.progress' | 'agent.result'
| 'artifact.created' | 'artifact.updated'
| 'run.completed' | 'run.failed' | 'error'
@@ -94,6 +102,9 @@ export interface StreamEvent<P = unknown> {
// payload 形状(按 type 判别;未知字段忽略)
export interface MessageDeltaPayload { index: number; text: string }
export interface ToolCallDeltaPayload { index: number; callId?: string; name?: string; delta: string }
export interface ToolOutputPayload { callId: string; stream: 'stdout' | 'stderr'; text: string }
export interface ToolResultPayload { callId: string; status: string; summary?: string; truncated?: boolean; durationMs?: number }
export interface AgentSpawnPayload { agentRun: ID; name: string; task: string }
export interface AgentProgressPayload { agentRun: ID; note?: string; phase?: RunPhase }
export interface AgentResultPayload { agentRun: ID; status: RunStatus; summary?: string }
@@ -103,11 +114,19 @@ export interface RunCompletedPayload {
context?: ContextObservation
}
interface ContextSection {
name: string
estTokens: number
}
export interface ContextObservation {
items: string[]
truncations?: string[]
estChars: number
sections?: ContextSection[]
prefixHash?: string
}
export interface RunCheckPayload { passed: boolean; reason?: string }
export interface RunFailedPayload { code: string; message: string }
// ---- 收件箱卡片 ----
@@ -145,6 +164,7 @@ export interface HITLCard {
choices?: HITLChoice[]; allowFreeInput: boolean; freeInputHint?: string;
timeoutSec?: number | null; form?: UINode[]; createdAt: string; resolved: boolean;
anchorMessageId?: ID; blocked?: boolean; blockReason?: string;
detail?: string;
}
export interface HITLResponse {
@@ -210,6 +230,8 @@ export interface PromptModule {
body: string; outputContract?: string;
source?: 'builtin' | 'plugin' | 'user'; owner?: string;
basedOnVersion?: number; drifted?: boolean; disabled?: boolean;
/** 子代理 profile。只对 agent.sub.* 有意义;空字段表示沿用内置。 */
tools?: string[]; model?: string; maxTurns?: number;
}
export interface PromptBlock { moduleId: string; version: number; layer: string; body: string; }
@@ -260,6 +282,36 @@ export interface PluginPanel {
id: string; title: string; icon?: string; body: UINode[]; refreshTool?: string;
}
/** 模板占位符:{{input}}、{{slots.<key>}}、{{steps.<id>.output}}。 */
export interface WorkflowDef {
id: string; title: string; description?: string; intentId?: string;
/** 整条工作流的 critic 重试上限。 */
maxRetries?: number;
steps: WorkflowStep[];
}
export interface StepCritic {
criteria: string;
/** 本步重试上限;缺省 1 次。 */
maxRetries?: number;
}
export interface WorkflowStep {
id: string; title: string;
kind: 'prompt' | 'tool' | 'hitl' | 'artifact' | 'card' | 'script';
prompt?: string;
/** prompt 步骤的子代理 profile,缺省 general。 */
agent?: string;
tool?: string;
args?: unknown;
hitl?: { question: string; kind: HITLKind; choices?: HITLChoice[]; allowFreeInput: boolean; freeInputHint?: string };
artifact?: string;
card?: { kind: CardKind; title: string; tone?: string; body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[] };
/** script 步骤在插件目录内的相对路径。 */
script?: string;
critic?: StepCritic;
}
export interface PluginWorkspace {
id: string; title: string; icon?: string; shortcut?: string;
badgeTool?: string; body: UINode[]; refreshTool?: string;
+186 -37
View File
@@ -1,20 +1,41 @@
// live 流式投影纯逻辑(中栏过程/回复分离):事件按到达序并入 items——thinking/text
// 增量各并入其末段,工具按当前位置落位。SSE 有序且后端每轮按 思考→正文→工具 追加落库
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定,
// 对齐落库的 Process: len(calls) > 0——工具轮解说的 tool.call 一到即并入过程区,
// 无需等下一轮文本段开启。
// 残余边界:某轮若既无正文也无思考、仅静默发起工具,其调用会被并入上一文本段(需协议
// 携带轮次归属才能消除;现行事件流无法区分)。
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定。
// 参数增量(编写中)一到即算工具,解说不用等工具真正开始执行才离开回复区。
import type { Message, Part } from './contracts'
import type { LiveState } from './store'
export type ToolLiveState = 'composing' | 'running' | 'done'
// 到达序部件流的元素。
export type LiveItem =
| { kind: 'thinking'; text: string; startedAt: number; endedAt: number }
| { kind: 'text'; index: number; text: string }
| { kind: 'tool'; callId?: string; name: string; status?: string; summary?: string }
| {
kind: 'tool'
callId?: string
index?: number
name: string
args: string
state: ToolLiveState
startedAt: number
endedAt: number
output: string
status?: string
summary?: string
}
| { kind: 'artifact'; artifact: { artifactId: string; name: string; kind?: string } }
| { kind: 'check'; passed: boolean; reason: string }
| { kind: 'final' }
export type LiveTool = Extract<LiveItem, { kind: 'tool' }>
const OUTPUT_TAIL = 4096
function isTool(it: LiveItem): it is LiveTool {
return it.kind === 'tool'
}
// applyThinkingDelta 并入思考增量:末段是思考则追加,否则开启新思考段(新轮开始)。
// now 供测试固定首尾时间;缺省用墙上时钟,durationMs = endedAt - startedAt。
@@ -39,45 +60,139 @@ export function applyTextDelta(items: LiveItem[], index: number, delta: string):
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[] {
// applyToolCallDelta 并入正在编写的参数。同 index(或同 callId)的编写中条目追加,否则新开一条。
export function applyToolCallDelta(items: LiveItem[], index: number, delta: string, callId?: string, name?: string, now = Date.now()): 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
if (!isTool(it) || it.state !== 'composing') continue
if (it.index !== index && !(callId && it.callId === callId)) continue
const out = items.slice()
out[i] = { kind: 'tool', callId: it.callId, name: it.name, status, summary }
out[i] = {
...it,
args: it.args + delta,
callId: callId || it.callId,
name: name || it.name,
endedAt: now,
}
return out
}
return [...items, {
kind: 'tool', index, callId, name: name || '工具', args: delta,
state: 'composing', startedAt: now, endedAt: now, output: '',
}]
}
// applyToolCall 把一条调用标成运行中。优先按 callId 接上编写中的条目,否则接第一条编写中的条目。
export function applyToolCall(items: LiveItem[], name: string, callId?: string, args?: string, now = 0): LiveItem[] {
let idx = -1
if (callId) {
for (let i = 0; i < items.length; i++) {
const it = items[i]
if (isTool(it) && it.state === 'composing' && it.callId === callId) { idx = i; break }
}
}
if (idx < 0) {
for (let i = 0; i < items.length; i++) {
const it = items[i]
if (isTool(it) && it.state === 'composing') { idx = i; break }
}
}
if (idx >= 0) {
const it = items[idx] as LiveTool
const out = items.slice()
out[idx] = {
...it,
name: name || it.name,
callId: callId || it.callId,
args: args || it.args,
state: 'running',
startedAt: now || it.startedAt,
endedAt: now,
}
return out
}
return [...items, {
kind: 'tool', callId, name, args: args ?? '', state: 'running',
startedAt: now, endedAt: now, output: '',
}]
}
// applyToolOutput 把执行中的输出追加到对应工具,只保留尾部,避免长日志撑住内存。
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string): LiveItem[] {
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
if (!isTool(it) || it.state === 'done') continue
if (callId && it.callId && it.callId !== callId) continue
let output = it.output + text
if (output.length > OUTPUT_TAIL) output = output.slice(output.length - OUTPUT_TAIL)
const out = items.slice()
out[i] = { ...it, output }
return out
}
return items
}
// liveToMessage:run 进行中没有已落库的助手消息,把 LiveState 合成同构临时消息,
// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);终态对账后由真消息替代。
// applyToolResult 回填对应工具(优先按 callId 配对,回落到最近一个未决工具)。
export function applyToolResult(items: LiveItem[], callId: string | undefined, status: string, summary?: string, now = 0): LiveItem[] {
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
if (!isTool(it) || it.state === 'done') continue
if (callId && it.callId && it.callId !== callId) continue
const out = items.slice()
out[i] = { ...it, callId: it.callId, name: it.name, status, summary, state: 'done', endedAt: now || it.endedAt }
return out
}
return items
}
export function applyCheck(items: LiveItem[], passed: boolean, reason: string): LiveItem[] {
return [...items, { kind: 'check', passed, reason }]
}
// applyFinalizing 标记收尾开始:此前的正文都是过程,此后的正文才是给用户的回复。
export function applyFinalizing(items: LiveItem[]): LiveItem[] {
if (items.some(it => it.kind === 'final')) return items
return [...items, { kind: 'final' }]
}
export function applyArtifactCreated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] {
return [...items, { kind: 'artifact', artifact }]
}
export function applyArtifactUpdated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] {
const out = items.slice()
for (let i = out.length - 1; i >= 0; i--) {
const it = out[i]
if (it.kind === 'artifact' && it.artifact.artifactId === artifact.artifactId) {
out[i] = { kind: 'artifact', artifact: { ...it.artifact, ...artifact } }
return out
}
}
return [...out, { kind: 'artifact', artifact }]
}
// liveToMessage:run 进行中没有已落库的助手消息,把 LiveState 合成同构临时消息。
export function liveToMessage(live: LiveState): Message {
// 文本段是否为过程:本段之后、下一个思考/文本段之前出现工具(= 该轮带工具调用)。
const finalAt = live.items.findIndex(it => it.kind === 'final')
const textProcess = new Map<number, boolean>()
for (let i = 0; i < live.items.length; i++) {
if (live.items[i].kind !== 'text') continue
if (finalAt >= 0) {
textProcess.set(i, i < finalAt)
continue
}
let hasCalls = false
for (let j = i + 1; j < live.items.length && live.items[j].kind === 'tool'; j++) hasCalls = true
textProcess.set(i, hasCalls)
}
const parts: Part[] = []
const deliverables: Part[] = []
for (let i = 0; i < live.items.length; i++) {
const it = live.items[i]
if (it.kind === 'final' || it.kind === 'artifact') continue
if (it.kind === 'check') {
parts.push({ type: 'check', process: true, check: { passed: it.passed, reason: it.reason || undefined } })
continue
}
if (it.kind === 'thinking') {
const durationMs = it.endedAt - it.startedAt
if (it.text) parts.push({
@@ -85,25 +200,59 @@ export function liveToMessage(live: LiveState): Message {
...(durationMs > 0 ? { durationMs } : {}),
})
} 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 } })
const durationMs = it.state === 'done' ? it.endedAt - it.startedAt : 0
const call = {
id: it.callId ?? '',
name: it.name,
...(it.args ? { arguments: it.args } : {}),
...(durationMs > 0 ? { durationMs } : {}),
}
parts.push({
type: 'tool_call', process: true, toolCall: call,
toolPhase: it.state, ...(it.output ? { toolOutput: it.output } : {}),
} as Part)
if (it.state === 'done') {
parts.push({
type: 'tool_result', process: true, toolCall: { id: call.id, name: call.name },
toolResult: { callId: call.id, status: it.status === 'error' ? 'error' : 'ok', summary: it.summary },
})
}
} else if (it.kind === 'artifact') {
// 交付物卡片:内容部件,投影钉尾(落库侧 llm.go 同序——最终正文之后),
// 流式期间即可在消息底部看到"文件已生成"卡片。
deliverables.push({ type: 'artifact_ref', artifact: it.artifact })
} else {
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
}
}
parts.push(...deliverables)
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
}
// showProcessInline:运行中时间线始终展开(不能靠 responding 提前折,那一轮文字
// 后面还可能再调用工具)。run 结束后随「自动折叠过程」偏好收成一行。
// livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。
export function livePhaseLabel(live: LiveState, now = Date.now()): string {
if (live.phase === 'finalizing') return '整理回复中'
let last: LiveItem | undefined
for (let i = live.items.length - 1; i >= 0; i--) {
const kind = live.items[i].kind
if (kind !== 'artifact' && kind !== 'check' && kind !== 'final') { last = live.items[i]; break }
}
if (!last || last.kind === 'thinking') return '思考中'
if (last.kind === 'tool') {
if (last.state === 'composing') return `正在编写 ${last.name}`
if (last.state === 'running') {
const s = Math.max(0, Math.floor((now - last.startedAt) / 1000))
const m = Math.floor(s / 60)
const clock = m > 0 ? `${m} 分 ${s % 60} 秒` : `${s} 秒`
return `正在运行 ${last.name} · ${clock}`
}
return '回复中'
}
return '回复中'
}
// showProcessInline:运行中时间线始终展开。run 结束后随「自动折叠过程」偏好收成一行。
export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean {
return live != null || !autoCollapse
}
// hasSettledReply:助手回复落库后,这个 run 的实时气泡才该收起。
// 用户消息也会带同一个 runId,不能拿它判断回复已经落库。
export function hasSettledReply(messages: { role: string; runId?: string }[], runId: string): boolean {
return messages.some(m => m.role === 'assistant' && m.runId === runId)
}
+81 -3
View File
@@ -7,12 +7,90 @@ export interface TimelineSplit {
final: Part[]
}
/** 最后一个 process 部件之前(含自身)是工作时间线,其余是最终回复。 */
export function splitTimeline(parts: Part[]): TimelineSplit {
/** 最后一个 process 部件之前(含自身)是工作时间线,其余是最终回复。
* promoteLastText:回复区没有正文时,把时间线里最后一段解说提上来。失败或被截断的 run 用它保住可见回复。 */
export function splitTimeline(parts: Part[], promoteLastText = false): TimelineSplit {
let last = -1
for (let i = 0; i < parts.length; i++) if (parts[i].process) last = i
if (last < 0) return { timeline: [], final: parts }
return { timeline: parts.slice(0, last + 1), final: parts.slice(last + 1) }
const timeline = parts.slice(0, last + 1)
const final = parts.slice(last + 1)
if (!promoteLastText || final.some(p => p.type === 'text' && (p.text ?? '').trim())) return { timeline, final }
let textAt = -1
for (let i = timeline.length - 1; i >= 0; i--) {
if (timeline[i].type === 'text' && (timeline[i].text ?? '').trim()) { textAt = i; break }
}
if (textAt < 0) return { timeline, final }
const lifted = { ...timeline[textAt], process: undefined }
return { timeline: timeline.filter((_, i) => i !== textAt), final: [lifted, ...final] }
}
/** 同一轮里同名工件只留最新一张,updates 是被折叠掉的次数。 */
export function dedupeDeliverables(parts: Part[]): { artifact: NonNullable<Part['artifact']>; updates: number }[] {
const order: string[] = []
const byName = new Map<string, { artifact: NonNullable<Part['artifact']>; count: number }>()
for (const p of parts) {
if (p.type !== 'artifact_ref' || !p.artifact) continue
const name = p.artifact.name || p.artifact.artifactId
const cur = byName.get(name)
if (!cur) {
order.push(name)
byName.set(name, { artifact: p.artifact, count: 1 })
} else {
cur.artifact = p.artifact
cur.count++
}
}
return order.map(name => {
const cur = byName.get(name)!
return { artifact: cur.artifact, updates: cur.count - 1 }
})
}
/** 把单独成条的错误消息并回同一 run 的助手消息。没有正文的错误条保留原位。 */
export function foldRunErrors<T extends { id: string; runId?: string; role: string; parts: Part[] }>(messages: T[]): T[] {
const extras = new Map<string, Part[]>()
for (const m of messages) {
if (m.role !== 'assistant' || !m.runId) continue
if (m.parts.length > 0 && m.parts.every(p => p.type === 'error')) {
extras.set(m.runId, [...(extras.get(m.runId) ?? []), ...m.parts])
}
}
const used = new Set<string>()
const out: T[] = []
for (const m of messages) {
if (m.role === 'assistant' && m.runId && extras.has(m.runId) && m.parts.every(p => p.type === 'error')) {
const hasBody = messages.some(o => o !== m && o.runId === m.runId && o.role === 'assistant' && o.parts.some(p => p.type !== 'error'))
if (hasBody) continue
}
if (m.role === 'assistant' && m.runId && extras.has(m.runId) && m.parts.some(p => p.type !== 'error') && !used.has(m.runId)) {
used.add(m.runId)
out.push({ ...m, parts: [...m.parts, ...extras.get(m.runId)!] })
continue
}
out.push(m)
}
return out
}
/** 把运行错误说成人话。detail 保留原始码,供展开查看。 */
export function humanError(code?: string, message?: string): { title: string; detail: string } {
const msg = (message ?? '').trim()
const detail = [code, msg].filter(Boolean).join(': ')
if (code === 'run.cancelled' || msg === '已取消') return { title: '已取消', detail }
const stripped = msg.replace(/^budget:\s*/, '')
if (msg.startsWith('budget:') || /max turns|maxTurns|tool calls/i.test(msg)) {
return { title: `已停止:${stripped}`, detail }
}
return { title: msg ? `已停止:${msg}` : '已停止', detail }
}
/** 终态摘要:已工作时长,以及工具次数和文件数。 */
export function processSummary(toolCalls: number, files: number, createdAt?: string, endedAt?: string): string {
const bits = [workedLabel(createdAt, endedAt)]
if (toolCalls > 0) bits.push(`${toolCalls} 次工具调用`)
if (files > 0) bits.push(`${files} 个文件`)
return bits.join(' · ')
}
const ART_ID = /art_[0-9a-f]{12}/g
+6
View File
@@ -31,6 +31,12 @@ export function replaceQueuedText(queue: QueuedMessage[], id: string, text: stri
return queue.map(q => q.id === id ? { ...q, text } : q)
}
// deliveryRequestId 是这条排队消息重发时必须复用的 requestId。
// 服务端按它去重,避免忙时 409 再入队后写出第二条用户消息。
export function deliveryRequestId(item: QueuedMessage): string {
return item.id
}
export function promoteQueuedMessage(queue: QueuedMessage[], id: string): QueuedMessage[] {
const item = queue.find(q => q.id === id)
if (!item) return queue
+9 -4
View File
@@ -1,8 +1,7 @@
// 预览能力装配(裁定 D1–D4):把重解析器经 configureFilePreviewLoaders 注入 @ui。
// 全部 dynamic import——对应格式首次预览时才下载 chunk,主 bundle 不含解析器。
// 链接预览 fetcher 走服务端 /api/url-preview(SSRF 防护在服务端执行端)。
import { configureFilePreviewLoaders, configureLinkPreviewFetcher } from '@ui'
import { marked } from 'marked'
import { configureFilePreviewLoaders, configureLinkPreviewFetcher, renderMarkdown, setSafeHTML, stripUnsafeLinks } from '@ui'
import { auth } from './auth'
import type { LinkPreviewData } from '@ui'
@@ -28,7 +27,7 @@ const renderTextFallback = (el: HTMLElement, buf: ArrayBuffer, name: string | un
note.textContent = `「${name ?? '该文件'}」不是有效的 ${kindLabel} 文件(缺少 ZIP 结构),已按文本内容展示;需要正式格式请让智能体重新生成。`
const body = document.createElement('div')
body.className = 'doc-viewer'
body.innerHTML = marked.parse(new TextDecoder().decode(buf)) as string
setSafeHTML(body, renderMarkdown(new TextDecoder().decode(buf), { breaks: true }))
el.innerHTML = ''
el.append(note, body)
}
@@ -43,13 +42,18 @@ const docxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?:
const { renderAsync } = await import('docx-preview')
el.innerHTML = ''
await renderAsync(buf, el, el, { className: 'docx', inWrapper: true, breakPages: true })
stripUnsafeLinks(el)
return
} catch (err) {
console.error('[preview:docx] page render failed, falling back', source.name, err)
}
const mammoth = await import('mammoth')
const { value } = await mammoth.convertToHtml({ arrayBuffer: buf })
el.innerHTML = `<div class="doc-viewer">${value}</div>`
el.innerHTML = ''
const body = document.createElement('div')
body.className = 'doc-viewer'
setSafeHTML(body, value)
el.append(body)
}
const xlsxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => {
@@ -120,6 +124,7 @@ const pptxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?:
el.append(stage)
const previewer = init(stage, { width: stage.clientWidth || 900 })
await previewer.preview(buf)
stripUnsafeLinks(el)
}
export function wirePreviewCapabilities() {
+5
View File
@@ -0,0 +1,5 @@
/** 阶段徽章:只读项目设置里的 stage 字符串;没有就不显示。 */
export function projectStage(settings?: Record<string, unknown> | null): string | null {
const stage = settings?.stage
return typeof stage === 'string' && stage.trim() !== '' ? stage.trim() : null
}
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import { canFlushOutbox, deriveShellPhase, hasInFlight, isShellBusy, showLiveBubble, type ShellFacts } from './runstate'
interface Model {
facts: ShellFacts
outbox: string[]
userRequestIds: string[]
flushed: string[]
}
const idle: ShellFacts = { posting: false, openRun: false, liveRunId: null, assistantRunIds: [] }
function model(): Model {
return { facts: { ...idle, assistantRunIds: [] }, outbox: [], userRequestIds: [], flushed: [] }
}
type Step =
| 'send'
| 'post-ok'
| 'post-409'
| 'post-fail'
| 'delta'
| 'assistant'
| 'done'
| 'reconnect'
| 'switch'
| 'queue'
| 'flush'
function apply(m: Model, step: Step): Model {
const facts = {
...m.facts,
assistantRunIds: m.facts.assistantRunIds.slice(),
}
const next: Model = { facts, outbox: m.outbox.slice(), userRequestIds: m.userRequestIds.slice(), flushed: m.flushed.slice() }
const phase = deriveShellPhase(next.facts)
const requestId = 'req-1'
switch (step) {
case 'send':
if (isShellBusy(phase)) {
next.outbox.push(requestId)
return next
}
next.facts.posting = true
return next
case 'post-ok':
next.facts.posting = false
next.facts.openRun = true
next.facts.liveRunId = 'run-1'
if (!next.userRequestIds.includes(requestId)) next.userRequestIds.push(requestId)
return next
case 'post-409':
next.facts.posting = false
if (!next.outbox.includes(requestId)) next.outbox.unshift(requestId)
return next
case 'post-fail':
next.facts.posting = false
return next
case 'delta':
if (next.facts.openRun) next.facts.liveRunId = 'run-1'
return next
case 'assistant':
if (next.facts.liveRunId && !next.facts.assistantRunIds.includes(next.facts.liveRunId)) {
next.facts.assistantRunIds.push(next.facts.liveRunId)
}
return next
case 'done':
next.facts.openRun = false
return next
case 'reconnect':
return next
case 'switch':
return model()
case 'queue':
next.outbox.push('queued')
return next
case 'flush': {
if (!canFlushOutbox(deriveShellPhase(next.facts)) || next.outbox.length === 0) return next
const id = next.outbox.shift() ?? ''
next.flushed.push(id)
next.facts.posting = true
if (id === requestId && !next.userRequestIds.includes(id)) next.userRequestIds.push(id)
return next
}
default: {
const unexpected: never = step
throw new Error(unexpected)
}
}
}
function assertInvariants(m: Model) {
const phase = deriveShellPhase(m.facts)
expect(isShellBusy(phase)).toBe(hasInFlight(m.facts))
expect(showLiveBubble(m.facts.liveRunId, m.facts.assistantRunIds)).toBe(
!!m.facts.liveRunId && !m.facts.assistantRunIds.includes(m.facts.liveRunId),
)
if (isShellBusy(phase)) {
expect(canFlushOutbox(phase)).toBe(false)
}
expect(new Set(m.userRequestIds).size).toBe(m.userRequestIds.length)
}
const steps: Step[] = ['send', 'post-ok', 'post-409', 'post-fail', 'delta', 'assistant', 'done', 'reconnect', 'switch', 'queue', 'flush']
describe('shell run state', () => {
it('walks sequences up to depth 4 without breaking the four invariants', () => {
let frontier: Model[] = [model()]
for (let depth = 0; depth < 4; depth++) {
const grown: Model[] = []
for (const current of frontier) {
for (const step of steps) {
const next = apply(current, step)
assertInvariants(next)
grown.push(next)
}
}
frontier = grown
}
expect(frontier.length).toBe(steps.length ** 4)
})
it('follows sending, running, settling, then idle', () => {
let m = model()
m = apply(m, 'send')
expect(deriveShellPhase(m.facts)).toBe('sending')
m = apply(m, 'post-ok')
expect(deriveShellPhase(m.facts)).toBe('running')
expect(showLiveBubble(m.facts.liveRunId, m.facts.assistantRunIds)).toBe(true)
m = apply(m, 'done')
expect(deriveShellPhase(m.facts)).toBe('settling')
m = apply(m, 'assistant')
expect(deriveShellPhase(m.facts)).toBe('idle')
expect(showLiveBubble(m.facts.liveRunId, m.facts.assistantRunIds)).toBe(false)
})
})
+39
View File
@@ -0,0 +1,39 @@
// 产品壳运行态的唯一来源。按钮、停止、取消和排队发送都读这里,不再各自看 runs 或 live。
// sending:请求已发出、run 还没出现。running:有未结束的 run。
// settling:run 已结束,但这条 run 的助手消息还没落库。idle:可以发送下一条。
export type ShellPhase = 'sending' | 'running' | 'settling' | 'idle'
export interface ShellFacts {
posting: boolean
openRun: boolean
liveRunId: string | null
assistantRunIds: string[]
}
export function deriveShellPhase(facts: ShellFacts): ShellPhase {
const livePending = showLiveBubble(facts.liveRunId, facts.assistantRunIds)
if (facts.posting && !facts.openRun) return 'sending'
if (facts.openRun) return 'running'
if (livePending) return 'settling'
return 'idle'
}
export function isShellBusy(phase: ShellPhase): boolean {
return phase !== 'idle'
}
// 流式气泡只在这条 run 还没有助手消息时显示。用户消息上的同一个 runId 不算回复已落库。
export function showLiveBubble(liveRunId: string | null, assistantRunIds: string[]): boolean {
if (!liveRunId) return false
return !assistantRunIds.includes(liveRunId)
}
export function canFlushOutbox(phase: ShellPhase): boolean {
return phase === 'idle'
}
// 在途:正在发送、有未结束的 run,或实时气泡还在等助手消息。
export function hasInFlight(facts: ShellFacts): boolean {
return facts.posting || facts.openRun || showLiveBubble(facts.liveRunId, facts.assistantRunIds)
}
+30
View File
@@ -0,0 +1,30 @@
// 中栏 → 右栏单向滚动同步,以及互定位后的 1.2s 闪烁。
// 比例按可滚动范围计算;任一侧没有溢出时不同步,避免把静止栏拽动。
export interface ScrollBox {
scrollTop: number
scrollHeight: number
clientHeight: number
}
export function scrollRatio(box: ScrollBox): number | null {
const range = box.scrollHeight - box.clientHeight
if (range <= 0) return null
return box.scrollTop / range
}
export function scrollTopForRatio(box: Pick<ScrollBox, 'scrollHeight' | 'clientHeight'>, ratio: number): number | null {
const range = box.scrollHeight - box.clientHeight
if (range <= 0) return null
const clamped = Math.min(1, Math.max(0, ratio))
return clamped * range
}
/** 给已定位的节点加上 mk-flash,1.2s 后摘掉。 */
export function flashElement(el: Element | null) {
if (!el || typeof window === 'undefined') return
el.classList.remove('mk-flash')
void (el as HTMLElement).offsetWidth
el.classList.add('mk-flash')
window.setTimeout(() => el.classList.remove('mk-flash'), 1200)
}
+179 -47
View File
@@ -2,9 +2,12 @@
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated, type LiveItem } from './live'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs'
import { enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import { canFlushOutbox, deriveShellPhase } from './runstate'
import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
@@ -14,6 +17,8 @@ export interface ContextObs {
items: string[]
truncations?: string[]
estChars: number
sections?: { name: string; estTokens: number }[]
prefixHash?: string
}
export interface LiveState {
@@ -32,7 +37,9 @@ export interface UIState {
rightWidth: number
rightCollapsed: boolean
rightTab: string
theme: 'dark' | 'light'
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
inboxFilter: 'all' | 'todo'
theme: ThemeChoice
autoCollapseProcess: boolean
settingsOpen: boolean
trackerRunId: string | null
@@ -69,11 +76,15 @@ interface AppState {
cards: ActivityCard[]
projectCards: ActivityCard[]
inboxScope: 'conv' | 'project'
/** runtime.mock:只控制演示指令提示,不改变对话能力。 */
runtimeMock: boolean
profiles: AgentProfile[]
pluginPanels: PluginPanel[]
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
ui: UIState
connected: boolean
/** 发送请求已经出去,run 记录还没回来。 */
posting: boolean
error: string | null
toast: string | null
outbox: QueuedMessage[]
@@ -88,6 +99,7 @@ const DEFAULT_UI: UIState = {
rightWidth: 340,
rightCollapsed: false,
rightTab: 'inbox',
inboxFilter: 'all',
activeWorkspace: null,
theme: 'dark',
autoCollapseProcess: true,
@@ -106,7 +118,12 @@ const DEFAULT_UI: UIState = {
function loadUI(): UIState {
try {
const raw = localStorage.getItem('as.ui')
if (raw) return { ...DEFAULT_UI, ...JSON.parse(raw) }
if (raw) {
const ui = { ...DEFAULT_UI, ...JSON.parse(raw) } as UIState
if (!isThemeChoice(ui.theme)) ui.theme = 'dark'
if (ui.inboxFilter !== 'all' && ui.inboxFilter !== 'todo') ui.inboxFilter = 'all'
return ui
}
} catch { /* ignore */ }
return { ...DEFAULT_UI }
}
@@ -115,9 +132,9 @@ let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', profiles: [], pluginPanels: [],
plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
ui: loadUI(), connected: false, error: null, toast: null,
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {},
}
@@ -152,8 +169,37 @@ function setUI(patch: Partial<UIState>) {
listeners.forEach(l => l())
}
let systemQuery: MediaQueryList | null = null
let systemHandler: (() => void) | null = null
function applyTheme(theme: string) {
document.documentElement.dataset.theme = theme
if (typeof document === 'undefined') return
if (systemQuery && systemHandler) {
systemQuery.removeEventListener('change', systemHandler)
systemQuery = null
systemHandler = null
}
const paint = (prefersDark: boolean) => {
document.documentElement.dataset.theme = resolveTheme(theme, prefersDark)
}
if (theme === 'system' && typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
systemQuery = window.matchMedia('(prefers-color-scheme: dark)')
systemHandler = () => paint(systemQuery?.matches ?? false)
systemQuery.addEventListener('change', systemHandler)
paint(systemQuery.matches)
return
}
paint(false)
}
applyTheme(state.ui.theme)
function flashLocate(root: string, attr: string, id: string) {
if (typeof document === 'undefined') return
const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/["\\]/g, '')
const el = document.querySelector(`${root} [${attr}="${esc}"]`)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
flashElement(el)
}
let sse: { close: () => void } | null = null
@@ -254,6 +300,12 @@ export const store = {
},
// 跨会话标记(提案 2026-09-30-cross-session-reading):出站 allowCrossRead / 入站 crossPrivate。
async setPermissionMode(convId: string, permissionMode: 'default' | 'plan' | 'auto_edit') {
const tasks = state.tasks.map(t => t.id === convId ? { ...t, permissionMode } : t)
set({ tasks })
try { await api.setPermissionMode(convId, permissionMode) } catch (e) { store.toast(`权限模式切换失败:${e}`); store.refreshTasks() }
},
async setCrossFlags(convId: string, flags: { allowCrossRead?: boolean; crossPrivate?: boolean }) {
const tasks = state.tasks.map(t => t.id === convId ? { ...t, ...flags } : t)
set({ tasks })
@@ -262,18 +314,20 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv', outbox: [], outboxDraining: false })
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
syncPreviewRail((state.previewTabs[convId] ?? []).length > 0)
await store.reconcile()
await store.connectLive()
const resumeSeq = await store.reconcile()
store.connectLive(resumeSeq ?? state.lastSeq)
},
// 终态对账:以 Query 的权威消息树/运行树/游标为准收敛。
async reconcile() {
// 返回值是实时流应从这个 seq 之后重放;有进行中的 run 时它早于 lastSeq。
async reconcile(): Promise<number | undefined> {
if (!state.currentConvId) return
try {
const d = await api.conversationDetail(state.currentConvId)
const liveRunActive = state.live && !isTerminal(d.runs.find(r => r.id === state.live!.runId)?.status)
const resumeSeq = d.resumeSeq ?? d.lastSeq
set({
messages: d.messages,
runs: d.runs,
@@ -286,15 +340,16 @@ export const store = {
set({ plan, todos })
store.refreshCards(d.conversation.id)
store.refreshTasks()
return resumeSeq
} catch (e) {
set({ error: String(e) })
}
},
connectLive() {
connectLive(since?: number) {
if (!state.currentConvId) return
const convId = state.currentConvId
sse = connectEvents(convId, state.lastSeq, {
sse = connectEvents(convId, since ?? state.lastSeq, {
onOpen: () => set({ connected: true }),
onError: () => set({ connected: false }),
onEvent: e => store.applyEvent(e),
@@ -302,30 +357,50 @@ export const store = {
},
applyEvent(e: StreamEvent) {
const now = eventTime(e)
const project = (mutate: (l: LiveState) => void) => touchLive(e.runId ?? '', mutate, now)
switch (e.type) {
case 'message.delta': {
const p = e.payload as { index: number; text: string }
set({ live: touchLive(e.runId ?? '', l => { l.items = applyTextDelta(l.items, p.index, p.text) }) })
set({ live: project(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.items = applyThinkingDelta(l.items, p.text) }) })
set({ live: project(l => { l.items = applyThinkingDelta(l.items, p.text, now) }) })
break
}
case 'tool.call.delta': {
const p = e.payload as { index: number; callId?: string; name?: string; delta: string }
set({ live: project(l => { l.items = applyToolCallDelta(l.items, p.index, p.delta, p.callId, p.name, now) }) })
break
}
case 'tool.call': {
const p = e.payload as { callId: string; name: string }
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolCall(l.items, p.name, p.callId) }) })
const p = e.payload as { callId: string; name: string; arguments?: string }
set({ live: project(l => { l.items = applyToolCall(l.items, p.name, p.callId, p.arguments, now) }) })
break
}
case 'tool.output': {
const p = e.payload as { callId?: string; text: string }
set({ live: project(l => { l.items = applyToolOutput(l.items, p.callId, p.text) }) })
break
}
case 'tool.result': {
const p = e.payload as { callId: string; status: string; summary?: string }
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolResult(l.items, p.callId, p.status, p.summary) }) })
set({ live: project(l => { l.items = applyToolResult(l.items, p.callId, p.status, p.summary, now) }) })
break
}
case 'run.phase': {
const p = e.payload as { phase: string }
set({ live: touchLive(e.runId ?? '', l => { l.phase = p.phase }) })
set({ live: project(l => {
l.phase = p.phase
if (p.phase === 'finalizing') l.items = applyFinalizing(l.items)
}) })
break
}
case 'run.check': {
const p = e.payload as { passed?: boolean; reason?: string }
set({ live: project(l => { l.items = applyCheck(l.items, !!p.passed, p.reason ?? '') }) })
break
}
case 'hitl.request': {
@@ -338,17 +413,17 @@ export const store = {
}
case 'agent.spawn': {
const p = e.payload as { agentRun: string; name: string; task: string }
set({ live: touchLive(e.runId ?? '', l => { l.agentNotes = { ...l.agentNotes, [p.agentRun]: `已派生 ${p.name}:${p.task}` } }) })
set({ live: project(l => { l.agentNotes = { ...l.agentNotes, [p.agentRun]: `已派生 ${p.name}:${p.task}` } }) })
break
}
case 'agent.progress': {
const p = e.payload as { agentRun: string; note?: string }
set({ live: touchLive(e.runId ?? '', l => { if (p.note) l.agentNotes = { ...l.agentNotes, [p.agentRun]: p.note! } }) })
set({ live: project(l => { if (p.note) l.agentNotes = { ...l.agentNotes, [p.agentRun]: p.note! } }) })
break
}
case 'agent.result': {
const p = e.payload as { agentRun: string; summary?: string }
set({ live: touchLive(e.runId ?? '', l => { l.agentNotes = { ...l.agentNotes, [p.agentRun]: p.summary ?? '完成' } }) })
set({ live: project(l => { l.agentNotes = { ...l.agentNotes, [p.agentRun]: p.summary ?? '完成' } }) })
store.reconcile()
break
}
@@ -380,11 +455,13 @@ export const store = {
set({ cards })
break
}
case 'artifact.created': {
case 'artifact.created':
case 'artifact.updated': {
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 })
const ref = { artifactId: p.artifact.id, name: p.artifact.name, kind: p.artifact.kind }
set({ live: project(l => {
l.items = e.type === 'artifact.updated' ? applyArtifactUpdated(l.items, ref) : applyArtifactCreated(l.items, ref)
}) })
}
if (state.currentProjectId) store.refreshArtifacts(state.currentProjectId)
@@ -404,13 +481,19 @@ export const store = {
setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
break
}
default: {
const unexpected: never = e.type
void unexpected
}
}
},
async sendMessage(text: string, attachmentIds: string[]): Promise<'ok' | 'busy'> {
async sendMessage(text: string, attachmentIds: string[], requestId?: string): Promise<'ok' | 'busy'> {
if (!state.currentConvId) return 'ok'
const id = requestId ?? newQueuedMessage(text, attachmentIds, []).id
set({ posting: true })
try {
const out = await api.sendMessage(state.currentConvId, text, attachmentIds)
const out = await api.sendMessage(state.currentConvId, text, attachmentIds, id)
if (out.command) {
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
if (out.conversation) await store.openConversation(out.conversation.id)
@@ -419,23 +502,36 @@ export const store = {
return 'ok'
}
if (out.run) {
const started = out.run
// 立刻把 run 合进列表,停止/排队不必等终态对账。已有同 id 记录保持不动。
const runs = mergeStartedRun(state.runs, started)
const messages = state.messages.some(m => m.id === out.message.id)
? state.messages
: [...state.messages, out.message]
if (isTerminal(runs.find(r => r.id === started.id)?.status)) {
set({ runs, messages })
await store.reconcile()
return 'ok'
}
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
if (!state.live || state.live.runId !== out.run.id) {
set({ live: { runId: out.run.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() } })
}
set({ messages: [...state.messages, out.message] })
const live = !state.live || state.live.runId !== started.id
? { runId: started.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() }
: state.live
set({ live, runs, messages })
store.connectIfNeeded()
}
return 'ok'
} catch (e) {
if (isConversationBusy(e)) {
set({ outbox: enqueueMessageFront(state.outbox, newQueuedMessage(text, attachmentIds, [])) })
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [] }) })
return 'busy'
}
store.toast(`发送失败:${e}`)
await store.reconcile()
return 'ok'
} finally {
set({ posting: false })
}
},
@@ -455,29 +551,29 @@ export const store = {
const item = state.outbox.find(q => q.id === id)
if (!item) return
const rest = removeQueuedMessage(state.outbox, id)
if (activeRun(state.runs) || state.live) {
if (!canFlushOutbox(shellPhase())) {
set({ outbox: promoteQueuedMessage(state.outbox, id) })
store.toast('回合进行中,已放到队首,结束后自动发送')
return
}
set({ outbox: rest })
await store.sendMessage(item.text, item.attachmentIds)
await store.sendMessage(item.text, item.attachmentIds, deliveryRequestId(item))
},
async flushOutbox() {
if (state.outboxDraining) return
if (activeRun(state.runs) || state.live) return
if (!canFlushOutbox(shellPhase())) return
const next = state.outbox[0]
if (!next) return
set({ outbox: state.outbox.slice(1), outboxDraining: true })
let result: 'ok' | 'busy' = 'ok'
try {
result = await store.sendMessage(next.text, next.attachmentIds)
result = await store.sendMessage(next.text, next.attachmentIds, deliveryRequestId(next))
} finally {
set({ outboxDraining: false })
}
if (result === 'busy') return
if (!activeRun(state.runs) && !state.live && state.outbox.length > 0) {
if (canFlushOutbox(shellPhase()) && state.outbox.length > 0) {
await store.flushOutbox()
}
},
@@ -487,7 +583,7 @@ export const store = {
},
async cancelActive() {
const active = state.runs.find(r => !isTerminal(r.status))
const active = activeRun(state.runs)
if (!active) return
await api.cancelRun(active.id).catch(() => undefined)
setTimeout(() => store.reconcile(), 200)
@@ -527,6 +623,10 @@ export const store = {
const out = await api.cardAction(card.id, actionId, freeText)
store.upsertCard(out.card)
if (out.receipt) set({ messages: [...state.messages, out.receipt as Message] })
// 命令确认卡的动作响应是决议前快照。再拉一次,内联卡才能从待确认过渡到折叠。
if (card.kind === 'confirm' && actionId !== 'recheck' && out.card.status === 'action_required' && card.conversationId) {
await store.refreshCards(card.conversationId)
}
} catch (e) {
store.toast(`操作失败:${e}`)
}
@@ -561,6 +661,12 @@ export const store = {
const ws = await api.listWorkspaces()
set({ serverWorkspaces: ws ?? [] })
} catch { /* ignore */ }
try {
const settings = await api.getSettings()
const runtime = settings.user.runtime
const mock = !!runtime && typeof runtime === 'object' && (runtime as { mock?: unknown }).mock === true
set({ runtimeMock: mock })
} catch { /* ignore */ }
},
async uploadAttachment(file: File): Promise<string | null> {
@@ -655,18 +761,21 @@ export const store = {
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
},
closeWorkspace() { setUI({ activeWorkspace: null }) },
/** 铃铛:打开右栏并切到待处理。哪一侧有待办就看哪一侧。 */
focusPending() {
const conv = state.cards.filter(c => c.status === 'action_required').length
const proj = state.projectCards.filter(c => c.status === 'action_required').length
if (conv === 0 && proj > 0) store.setInboxScope('project')
setUI({ rightCollapsed: false, rightTab: 'inbox', inboxFilter: 'todo' })
},
openInbox(cardId?: string) {
setUI({ rightCollapsed: false, rightTab: 'inbox' })
if (cardId) setTimeout(() => {
document.querySelector(`[data-card-id="${cardId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 60)
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
},
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
locateMessage(messageId?: string) {
if (!messageId) return
setTimeout(() => {
document.querySelector(`[data-msg-id="${messageId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 60)
setTimeout(() => flashLocate('.chat', 'data-msg-id', messageId), 60)
},
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
@@ -705,8 +814,8 @@ export const store = {
},
}
function touchLive(_runId: string, mutate: (l: LiveState) => void): LiveState | null {
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() }
function touchLive(_runId: string, mutate: (l: LiveState) => void, now = Date.now()): LiveState | null {
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: now }
const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } }
mutate(next)
return next
@@ -720,6 +829,29 @@ function isConversationBusy(err: unknown): boolean {
return String(err).includes('409:')
}
function shellPhase() {
const liveRunId = state.live?.runId ?? null
return deriveShellPhase({
posting: state.posting,
openRun: !!activeRun(state.runs),
liveRunId,
assistantRunIds: state.messages.filter(m => m.role === 'assistant' && m.runId).map(m => m.runId as string),
})
}
export function activeRun(runs: Run[]): Run | undefined {
return runs.find(r => !isTerminal(r.status))
return runs.find(r => r.kind !== 'subagent' && !isTerminal(r.status))
}
// mergeStartedRun 把刚启动的 run 补进列表。同 id 已在(通常是对账拿到的更新记录)时不覆盖。
export function mergeStartedRun(runs: Run[], run: Run): Run[] {
if (runs.some(r => r.id === run.id)) return runs
return [...runs, run]
}
// eventTime 取事件上的服务端时间。重放时墙上时钟会把「已工作」和工具耗时算成 0。
export function eventTime(e: { ts?: string }, now = Date.now()): number {
if (!e.ts) return now
const t = Date.parse(e.ts)
return Number.isNaN(t) ? now : t
}
+95 -3
View File
@@ -15,8 +15,39 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
/* ---- 面板化三栏:独立圆角面板 + 4px 间隙(DESIGN.md §6) ---- */
.app { display: flex; height: 100vh; overflow: hidden; gap: 4px; padding: 4px; background: var(--bg-inset); }
.left-nav { background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); display: flex; flex-direction: column; min-width: 0; flex-shrink: 0; transition: width .15s var(--ease); }
.app { display: flex; flex-direction: column; height: 100vh; overflow: hidden; gap: 4px; padding: 4px; background: var(--bg-inset); }
.app-body { display: flex; flex: 1; min-height: 0; gap: 4px; }
.topbar {
display: flex; align-items: center; gap: 10px; height: 40px; flex-shrink: 0;
padding: 0 12px; border-radius: var(--radius-lg);
background: var(--bg-sidebar); color: var(--text-sidebar);
border: 1px solid var(--border-sidebar);
}
.topbar-name { font-weight: 650; letter-spacing: .01em; }
.topbar-project { color: var(--text-sidebar-dim); font-size: var(--fs-ui-sm); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar-stage {
font-size: var(--fs-ui-xs); padding: 2px 8px; border-radius: var(--radius-full);
border: 1px solid var(--border-sidebar); color: var(--text-sidebar);
}
.topbar-spacer { flex: 1; }
.topbar-bell {
position: relative; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center;
background: none; border: 1px solid var(--border-sidebar); border-radius: var(--radius-sm);
color: inherit; cursor: pointer;
}
.topbar-bell:hover { background: var(--bg-hover); }
.left-nav { background: var(--bg-sidebar); color: var(--text-sidebar); border: 1px solid var(--border-sidebar); border-radius: var(--radius-lg); display: flex; flex-direction: column; min-width: 0; flex-shrink: 0; transition: width .15s var(--ease); }
[data-theme='molv'] .left-nav,
[data-theme='molv'] .topbar {
--text: var(--text-sidebar);
--text-dim: var(--text-sidebar-dim);
--text-faint: var(--text-sidebar-faint);
--bg-hover: rgba(233, 240, 246, 0.08);
--bg-active: rgba(233, 240, 246, 0.14);
--border: var(--border-sidebar);
--border-soft: var(--border-sidebar);
--border-strong: var(--border-sidebar);
}
.left-nav.collapsed { width: 52px !important; }
.divider { width: 4px; cursor: col-resize; background: transparent; flex-shrink: 0; position: relative; }
.divider::before { content: ''; position: absolute; left: 1px; top: 12px; bottom: 12px; width: 2px; border-radius: 2px; background: transparent; transition: background .12s; }
@@ -57,16 +88,31 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
/* ---- 中栏对话 ---- */
/* 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 { position: relative; flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.chat-header { padding: 12px 22px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 10px; background: var(--bg); border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
.chat-title { font-weight: 600; font-size: var(--fs-ui-base); letter-spacing: -0.01em; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-body { flex: 1; overflow-y: auto; min-height: 0; padding: 22px max(22px, 13%); }
.jump-bottom {
position: absolute; left: 50%; bottom: 92px; transform: translateX(-50%); z-index: 4;
border: 1px solid var(--border); background: var(--bg-raised); color: var(--text);
border-radius: var(--radius-full); padding: 6px 14px; font-size: var(--fs-ui-sm);
box-shadow: var(--shadow-md); cursor: pointer;
}
.drop-veil {
position: absolute; inset: 8px; z-index: 5; pointer-events: none;
display: flex; align-items: center; justify-content: center;
border: 1px dashed var(--accent); border-radius: var(--radius-lg);
background: var(--accent-soft); color: var(--accent); font-size: var(--fs-ui-sm);
}
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 10px; font-size: var(--fs-ui-caption); }
.empty-state .es-mark { width: 44px; height: 44px; border-radius: var(--radius-lg); background: var(--bg-raised); border: 1px solid var(--border-soft); display: flex; align-items: center; justify-content: center; color: var(--text-faint); margin-bottom: 4px; }
.empty-state svg { opacity: .5; }
.msg { margin-bottom: 20px; animation: mk-rise-in .18s var(--ease-out); }
.msg-role { font-size: var(--fs-ui-sm); color: var(--text-faint); margin-bottom: 6px; display: flex; gap: 8px; align-items: center; }
.msg-role .role-mark { width: 18px; height: 18px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
.msg.notice { margin-bottom: 12px; }
.notice-summary { font-size: var(--fs-ui-sm); color: var(--text-dim); background: none; border: none; padding: 0; cursor: pointer; }
.notice-body { font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: pre-wrap; margin-top: 4px; }
.msg.user .bubble {
background: var(--accent-soft); border: 1px solid transparent;
border-radius: var(--radius-lg); padding: 11px 16px; white-space: pre-wrap;
@@ -105,6 +151,12 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.part-text hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
.part-text a { color: var(--accent); }
.part-error { color: var(--err); background: var(--err-soft); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
.part-error-detail { margin-top: 8px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.tool-preview, .tool-live-preview {
margin: 0; white-space: pre-wrap; font-family: var(--mono); font-size: var(--fs-ui-sm);
color: var(--text-dim); max-height: 8em; overflow: auto;
}
.tool-live-preview { margin-top: 6px; }
.artifact-chip { display: inline-flex; align-items: stretch; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; font-size: var(--fs-ui-caption); margin: 2px 0; }
.artifact-chip .ac-main { display: inline-flex; align-items: center; gap: 7px; color: var(--accent); background: none; border: none; padding: 5px 10px; cursor: pointer; font: inherit; max-width: 340px; }
.artifact-chip .ac-main span, .artifact-chip .ac-main { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -139,6 +191,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.tl-tools-list li { margin: 1px 0; }
.tl-tools-name { color: var(--text-dim); }
.tl-tools-sum { margin-left: 8px; color: var(--text-faint); }
.tl-check { color: var(--text-faint); font-size: var(--fs-ui-sm); margin: 4px 0; }
.tl-tools .status-error { margin-left: 8px; color: var(--err); }
/* 对话右侧预览区 */
@@ -173,6 +226,11 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
/* HITL 卡片(确认类交互统一绿色语言) */
.hitl-card { border: 1px solid var(--ok-border); background: var(--ok-soft); border-radius: var(--radius-lg); padding: 14px; margin: 10px 0; }
.hitl-card.wait { border-color: var(--wait); background: var(--wait-soft); }
.hitl-card.ask { border-color: var(--border-strong); background: var(--bg-raised); }
.hitl-detail { white-space: pre-wrap; margin: 8px 0 0; font-size: var(--fs-ui-sm); color: var(--text-faint); font-family: inherit; }
.mode-select { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-ui-sm); color: var(--text-faint); }
.mode-select select { font-size: var(--fs-ui-sm); color: var(--text); background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 8px; }
.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; }
@@ -277,6 +335,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.conn-dot { width: 8px; height: 8px; border-radius: 50%; }
.conn-dot.on { background: var(--ok); }
.conn-dot.off { background: var(--err); }
.conn-label { font-size: var(--fs-ui-sm); color: var(--text-faint); }
.doc-toolbar { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 6px; border: 3px solid transparent; background-clip: content-box; }
@@ -473,3 +532,36 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
border-radius: var(--radius-full); padding: 2px 12px; font-size: var(--fs-ui-sm); cursor: pointer; opacity: .72;
}
.inbox-scope button.active { opacity: 1; font-weight: 600; background: var(--bg-hover); }
.tsb-project {
display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%;
text-align: left; background: none; border: none; cursor: pointer;
padding: 8px 10px; border-radius: var(--radius); color: inherit;
}
.tsb-project:hover { background: var(--bg-hover); }
.tsb-project.active { background: var(--bg-active); }
.tsb-project-name { font-weight: 600; font-size: var(--fs-ui-sm); }
.tsb-project-meta { color: var(--text-faint); font-size: var(--fs-ui-xs); }
.tsb-tool {
display: flex; align-items: center; gap: 8px; width: 100%;
background: none; border: none; cursor: pointer; color: inherit;
padding: 6px 10px; border-radius: var(--radius); font-size: var(--fs-ui-sm);
}
.tsb-tool:hover { background: var(--bg-hover); }
.tsb-tool kbd { margin-left: auto; font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--text-faint); }
.tsb-tool-note { color: var(--text-faint); font-size: var(--fs-ui-xs); padding: 2px 10px 8px; }
.tsb-tool-badge {
margin-left: 6px; min-width: 16px; padding: 0 5px; border-radius: var(--radius-full);
background: var(--err); color: var(--text-on-accent); font-size: var(--fs-ui-xs);
}
.rewind-row { margin-top: 8px; }
.rewind-panel {
margin-top: 8px; padding: 8px 10px;
background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm);
font-size: var(--fs-ui-sm);
}
.rewind-panel ul { margin: 6px 0; padding-left: 1.2em; }
.msg.is-excluded { opacity: 0.55; }
.msg-tools { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-top: 4px; }
.session-note { width: 12rem; }
.rewind-actions { display: flex; gap: 8px; }
.rewind-error { color: var(--err); font-size: var(--fs-ui-sm); }
+13
View File
@@ -0,0 +1,13 @@
// 主题选择:深色 / 浅色 / 墨绿直接上屏;跟随系统只在深色与浅色之间解析。
export type ThemeChoice = 'dark' | 'light' | 'molv' | 'system'
export type PaintedTheme = 'dark' | 'light' | 'molv'
export function resolveTheme(choice: string, prefersDark: boolean): PaintedTheme {
if (choice === 'system') return prefersDark ? 'dark' : 'light'
if (choice === 'light' || choice === 'molv') return choice
return 'dark'
}
export function isThemeChoice(value: string): value is ThemeChoice {
return value === 'dark' || value === 'light' || value === 'molv' || value === 'system'
}
+55 -12
View File
@@ -37,6 +37,14 @@ function argOf(raw: string | undefined, keys: string[]): string {
return ''
}
/** 编写时看脚本尾部,运行后有输出则改看输出尾部。 */
export function toolTail(args?: string, output?: string): string {
const script = argOf(args, ['script', 'command', 'cmd'])
const body = (output && output.trim()) ? output : (script || args || '')
const lines = body.split('\n').filter((line, i, all) => line.trim() || i === all.length - 1)
return lines.slice(-6).join('\n')
}
function firstLine(s: string): string {
const line = s.split('\n').map(x => x.trim()).find(Boolean) ?? ''
return line.length > 80 ? `${line.slice(0, 80)}…` : line
@@ -79,8 +87,13 @@ export function toolGroupLabel(kind: ToolKind, count: number, running: boolean):
}
export interface ToolGroupItem {
id: string
name: string
headline: string
arguments?: string
output?: string
phase?: 'composing' | 'running' | 'done'
durationMs?: number
status?: 'ok' | 'error'
summary?: string
running: boolean
@@ -90,8 +103,35 @@ export type TimelineEntry =
| { kind: 'thinking'; text: string; durationMs?: number; active: boolean }
| { kind: 'text'; text: string }
| { kind: 'tools'; toolKind: ToolKind; items: ToolGroupItem[] }
| { kind: 'toolblock'; toolKind: ToolKind; item: ToolGroupItem }
| { kind: 'check'; passed: boolean; reason: string }
| { kind: 'other'; part: Part }
const SOLO: ReadonlySet<ToolKind> = new Set(['exec', 'write', 'delegate'])
interface ToolFace {
toolPhase?: 'composing' | 'running' | 'done'
toolOutput?: string
}
function toolItem(call: Part, res: Part | undefined, live: boolean): ToolGroupItem {
const face = call as Part & ToolFace
const status = res?.toolResult?.status
const phase = face.toolPhase ?? (live && !res ? 'running' : res ? 'done' : undefined)
return {
id: call.toolCall?.id || call.toolCall?.name || 'tool',
name: call.toolCall?.name ?? '工具',
headline: toolHeadline(call.toolCall?.name ?? '', call.toolCall?.arguments),
arguments: call.toolCall?.arguments,
output: face.toolOutput,
phase,
durationMs: call.toolCall?.durationMs,
status: status === 'ok' || status === 'error' ? status : undefined,
summary: res?.toolResult?.summary,
running: phase === 'composing' || phase === 'running',
}
}
/** 把时间线部件收成行:相邻同类工具合并,思考是否仍在进行由 live 与后续部件决定。 */
export function timelineEntries(parts: Part[], live: boolean): TimelineEntry[] {
const out: TimelineEntry[] = []
@@ -100,23 +140,21 @@ export function timelineEntries(parts: Part[], live: boolean): TimelineEntry[] {
const p = parts[i]
if (p.type === 'tool_call') {
const kind = toolKind(p.toolCall?.name ?? '')
if (SOLO.has(kind)) {
const call = parts[i]
i++
let res: Part | undefined
if (parts[i]?.type === 'tool_result') { res = parts[i]; i++ }
out.push({ kind: 'toolblock', toolKind: kind, item: toolItem(call, res, live) })
continue
}
const items: ToolGroupItem[] = []
while (i < parts.length && parts[i].type === 'tool_call' && toolKind(parts[i].toolCall?.name ?? '') === kind) {
const call = parts[i]
i++
let res: Part | undefined
if (parts[i]?.type === 'tool_result') {
res = parts[i]
i++
}
const status = res?.toolResult?.status
items.push({
name: call.toolCall?.name ?? '工具',
headline: toolHeadline(call.toolCall?.name ?? '', call.toolCall?.arguments),
status: status === 'ok' || status === 'error' ? status : undefined,
summary: res?.toolResult?.summary,
running: live && !res,
})
if (parts[i]?.type === 'tool_result') { res = parts[i]; i++ }
items.push(toolItem(call, res, live))
}
out.push({ kind: 'tools', toolKind: kind, items })
continue
@@ -132,6 +170,11 @@ export function timelineEntries(parts: Part[], live: boolean): TimelineEntry[] {
i++
continue
}
if (p.type === 'check') {
out.push({ kind: 'check', passed: !!p.check?.passed, reason: p.check?.reason ?? '' })
i++
continue
}
out.push({ kind: 'other', part: p })
i++
}
+66
View File
@@ -0,0 +1,66 @@
// 体系工具排位:服务端声明的工作区对所有用户可见,按 shortcut 数字占位;
// 内置「工件」「代理」补进空位。插件管理面板不进这条清单。
export interface WorkspaceEntry {
key: string
title: string
icon: string
shortcut: string
}
export interface WorkspaceSource {
id: string
title: string
icon?: string
shortcut?: string
}
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
{ key: 'artifacts', title: '工件', icon: '📦' },
{ key: 'agents', title: '代理', icon: '🤖' },
]
function shortcutNum(raw?: string): number | null {
if (!raw) return null
const m = raw.match(/(\d)/)
if (!m) return null
const n = Number(m[1])
return n >= 1 && n <= 9 ? n : null
}
export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry[] {
const used = new Set<number>()
const ranked: { rank: number; entry: Omit<WorkspaceEntry, 'shortcut'> }[] = []
const unranked: Omit<WorkspaceEntry, 'shortcut'>[] = []
for (const w of server) {
const entry = { key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂' }
const n = shortcutNum(w.shortcut)
if (n && !used.has(n)) {
used.add(n)
ranked.push({ rank: n, entry })
} else {
unranked.push(entry)
}
}
const takeFree = (prefer: number): number => {
if (!used.has(prefer) && prefer >= 1 && prefer <= 9) {
used.add(prefer)
return prefer
}
for (let n = 1; n <= 9; n++) {
if (!used.has(n)) {
used.add(n)
return n
}
}
return 99
}
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
return ranked
.filter(item => item.rank <= 9)
.sort((a, b) => a.rank - b.rank)
.slice(0, 9)
.map((item, i) => ({ ...item.entry, shortcut: `⌘${i + 1}` }))
}
+30
View File
@@ -0,0 +1,30 @@
// 棘轮:宿主侧的 HTML 写入必须走 @ui safeHtml,不能在组件里直接塞字符串。
import { describe, expect, it } from 'vitest'
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, relative, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const SRC = join(dirname(fileURLToPath(import.meta.url)), '..', 'src')
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
if (statSync(p).isDirectory()) walk(p, out)
else if (/\.(ts|tsx)$/.test(name)) out.push(p)
}
return out
}
describe('html sinks', () => {
it('frontend components do not write HTML themselves', () => {
const hits: string[] = []
for (const file of walk(SRC)) {
const text = readFileSync(file, 'utf8')
const rel = relative(SRC, file)
if (text.includes('dangerouslySetInnerHTML')) hits.push(`${rel}: dangerouslySetInnerHTML`)
// 空白放进前瞻里,避免 \s* 回退到空格前面、把 innerHTML = '' 误判成写入。
if (/innerHTML\s*=(?!\s*['"]['"])/.test(text)) hits.push(`${rel}: innerHTML`)
}
expect(hits).toEqual([])
})
})
+62 -11
View File
@@ -2,8 +2,9 @@
// 轮次穿插同构、过程区折叠谓词。
import { describe, it, expect } from 'vitest'
import {
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated,
liveToMessage, showProcessInline, type LiveItem,
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated,
applyCheck, applyFinalizing,
hasSettledReply, liveToMessage, livePhaseLabel, showProcessInline, type LiveItem,
} from '../src/live'
import type { LiveState } from '../src/store'
@@ -63,12 +64,25 @@ describe('applyThinkingDelta / applyTextDelta', () => {
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: '结果' },
{ kind: 'tool', callId: 'ca', name: 'a', args: '', state: 'running', startedAt: 0, endedAt: 0, output: '' },
{ kind: 'tool', callId: 'cb', name: 'b', args: '', state: 'done', startedAt: 0, endedAt: 0, output: '', status: 'ok', summary: '结果' },
])
// 无 callId 的事件回落到尾部配对。
items = applyToolResult(items, undefined, 'ok')
expect(items[0]).toEqual({ kind: 'tool', callId: 'ca', name: 'a', status: 'ok' })
expect(items[0]).toMatchObject({ kind: 'tool', callId: 'ca', name: 'a', state: 'done', status: 'ok' })
})
it('walks a tool from composing to running to done, and marks the narration as process', () => {
let items: LiveItem[] = applyTextDelta([], 0, '我来生成文档。')
items = applyToolCallDelta(items, 0, '{"script":', 'c1', 'run_python', 1000)
items = applyToolCallDelta(items, 0, '"print(1)"}', undefined, undefined, 1100)
const composing = liveToMessage(live({ items }))
expect(composing.parts[0].process).toBe(true)
expect(composing.parts[1]).toMatchObject({ type: 'tool_call', toolPhase: 'composing', toolCall: { name: 'run_python', arguments: '{"script":"print(1)"}' } })
items = applyToolCall(items, 'run_python', 'c1', undefined, 2000)
items = applyToolOutput(items, 'c1', 'hello\n')
items = applyToolResult(items, 'c1', 'ok', 'exit=0', 3500)
const done = liveToMessage(live({ items }))
expect(done.parts[1]).toMatchObject({ toolPhase: 'done', toolOutput: 'hello\n', toolCall: { durationMs: 1500 } })
expect(done.parts[2]).toMatchObject({ type: 'tool_result', toolResult: { summary: 'exit=0' } })
})
})
@@ -78,7 +92,7 @@ describe('liveToMessage', () => {
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_call', process: true, toolCall: { id: 'c1', name: 'update_todos' }, toolPhase: 'done' },
{ type: 'tool_result', process: true, toolCall: { id: 'c1', name: 'update_todos' },
toolResult: { callId: 'c1', status: 'ok', summary: '已更新' } },
{ type: 'thinking', text: '再想想', process: true },
@@ -122,8 +136,7 @@ describe('liveToMessage', () => {
const msg = liveToMessage(live({ items }))
for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true)
})
it('deliverable cards are content parts pinned after the reply text', () => {
// 与落库序同构(llm.go 交付物钉尾):artifact_ref 在终轮正文之后、不折叠。
it('does not project deliverable cards while the run is live', () => {
let items: LiveItem[] = []
items = applyTextDelta(items, 0, '生成报表。')
items = applyToolCall(items, 'make_report', 'c1')
@@ -131,10 +144,34 @@ describe('liveToMessage', () => {
items = applyArtifactCreated(items, { artifactId: 'a1', name: 'report.csv', kind: 'csv' })
items = applyTextDelta(items, 1, '完成。')
const msg = liveToMessage(live({ items }))
expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'artifact_ref'])
expect(msg.parts.find(p => p.type === 'artifact_ref')?.process).toBeFalsy() // 内容部件,不折叠
expect(msg.parts.some(p => p.type === 'artifact_ref')).toBe(false)
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined])
})
it('moves earlier text into the process area once finalizing starts', () => {
let items: LiveItem[] = applyTextDelta([], 0, '草稿回复')
items = applyCheck(items, false, '纸张不是 A4')
items = applyFinalizing(items)
items = applyTextDelta(items, 1, '纪要已写好。')
const msg = liveToMessage(live({ phase: 'finalizing', items }))
expect(msg.parts.map(p => p.type)).toEqual(['text', 'check', 'text'])
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined])
expect(msg.parts[1]).toMatchObject({ type: 'check', process: true, check: { passed: false, reason: '纸张不是 A4' } })
})
})
describe('livePhaseLabel', () => {
it('names the current activity in Chinese', () => {
let items: LiveItem[] = applyThinkingDelta([], '想', 0)
expect(livePhaseLabel(live({ items }), 0)).toBe('思考中')
items = applyToolCallDelta(items, 0, '{}', 'c', 'run_python', 0)
expect(livePhaseLabel(live({ items }), 0)).toBe('正在编写 run_python')
items = applyToolCall(items, 'run_python', 'c', undefined, 1000)
expect(livePhaseLabel(live({ items }), 4000)).toBe('正在运行 run_python · 3 秒')
items = applyToolResult(items, 'c', 'ok', 'done', 4000)
items = applyTextDelta(items, 1, '好了')
expect(livePhaseLabel(live({ items }), 4000)).toBe('回复中')
expect(livePhaseLabel(live({ items, phase: 'finalizing' }), 4000)).toBe('整理回复中')
})
})
describe('showProcessInline', () => {
@@ -154,3 +191,17 @@ describe('showProcessInline', () => {
expect(showProcessInline(null, false)).toBe(true)
})
})
describe('hasSettledReply', () => {
const user = { role: 'user', runId: 'r1' }
const assistant = { role: 'assistant', runId: 'r1' }
it('stays false while only the user message carries the run id', () => {
expect(hasSettledReply([user], 'r1')).toBe(false)
expect(hasSettledReply([user, { role: 'assistant', runId: 'other' }], 'r1')).toBe(false)
})
it('turns true once an assistant message for that run is stored', () => {
expect(hasSettledReply([user, assistant], 'r1')).toBe(true)
})
})
+41 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { splitTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel } from '../src/messageparts'
import { splitTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../src/messageparts'
import type { Part } from '../src/contracts'
function p(partial: Partial<Part> & { type: Part['type'] }): Part {
@@ -32,6 +32,20 @@ describe('splitTimeline', () => {
expect(split.final.map(x => x.text)).toEqual(['最终'])
})
it('lifts the last narration when a finished run has no final text', () => {
const parts: Part[] = [
p({ type: 'text', text: '先写一版', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'run_python' } }),
p({ type: 'text', text: '纪要已经整理好', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'run_python' } }),
]
const split = splitTimeline(parts, true)
expect(split.final.map(x => x.text)).toEqual(['纪要已经整理好'])
expect(split.final[0].process).toBeUndefined()
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_call'])
expect(splitTimeline(parts).final).toEqual([])
})
it('puts a message with no process parts entirely in the final section', () => {
const parts = [p({ type: 'text', text: 'hi' })]
expect(splitTimeline(parts).timeline).toEqual([])
@@ -54,6 +68,32 @@ describe('artifact ids in reply text', () => {
})
})
describe('deliverables, errors, and the process summary', () => {
it('keeps the latest card for a repeated file name', () => {
const parts: Part[] = [
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '精美版.docx' } }),
p({ type: 'artifact_ref', artifact: { artifactId: 'a2', name: '精美版.docx' } }),
p({ type: 'artifact_ref', artifact: { artifactId: 'b1', name: '纪要.md' } }),
]
expect(dedupeDeliverables(parts)).toEqual([
{ artifact: { artifactId: 'a2', name: '精美版.docx' }, updates: 1 },
{ artifact: { artifactId: 'b1', name: '纪要.md' }, updates: 0 },
])
})
it('folds a same-run error into the assistant message that has a body', () => {
const body = { id: 'm1', runId: 'r1', role: 'assistant', parts: [p({ type: 'text', text: '做到这里' })] }
const err = { id: 'm2', runId: 'r1', role: 'assistant', parts: [p({ type: 'error', error: { code: 'budget', message: 'max turns' } })] }
const folded = foldRunErrors([body, err])
expect(folded.map(m => m.id)).toEqual(['m1'])
expect(folded[0].parts.map(part => part.type)).toEqual(['text', 'error'])
expect(humanError('run.cancelled', '已取消').title).toBe('已取消')
expect(humanError('budget', 'max turns').title).toContain('已停止')
expect(processSummary(12, 3, '2026-10-01T00:00:00.000Z', '2026-10-01T00:03:13.000Z'))
.toBe('已工作 3 分 13 秒 · 12 次工具调用 · 3 个文件')
})
})
describe('workedLabel', () => {
it('formats the run span and degrades without timestamps', () => {
expect(workedLabel('2026-10-01T00:00:00.000Z', '2026-10-01T00:01:05.000Z')).toBe('已工作 1 分 5 秒')
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { createElement } from 'react'
import { activeRun } from '../src/store'
import { NoticeMessage } from '../src/components/Conversation'
import type { Message, Run } from '../src/contracts'
function run(kind: Run['kind'], status: Run['status']): Run {
return {
id: `${kind}-${status}`, conversationId: 'c', projectId: 'p',
kind, status, createdAt: '2026-10-01T00:00:00Z',
}
}
describe('activeRun', () => {
it('ignores background subagents so they do not block sending', () => {
const runs = [run('subagent', 'running'), run('conversation', 'completed')]
expect(activeRun(runs)).toBeUndefined()
expect(activeRun([run('conversation', 'running'), run('subagent', 'running')])?.kind).toBe('conversation')
})
})
describe('NoticeMessage', () => {
it('renders a compact notice row', () => {
const msg: Message = {
id: 'msg_notice', conversationId: 'c', role: 'system', createdAt: '2026-10-01T00:00:00Z',
parts: [{ type: 'text', text: '<system-reminder>\n后台子代理「检索」已结束。\n</system-reminder>' }],
}
const html = renderToStaticMarkup(createElement(NoticeMessage, { msg }))
expect(html).toContain('通知 · 后台子代理「检索」已结束。')
expect(html).toContain('msg notice')
expect(html).not.toContain('<details')
})
})
+10 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage,
deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage,
removeQueuedMessage, replaceQueuedText,
} from '../src/outbox'
@@ -25,4 +25,13 @@ describe('outbox', () => {
const again = enqueueMessageFront(q, b)
expect(again.map(x => x.id)).toEqual([b.id, a.id])
})
it('reuses the queued id as the request id when the send is retried', () => {
const item = newQueuedMessage('hi', ['f1'], ['note.md'])
const queued = enqueueMessageFront([], item)
expect(deliveryRequestId(queued[0])).toBe(item.id)
const retried = enqueueMessageFront(queued, { ...item, text: item.text })
expect(deliveryRequestId(retried[0])).toBe(item.id)
expect(retried).toHaveLength(1)
})
})
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { scrollRatio, scrollTopForRatio } from '../src/scrollsync'
describe('scrollsync', () => {
it('没有溢出时不同步', () => {
expect(scrollRatio({ scrollTop: 0, scrollHeight: 100, clientHeight: 100 })).toBeNull()
expect(scrollTopForRatio({ scrollHeight: 80, clientHeight: 80 }, 0.5)).toBeNull()
})
it('按可滚动范围的比例映射,并夹在 0 到 1', () => {
expect(scrollRatio({ scrollTop: 50, scrollHeight: 200, clientHeight: 100 })).toBe(0.5)
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, 0.5)).toBe(100)
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, 2)).toBe(200)
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, -1)).toBe(0)
})
})
+37
View File
@@ -0,0 +1,37 @@
// 发送后的运行态投影:把刚启动的 run 合进列表,并用事件上的服务端时间。
import { describe, expect, it } from 'vitest'
import type { Run } from '../src/contracts'
import { activeRun, eventTime, mergeStartedRun } from '../src/store'
function run(id: string, status: Run['status'], over: Partial<Run> = {}): Run {
return {
id, conversationId: 'c', projectId: 'p', kind: 'conversation', status,
createdAt: '2026-10-03T00:00:00Z', ...over,
}
}
describe('mergeStartedRun', () => {
it('adds a pending run so the composer treats the conversation as active', () => {
const merged = mergeStartedRun([], run('r1', 'pending'))
expect(activeRun(merged)?.id).toBe('r1')
})
it('keeps an existing record when the same id arrives again', () => {
const settled = run('r1', 'completed', { phase: 'done' })
const merged = mergeStartedRun([settled], run('r1', 'pending'))
expect(merged).toHaveLength(1)
expect(merged[0].status).toBe('completed')
expect(activeRun(merged)).toBeUndefined()
})
})
describe('eventTime', () => {
it('parses the server timestamp', () => {
expect(eventTime({ ts: '2026-10-03T14:50:25.073Z' }, 0)).toBe(Date.parse('2026-10-03T14:50:25.073Z'))
})
it('falls back when the timestamp is missing or unparseable', () => {
expect(eventTime({}, 42)).toBe(42)
expect(eventTime({ ts: 'not-a-time' }, 42)).toBe(42)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { projectStage } from '../src/projectmeta'
import { resolveTheme } from '../src/theme'
describe('resolveTheme', () => {
it('跟随系统只在深色与浅色之间解析', () => {
expect(resolveTheme('system', true)).toBe('dark')
expect(resolveTheme('system', false)).toBe('light')
})
it('墨绿与浅色原样上屏,未知值回落深色', () => {
expect(resolveTheme('molv', true)).toBe('molv')
expect(resolveTheme('light', true)).toBe('light')
expect(resolveTheme('nope', false)).toBe('dark')
})
})
describe('projectStage', () => {
it('只在 settings.stage 是非空字符串时显示', () => {
expect(projectStage({ stage: '设计输入' })).toBe('设计输入')
expect(projectStage({ stage: ' ' })).toBeNull()
expect(projectStage({ stage: 3 })).toBeNull()
expect(projectStage(undefined)).toBeNull()
})
})
+15 -3
View File
@@ -39,7 +39,7 @@ describe('timelineEntries', () => {
p({ type: 'tool_call', process: true, toolCall: { id: 'c', name: 'run_python', arguments: '{"script":"print(1)"}' } }),
]
const rows = timelineEntries(parts, true)
expect(rows.map(r => r.kind)).toEqual(['thinking', 'tools', 'text', 'tools'])
expect(rows.map(r => r.kind)).toEqual(['thinking', 'tools', 'text', 'toolblock'])
const reads = rows[1]
if (reads.kind !== 'tools') throw new Error('expected tool group')
expect(reads.toolKind).toBe('read')
@@ -49,8 +49,20 @@ describe('timelineEntries', () => {
if (thinking.kind !== 'thinking') throw new Error('expected thinking')
expect(thinking.active).toBe(false)
const exec = rows[3]
if (exec.kind !== 'tools') throw new Error('expected exec group')
expect(exec.items[0].running).toBe(true)
if (exec.kind !== 'toolblock') throw new Error('expected exec block')
expect(exec.item.running).toBe(true)
expect(exec.item.headline).toBe('print(1)')
})
it('renders a check row instead of narration', () => {
const rows = timelineEntries([
p({ type: 'check', process: true, check: { passed: false, reason: '纸张不是 A4' } }),
p({ type: 'check', process: true, check: { passed: true } }),
], false)
expect(rows).toEqual([
{ kind: 'check', passed: false, reason: '纸张不是 A4' },
{ kind: 'check', passed: true, reason: '' },
])
})
it('marks the trailing thinking row active while the run is live', () => {
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { buildWorkspaceEntries } from '../src/workspaces'
describe('buildWorkspaceEntries', () => {
it('没有领域插件时,工件和代理补在 ⌘1 ⌘2', () => {
const entries = buildWorkspaceEntries([])
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 工件', '⌘2 代理'])
})
it('声明了 shortcut 的工作区占住该位,内置工具让位', () => {
const entries = buildWorkspaceEntries([
{ id: 'trace', title: '追溯网', shortcut: '1' },
])
expect(entries.map(e => e.title)).toEqual(['追溯网', '工件', '代理'])
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3'])
expect(entries[0].key).toBe('ws:trace')
})
it('未编号的工作区排在内置工具后面', () => {
const entries = buildWorkspaceEntries([
{ id: 'mem', title: '记忆库' },
])
expect(entries.map(e => e.title)).toEqual(['工件', '代理', '记忆库'])
})
})
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/outbox.ts","./src/passwords.ts","./src/previewsetup.ts","./src/previewtabs.ts","./src/store.ts","./src/tasklist.ts","./src/toolkind.ts","./src/components/AdminUsersModal.tsx","./src/components/AdminZone.tsx","./src/components/AgentTracker.tsx","./src/components/ArtifactsPanel.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/PreviewPane.tsx","./src/components/ProcessTimeline.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./src/components/adminSections.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/design-guard.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/outbox.test.ts","./test/previewtabs.test.ts","./test/tasklist.test.ts","./test/toolkind.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/outbox.ts","./src/passwords.ts","./src/pluginHost.ts","./src/previewsetup.ts","./src/previewtabs.ts","./src/projectmeta.ts","./src/runstate.test.ts","./src/runstate.ts","./src/scrollsync.ts","./src/store.ts","./src/tasklist.ts","./src/theme.ts","./src/toolkind.ts","./src/workspaces.ts","./src/components/AdminUsersModal.tsx","./src/components/AdminZone.tsx","./src/components/AgentTracker.tsx","./src/components/ArtifactsPanel.tsx","./src/components/BorrowPanels.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/PreviewPane.tsx","./src/components/ProcessTimeline.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/TopBar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./src/components/adminSections.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/design-guard.test.ts","./test/html-sinks.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/notice.test.ts","./test/outbox.test.ts","./test/previewtabs.test.ts","./test/scrollsync.test.ts","./test/send.test.ts","./test/tasklist.test.ts","./test/theme.test.ts","./test/toolkind.test.ts","./test/workspaces.test.ts"],"version":"5.9.3"}
+1 -1
View File
@@ -10,7 +10,7 @@ export default defineConfig({
resolve: {
alias: [{ find: '@ui', replacement: uiSrc }],
// 源码组合下 ui 仓有自己的 node_modules;dedupe 强制最终 bundle 用 frontend 的单副本。
dedupe: ['react', 'react-dom', 'marked'],
dedupe: ['react', 'react-dom', 'marked', 'dompurify'],
},
server: {
port: 5173,