Compare commits
161
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
034bd5f724 | ||
|
|
83a062b7f8 | ||
|
|
fdaf89de9d | ||
|
|
986ebdbb61 | ||
|
|
e058adcf72 | ||
|
|
d01a3ebd81 | ||
|
|
fac99939fa | ||
|
|
c1225bb880 | ||
|
|
57fabf1a95 | ||
|
|
360d6406e4 | ||
|
|
25e76c5e01 | ||
|
|
41f6ce8760 | ||
|
|
0f860c0743 | ||
|
|
fdedaa1123 | ||
|
|
4ab507b1b6 | ||
|
|
01b853fd0e | ||
|
|
7acfef0a1f | ||
|
|
c22120d7fe | ||
|
|
15d8a4093b | ||
|
|
d2d81e119c | ||
|
|
fb846bed14 | ||
|
|
566fec9f56 | ||
|
|
98751916da | ||
|
|
ed9845f8e4 | ||
|
|
a2ebc7fb84 | ||
|
|
367aeefff4 | ||
|
|
65d3b8ef02 | ||
|
|
ed7e3126e7 | ||
|
|
aaced8796b | ||
|
|
d0d9876b18 | ||
|
|
2c56054861 | ||
|
|
c2878c04ea | ||
|
|
b80153dbf8 | ||
|
|
39dc8c456c | ||
|
|
482ba885e1 | ||
|
|
09310cb753 | ||
|
|
fcf40ff75f | ||
|
|
dc18d6c747 | ||
|
|
68f6c8638b | ||
|
|
df4ca1e470 | ||
|
|
8108655e61 | ||
|
|
6b4920b509 | ||
|
|
a26d7c5aee | ||
|
|
69b1487fb7 | ||
|
|
32e327a831 | ||
|
|
3ed10e1220 | ||
|
|
204ca67dc7 | ||
|
|
339a9ebc2d | ||
|
|
475fd3a8d6 | ||
|
|
84b73ab328 | ||
|
|
c71a77bb22 | ||
|
|
50c7244601 | ||
|
|
2464c16e6b | ||
|
|
0a0b55612c | ||
|
|
62aa5f8a56 | ||
|
|
d384a3d778 | ||
|
|
787a09ecd3 | ||
|
|
345adf8e9b | ||
|
|
c401bfe1fe | ||
|
|
817c43e764 | ||
|
|
9badbb1f85 | ||
|
|
a775e1998f | ||
|
|
cf6f24e583 | ||
|
|
2f599c84f8 | ||
|
|
24d9f632ca | ||
|
|
eb6502f0ef | ||
|
|
13a1c63280 | ||
|
|
02cebc06ce | ||
|
|
36b92ede88 | ||
|
|
1f42edf80a | ||
|
|
b25979401a | ||
|
|
e26119db89 | ||
|
|
82d216b324 | ||
|
|
0eaced4d5a | ||
|
|
3edae0212c | ||
|
|
5961b6e9bd | ||
|
|
dcf9b6b847 | ||
|
|
799f3d30fe | ||
|
|
d5f59f1811 | ||
|
|
8be98b4cbe | ||
|
|
3936fff664 | ||
|
|
e4ca2acfa6 | ||
|
|
539aae69b9 | ||
|
|
763c4b4804 | ||
|
|
e5555b194a | ||
|
|
794c3e1657 | ||
|
|
ed95f64890 | ||
|
|
eca1665d91 | ||
|
|
bca7795218 | ||
|
|
c108209923 | ||
|
|
aedcf50fc9 | ||
|
|
1b4666c613 | ||
|
|
d1ea616b08 | ||
|
|
7d796ee507 | ||
|
|
83357b5537 | ||
|
|
f718095d78 | ||
|
|
bcc9f1b5db | ||
|
|
2b87a9c081 | ||
|
|
22f7d84560 | ||
|
|
e1fd10ec3d | ||
|
|
0ed8ad32f1 | ||
|
|
59065fecb8 | ||
|
|
0cc0f40425 | ||
|
|
ba52fad402 | ||
|
|
aad5871498 | ||
|
|
53d38c8ccb | ||
|
|
288d3aca07 | ||
|
|
3c815250a0 | ||
|
|
bfa634d2a9 | ||
|
|
b569d8d7b3 | ||
|
|
c17ac2413f | ||
|
|
7089f768fd | ||
|
|
de38791e25 | ||
|
|
f054187bb0 | ||
|
|
b048d90de4 | ||
|
|
ae761c2c4a | ||
|
|
e87bcf595f | ||
|
|
7418e507a0 | ||
|
|
1fc1d2f9b4 | ||
|
|
e7fa8756db | ||
|
|
801e551d8d | ||
|
|
b3112aa412 | ||
|
|
93587fe813 | ||
|
|
bfaf65ae1f | ||
|
|
48a14fb4d2 | ||
|
|
8bc5b47914 | ||
|
|
cd77340d92 | ||
|
|
c5e93568a5 | ||
|
|
89bfa83cae | ||
|
|
6752b788b9 | ||
|
|
411d09b3a8 | ||
|
|
af0385451a | ||
|
|
a61bd70399 | ||
|
|
b329de3bf5 | ||
|
|
b067b57975 | ||
|
|
c6464bc4b0 | ||
|
|
65f7753a79 | ||
|
|
8712fadb49 | ||
|
|
64837575a9 | ||
|
|
765789f34d | ||
|
|
754ce83ffd | ||
|
|
22a87232d7 | ||
|
|
fb219e04a3 | ||
|
|
edb5dbe73e | ||
|
|
9c87953877 | ||
|
|
a3c556cd0a | ||
|
|
642299ddd4 | ||
|
|
b2fbcf72bc | ||
|
|
59ffe044b5 | ||
|
|
01672deec5 | ||
|
|
9fc1387235 | ||
|
|
2516df5b3b | ||
|
|
1d6b8a64a6 | ||
|
|
6cfc1059c5 | ||
|
|
dc70399d45 | ||
|
|
3aeafc4440 | ||
|
|
4abf13d33d | ||
|
|
0eb9066510 | ||
|
|
616795be41 | ||
|
|
a54022c438 | ||
|
|
a617504268 |
+9
-1
@@ -1,3 +1,11 @@
|
|||||||
node_modules/
|
# 无斜杠形式同时忽略目录与符号链接(目录式规则不匹配 symlink,曾致链接被提交)。
|
||||||
|
node_modules
|
||||||
dist/
|
dist/
|
||||||
*.local
|
*.local
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
|
||||||
|
.mimosa
|
||||||
|
# Playwright 运行工件(e2e/ 用例输出;详工件在 Dev .tmp/e2e/<ts>/frontend/)
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|||||||
@@ -17,3 +17,21 @@
|
|||||||
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。
|
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。
|
||||||
- 跨仓 gitlink 变更须经组合验证后在 Dev 仓推进;受保护分支经 PR 合入。
|
- 跨仓 gitlink 变更须经组合验证后在 Dev 仓推进;受保护分支经 PR 合入。
|
||||||
- 验证命令见 PROJECT.md「验证」节;完成声明须附实际命令输出证据。
|
- 验证命令见 PROJECT.md「验证」节;完成声明须附实际命令输出证据。
|
||||||
|
- 异步状态只有一个所有者。事件按 seq 处理,过期事件丢弃。
|
||||||
|
|
||||||
|
## Gitea 与 Git 访问事实(2026-09-30 实测)
|
||||||
|
|
||||||
|
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
|
||||||
|
见到一律按过期处理,不要再试。当前权威通道:
|
||||||
|
|
||||||
|
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
|
||||||
|
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
|
||||||
|
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
|
||||||
|
(各仓 origin 与 gitlink 均为此格式)。
|
||||||
|
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
|
||||||
|
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
|
||||||
|
用作 `Authorization: token <值>` 请求头。**token 值不入仓、不回显、不写进产出物**
|
||||||
|
(baseline 硬边界 2:只记录来源、字段与可用状态)。
|
||||||
|
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
|
||||||
|
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
|
||||||
|
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
|
||||||
|
|||||||
+1
-1
@@ -10,4 +10,4 @@
|
|||||||
- `npx tsc -b` — 类型检查;`npm run build` — 产物 dist/(Dev Makefile 拷贝至 backend web/dist 内嵌)。
|
- `npx tsc -b` — 类型检查;`npm run build` — 产物 dist/(Dev Makefile 拷贝至 backend web/dist 内嵌)。
|
||||||
- `npm test` — vitest:契约形状测试(Core 黄金样本驱动的判别联合/事件信封/HITL impact/工具三清单断言)。
|
- `npm test` — vitest:契约形状测试(Core 黄金样本驱动的判别联合/事件信封/HITL impact/工具三清单断言)。
|
||||||
- 开发:`npm run dev`(Vite 代理 /api → 127.0.0.1:8787)。
|
- 开发:`npm run dev`(Vite 代理 /api → 127.0.0.1:8787)。
|
||||||
- 依赖刻意最小:react + marked;无 UI 框架(设计令牌手写,见 styles.css)。
|
- 依赖刻意最小:react + marked + dompurify(模型输出与文档预览先清洗再写入页面);无 UI 框架(设计令牌手写,见 styles.css)。
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// 双入口对拍的浏览器侧说明。CI 门禁在 backend command_pairs_test.go:
|
||||||
|
// 新内置命令未写入 testdata/command-pairs.json 时 go test 失败。
|
||||||
|
// 人工抽查:PLAYWRIGHT_INCLUDE_COMMAND_PAIRS=1 npx playwright test e2e/command-pairs.playwright.ts
|
||||||
|
// (scripts/e2e.sh 驱动的验收跑 auth-on 种子环境,本用例面向免登录演示环境,默认跳过。)
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test('command menu and chat share builtin.help', async ({ page }) => {
|
||||||
|
test.skip(!process.env.PLAYWRIGHT_BASE_URL || !process.env.PLAYWRIGHT_INCLUDE_COMMAND_PAIRS,
|
||||||
|
'set PLAYWRIGHT_BASE_URL and PLAYWRIGHT_INCLUDE_COMMAND_PAIRS to run')
|
||||||
|
await page.goto(process.env.PLAYWRIGHT_BASE_URL!)
|
||||||
|
await expect(page.getByText('帮助')).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// 增量 2 验收(2026-10-12 诊断报告):普通基座(无 rdm.inspect.* 命令面)不展示
|
||||||
|
// 「追溯审计」入口,且不发出任何 rdm.* 命令请求——消除「入口可达、进入即 400」。
|
||||||
|
// 驱动:scripts/e2e.sh 第 4 阶段——RDM 组合验收跑完后,同一数据目录换基座 CLI
|
||||||
|
//(agent-system serve,同源同 pin)服务,同一 preview 指向它,只跑本文件。
|
||||||
|
// 环境契约与 rdm-audit.playwright.ts 一致(e2e.sh 注入)。
|
||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||||
|
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
|
||||||
|
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||||
|
|
||||||
|
// 仅第 4 阶段(普通基座)启用:RDM 组合主跑(第 3 阶段)扫全目录时本文件自跳过,
|
||||||
|
// 否则「入口隐藏」断言会跑在入口本就可见的 RDM 组合上。
|
||||||
|
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
|
||||||
|
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (scripts/e2e.sh phase 4 drives this suite)')
|
||||||
|
|
||||||
|
// 与 rdm-audit spec 同一账号:若数据库仍是初始密码则先过强制改密门。
|
||||||
|
async function login(page: Page) {
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
const gate = page.getByText('修改密码')
|
||||||
|
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||||
|
if (gated) {
|
||||||
|
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||||
|
await pwInputs.nth(0).fill(PASSWORD)
|
||||||
|
await pwInputs.nth(1).fill(PASSWORD2)
|
||||||
|
await pwInputs.nth(2).fill(PASSWORD2)
|
||||||
|
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||||
|
} else {
|
||||||
|
// 已改密(RDM 阶段完成):改用新密码重登。
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('普通基座:命令面无 rdm.inspect.* → 审计入口隐藏、无 rdm 请求', async ({ page }) => {
|
||||||
|
const rdmRequests: string[] = []
|
||||||
|
page.on('request', r => {
|
||||||
|
if (r.url().includes('/api/commands/rdm.')) rdmRequests.push(r.url())
|
||||||
|
})
|
||||||
|
await login(page)
|
||||||
|
// 登录成功标志:侧栏常驻「代理」与「文件」体系工具(均为无条件内置)。
|
||||||
|
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||||
|
await expect(page.locator('.tsb-tool', { hasText: '文件' })).toBeVisible()
|
||||||
|
// 能力探测(GET /api/commands)返回普通基座清单后,审计入口保持隐藏。
|
||||||
|
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
|
||||||
|
// 入口不存在则不应有任何 rdm.* 命令请求(原缺陷路径是进入即 400)。
|
||||||
|
expect(rdmRequests).toEqual([])
|
||||||
|
})
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// U4 运行验收:真实浏览器走通项目追溯审计工作区(RdmAuditWorkspace 四入口)。
|
||||||
|
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制 + frontend dist,起 serve(auth on)
|
||||||
|
// 与 vite preview,并经 /api/commands 造种子(主项目已 ingest、隔离项目零数据)。
|
||||||
|
// 环境契约(e2e.sh 注入;缺省值与 e2e.sh 保持一致):
|
||||||
|
// PLAYWRIGHT_BASE_URL 前端入口(默认 http://127.0.0.1:4173)
|
||||||
|
// E2E_OWNER/E2E_PASSWORD 登录账号(域管理员+基座管理员)
|
||||||
|
// E2E_PROJECT_MAIN 已 ingest 的主项目名
|
||||||
|
// E2E_PROJECT_ISOLATED 零数据隔离项目名
|
||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||||
|
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
|
||||||
|
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||||
|
const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目'
|
||||||
|
const ISOLATED = process.env.E2E_PROJECT_ISOLATED || '隔离检查项目'
|
||||||
|
|
||||||
|
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
|
||||||
|
|
||||||
|
async function login(page: Page) {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
|
||||||
|
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||||
|
// 首个用例用初始密码(触发强制改密门),后续用例用改后的新密码。
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
const gate = page.getByText('修改密码')
|
||||||
|
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||||
|
if (gated) {
|
||||||
|
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||||
|
await pwInputs.nth(0).fill(PASSWORD)
|
||||||
|
await pwInputs.nth(1).fill(PASSWORD2)
|
||||||
|
await pwInputs.nth(2).fill(PASSWORD2)
|
||||||
|
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||||
|
} else {
|
||||||
|
// 已改密:改用新密码重登。
|
||||||
|
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||||
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||||
|
}
|
||||||
|
// 登录成功后侧栏体系工具出现「追溯审计」入口。
|
||||||
|
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toBeVisible({ timeout: 15_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAudit(page: Page) {
|
||||||
|
// 展开态侧栏底部的体系工具条(tsb-tool,name=标题)。
|
||||||
|
await page.locator('.tsb-tool', { hasText: '追溯审计' }).click()
|
||||||
|
await expect(page.getByText('追溯审计').first()).toBeVisible()
|
||||||
|
await expect(page.locator('.rdm-audit-project')).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.serial('U4 项目追溯审计工作区', () => {
|
||||||
|
// 卡片按 label 精确定位(「章节」与「空缺章节」等子串互不干扰)。
|
||||||
|
const card = (page: Page, label: string) =>
|
||||||
|
page.locator('.rdm-audit-card').filter({ has: page.locator('.rdm-audit-card-label', { hasText: new RegExp(`^${label}$`) }) })
|
||||||
|
|
||||||
|
test('登录并打开工作区,概览计数与覆盖标注可读', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
await openAudit(page)
|
||||||
|
// 概览:统计卡片 + 水位标注(数据完整性提示,不冒充实时)。
|
||||||
|
await expect(page.locator('.rdm-audit-card')).toHaveCount(9)
|
||||||
|
await expect(page.getByText(/截至水位 \d+/)).toBeVisible()
|
||||||
|
// 主项目应有切片与章节(种子 structure 装载 + ingest 保证)。
|
||||||
|
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/)
|
||||||
|
await expect(card(page, '章节')).toContainText(/[1-9]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('三次主要点击内完成取证:章节来源依据与最近修改原因', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
await openAudit(page)
|
||||||
|
// 路径①:资料与文档 → 展开文档(点击1)→ 选章节(点击2)→ 详情含最近事件(修改原因)。
|
||||||
|
// docs 树是层级结构:第一层是文档,章节在展开后的第二层。
|
||||||
|
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||||
|
await page.locator('.rdm-audit-tree-node').first().click()
|
||||||
|
const sectionNode = page.locator('.rdm-audit-tree-children .rdm-audit-tree-node')
|
||||||
|
.filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: 'section' }) }).first()
|
||||||
|
await expect(sectionNode).toBeVisible()
|
||||||
|
await sectionNode.click()
|
||||||
|
await expect(page.locator('.rdm-audit-detail')).toBeVisible()
|
||||||
|
await expect(page.getByText('最近事件')).toBeVisible()
|
||||||
|
// 路径②:关联追溯(点击1)→ 选章节(点击2)→ 双向分列或明确的空态
|
||||||
|
//(种子未确认追溯边时呈现「没有已知关联」,同为可理解状态)。
|
||||||
|
await page.getByRole('tab', { name: '关联追溯' }).click()
|
||||||
|
await expect(page.locator('.rdm-audit-pick button')).not.toHaveCount(0)
|
||||||
|
await page.locator('.rdm-audit-pick button').first().click()
|
||||||
|
await expect(page.getByText('← 返回')).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.getByText(/来源依据(\d+)/).or(page.getByText('没有已知关联')),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('操作记录按操作归组,历史模式可见水位', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
await openAudit(page)
|
||||||
|
await page.getByRole('tab', { name: '操作记录' }).click()
|
||||||
|
// 事件以单行或操作组呈现;种子项目必有 ingest 事件。
|
||||||
|
await expect(
|
||||||
|
page.locator('.rdm-audit-event').or(page.locator('.rdm-audit-opgroup')),
|
||||||
|
).not.toHaveCount(0)
|
||||||
|
// 历史模式:返回当前切换明确,无对象时输入提示可理解。
|
||||||
|
await page.getByRole('button', { name: '历史模式' }).click()
|
||||||
|
await expect(page.getByText(/截至 seq \d+/)).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: '← 返回当前' }).click()
|
||||||
|
await expect(page.getByRole('button', { name: '历史模式' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('项目切换无串页:隔离项目显示空态,切回主项目数据一致', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
await openAudit(page)
|
||||||
|
// 切到隔离项目:概览计数应为 0 且无残留「加载中…」。
|
||||||
|
await page.locator('.rdm-audit-project').selectOption({ label: ISOLATED })
|
||||||
|
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 })
|
||||||
|
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText('0')
|
||||||
|
// 空态:文档树与时间线给出可理解提示。
|
||||||
|
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||||
|
await expect(page.getByText('暂无文档')).toBeVisible()
|
||||||
|
await page.getByRole('tab', { name: '操作记录' }).click()
|
||||||
|
await expect(page.getByText('暂无事件')).toBeVisible()
|
||||||
|
// 立即切回主项目:标题与请求一致,数据回到非零(过期响应被丢弃,无串页)。
|
||||||
|
await page.getByRole('tab', { name: '概览' }).click()
|
||||||
|
await page.locator('.rdm-audit-project').selectOption({ label: MAIN })
|
||||||
|
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 })
|
||||||
|
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/)
|
||||||
|
await expect(page.locator('.rdm-audit-project')).toHaveValue(
|
||||||
|
await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"exports": 43
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"entry": ["src/main.tsx", "src/**/*.test.ts", "src/**/*.test.tsx", "test/**/*.ts", "test/**/*.tsx"],
|
||||||
|
"project": ["src/**/*.{ts,tsx}"]
|
||||||
|
}
|
||||||
Generated
+1387
-187
File diff suppressed because it is too large
Load Diff
+13
-3
@@ -7,19 +7,29 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"docx-preview": "^0.4.1",
|
||||||
|
"dompurify": "^3.4.16",
|
||||||
|
"jszip": "^3.10.2",
|
||||||
|
"mammoth": "^1.13.0",
|
||||||
"marked": "^15.0.7",
|
"marked": "^15.0.7",
|
||||||
|
"pptx-preview": "^1.0.7",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.50.1",
|
||||||
|
"@types/node": "^22.20.4",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"knip": "^5.88.1",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.0.11",
|
"vite": "^6.0.11",
|
||||||
"vitest": "^3.0.5"
|
"vitest": "^4.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// U4 运行验收(Playwright):真实浏览器走通追溯审计工作区四入口。
|
||||||
|
// 由 Dev 仓 scripts/e2e.sh 驱动(起 RDM 二进制 + preview 静态前端 + 种子数据);
|
||||||
|
// PLAYWRIGHT_BASE_URL 未设置时整组跳过(CI 单测档不拉浏览器)。
|
||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
const base = process.env.PLAYWRIGHT_BASE_URL || ''
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: 'e2e',
|
||||||
|
testMatch: /.*(spec|playwright)\.ts$/,
|
||||||
|
timeout: 30_000,
|
||||||
|
retries: 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['list']],
|
||||||
|
use: {
|
||||||
|
baseURL: base,
|
||||||
|
locale: 'zh-CN',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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}`)
|
||||||
+86
-50
@@ -1,44 +1,27 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { auth } from './auth'
|
import { auth } from './auth'
|
||||||
|
import { api } from './api'
|
||||||
|
import { LoginPage, ChangePasswordGate } from './components/LoginPage'
|
||||||
|
import { ChangePasswordModal } from './components/AdminUsersModal'
|
||||||
|
import { AdminZone } from './components/AdminZone'
|
||||||
|
import { Icon } from '@ui'
|
||||||
import { store, useStore } from './store'
|
import { store, useStore } from './store'
|
||||||
import { ProjectNav } from './components/ProjectNav'
|
import { TaskSidebar } from './components/TaskSidebar'
|
||||||
import { Conversation } from './components/Conversation'
|
import { Conversation } from './components/Conversation'
|
||||||
|
import { previewTabsKey } from './previewtabs'
|
||||||
import { Workbench } from './components/Workbench'
|
import { Workbench } from './components/Workbench'
|
||||||
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
|
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
|
||||||
import { AgentTracker } from './components/AgentTracker'
|
import { AgentTracker } from './components/AgentTracker'
|
||||||
import { SettingsModal } from './components/SettingsModal'
|
import { SettingsModal } from './components/SettingsModal'
|
||||||
import { DocViewer } from './components/DocViewer'
|
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
|
||||||
|
import { TopBar } from './components/TopBar'
|
||||||
|
import { workspaceEntries } from './components/Workspaces'
|
||||||
|
|
||||||
// 三栏布局:左(项目会话)/ 中(对话)/ 右(工作台);分隔条拖拽与超调收起(4.4)。
|
// 布局(三栏工作台):左(项目与会话)/ 中(对话)/ 第三栏(工作|文件双入口)。
|
||||||
|
// 文件预览不再是独立第四栏——预览在第三栏「文件」入口内,展开态铺满主区。
|
||||||
const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
|
const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
|
||||||
const RIGHT_MIN = 280, RIGHT_MAX = 560
|
const RIGHT_MIN = 280, RIGHT_MAX = 560
|
||||||
|
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
|
||||||
// 登录门:服务端开启 API key 认证时(PG 档默认)要求输入密钥;嵌入式档无认证直接放行。
|
|
||||||
function LoginGate({ onSuccess }: { onSuccess: () => void }) {
|
|
||||||
const [key, setKey] = useState('')
|
|
||||||
const [err, setErr] = useState('')
|
|
||||||
const submit = async () => {
|
|
||||||
auth.setKey(key)
|
|
||||||
try {
|
|
||||||
const r = await auth.fetch('/api/health')
|
|
||||||
if (r.ok) { onSuccess(); return }
|
|
||||||
} catch { /* 网络错误按认证失败处理,提示重试 */ }
|
|
||||||
auth.clear()
|
|
||||||
setErr('密钥无效或已撤销,请重试')
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="login-gate">
|
|
||||||
<div className="login-card">
|
|
||||||
<h2>Agent-System</h2>
|
|
||||||
<p className="login-hint">本服务已启用 API key 认证。密钥由服务端创建:<code>agent-system apikey create --owner <name></code></p>
|
|
||||||
<input type="password" value={key} onChange={e => setKey(e.target.value)}
|
|
||||||
placeholder="ask_…" onKeyDown={e => e.key === 'Enter' && submit()} autoFocus />
|
|
||||||
<button onClick={submit}>进入</button>
|
|
||||||
{err && <div className="login-err">{err}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -48,18 +31,35 @@ export function App() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const ui = useStore(s => s.ui)
|
const ui = useStore(s => s.ui)
|
||||||
|
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
|
||||||
|
// 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。
|
||||||
|
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
|
||||||
|
const rightTab = useStore(s => s.ui.rightTab)
|
||||||
const toast = useStore(s => s.toast)
|
const toast = useStore(s => s.toast)
|
||||||
const trackerRunId = useStore(s => s.ui.trackerRunId)
|
const trackerRunId = useStore(s => s.ui.trackerRunId)
|
||||||
const viewingArtifact = useStore(s => s.ui.viewingArtifact)
|
// 文件入口 + 预览标签在场时,第三栏用「阅读档」宽度(420–1100),不受工作档上限约束。
|
||||||
|
const filesWide = rightTab === 'files' && previewOpen
|
||||||
|
const rightPanelWidth = filesWide ? ui.previewWidth : ui.rightWidth
|
||||||
|
const previewExpanded = filesWide && ui.previewExpanded
|
||||||
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
|
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
|
||||||
// authState:checking → ok(放行)| required(登录门)。
|
// authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。
|
||||||
const [authState, setAuthState] = useState<'checking' | 'required' | 'ok'>('checking')
|
const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking')
|
||||||
|
const [mustChangeOwner, setMustChangeOwner] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await auth.fetch('/api/health')
|
const r = await auth.fetch('/api/health')
|
||||||
setAuthState(r.status === 401 ? 'required' : 'ok')
|
if (r.status === 401) { setAuthState('login'); return }
|
||||||
|
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
|
||||||
|
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
|
||||||
|
// key 持有者清 key 回登录页,改走密码登录完成改密。
|
||||||
|
const me = await api.me().catch(() => null)
|
||||||
|
if (me?.mustChangePassword) {
|
||||||
|
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
|
||||||
|
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
|
||||||
|
}
|
||||||
|
setAuthState('ok')
|
||||||
} catch {
|
} catch {
|
||||||
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
|
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
|
||||||
}
|
}
|
||||||
@@ -68,28 +68,40 @@ export function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authState !== 'ok') return
|
if (authState !== 'ok') return
|
||||||
|
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
|
||||||
|
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
|
||||||
|
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
|
||||||
|
store.refreshMe()
|
||||||
store.refreshProjects()
|
store.refreshProjects()
|
||||||
store.refreshProfiles()
|
store.refreshProfiles()
|
||||||
store.refreshPlugins()
|
store.refreshPlugins()
|
||||||
}, [authState])
|
}, [authState])
|
||||||
|
|
||||||
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean } | null>(null)
|
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
|
||||||
|
|
||||||
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
|
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
drag.current = { side, startX: e.clientX, startW: side === 'left' ? ui.leftWidth : ui.rightWidth, overshoot: false }
|
// 右侧分隔条按当前入口选宽度档:文件+预览在场走阅读档,否则工作档。
|
||||||
|
const wide = side === 'right' && rightTab === 'files' && previewOpen
|
||||||
|
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
|
||||||
|
drag.current = {
|
||||||
|
side, startX: e.clientX, startW: ui[key], overshoot: false,
|
||||||
|
min: side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN,
|
||||||
|
max: side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX,
|
||||||
|
key,
|
||||||
|
}
|
||||||
const move = (ev: MouseEvent) => {
|
const move = (ev: MouseEvent) => {
|
||||||
const d = drag.current
|
const d = drag.current
|
||||||
if (!d) return
|
if (!d) return
|
||||||
if (d.side === 'left') {
|
if (d.side === 'left') {
|
||||||
const w = d.startW + (ev.clientX - d.startX)
|
const w = d.startW + (ev.clientX - d.startX)
|
||||||
if (w < LEFT_MIN - LEFT_OVERSHOOT) { d.overshoot = true; return }
|
if (w < d.min - LEFT_OVERSHOOT) { d.overshoot = true; return }
|
||||||
d.overshoot = false
|
d.overshoot = false
|
||||||
store.setUI({ leftCollapsed: false, leftWidth: Math.min(LEFT_MAX, Math.max(LEFT_MIN, w)) })
|
store.setUI({ leftCollapsed: false, leftWidth: Math.min(d.max, Math.max(d.min, w)) })
|
||||||
} else {
|
} else {
|
||||||
const w = d.startW - (ev.clientX - d.startX)
|
const w = d.startW - (ev.clientX - d.startX)
|
||||||
if (w < RIGHT_MIN) return
|
if (w < d.min) return
|
||||||
store.setUI({ rightCollapsed: false, rightWidth: Math.min(RIGHT_MAX, w) })
|
store.setUI({ rightCollapsed: false, [d.key]: Math.min(d.max, w) } as Partial<typeof ui>)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const up = () => {
|
const up = () => {
|
||||||
@@ -103,55 +115,79 @@ export function App() {
|
|||||||
window.addEventListener('mouseup', up)
|
window.addEventListener('mouseup', up)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authState === 'login') {
|
||||||
|
return <LoginPage
|
||||||
|
onAuthed={() => setAuthState('ok')}
|
||||||
|
onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} />
|
||||||
|
}
|
||||||
|
if (authState === 'mustchange') {
|
||||||
|
return <ChangePasswordGate
|
||||||
|
owner={mustChangeOwner}
|
||||||
|
onDone={async () => { await store.refreshMe().catch(() => undefined); setAuthState('ok') }}
|
||||||
|
onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} />
|
||||||
|
}
|
||||||
if (authState !== 'ok') {
|
if (authState !== 'ok') {
|
||||||
return authState === 'required' ? <LoginGate onSuccess={() => setAuthState('ok')} />
|
return <div className="login-gate"><div className="login-card"><p>连接服务中…</p></div></div>
|
||||||
: <div className="login-gate"><div className="login-card"><p>连接服务中…</p></div></div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tools = workspaceEntries()
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
|
<TopBar />
|
||||||
|
<div className="app-body">
|
||||||
{!ui.leftCollapsed ? (
|
{!ui.leftCollapsed ? (
|
||||||
<>
|
<>
|
||||||
<div className="left-nav" style={{ width: ui.leftWidth }}>
|
<div className="left-nav" style={{ width: ui.leftWidth }}>
|
||||||
<ProjectNav />
|
<TaskSidebar />
|
||||||
</div>
|
</div>
|
||||||
<div className="divider" onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft} title="拖动调整宽度;继续向左超过最小宽度后松开可收起;双击切换" />
|
<div className="divider" onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft} title="拖动调整宽度;继续向左超过最小宽度后松开可收起;双击切换" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="left-nav collapsed" style={{ width: 52 }}>
|
<div className="left-nav collapsed" style={{ width: 52 }}>
|
||||||
<div className="icon-only">
|
<div className="icon-only">
|
||||||
<button title="展开侧栏" onClick={store.toggleLeft}>☰</button>
|
<button title="展开侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
||||||
<button title="新会话" onClick={() => store.createConversation()}>✚</button>
|
<button title="新建任务(⌘N)" onClick={() => store.createTask()}><Icon name="plus" /></button>
|
||||||
<button title="设置" onClick={() => store.openSettings(true)}>⚙</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="main">
|
<div className="main" hidden={previewExpanded}>
|
||||||
<Conversation />
|
<Conversation />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!ui.rightCollapsed ? (
|
{!ui.rightCollapsed ? (
|
||||||
<>
|
<>
|
||||||
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
|
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
|
||||||
<div className="right-panel" style={{ width: ui.rightWidth }}>
|
<div className={`right-panel mk-panel-grow${previewExpanded ? ' expanded' : ''}`}
|
||||||
|
style={previewExpanded ? undefined : { width: rightPanelWidth }}>
|
||||||
<Workbench />
|
<Workbench />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="right-panel collapsed" style={{ width: 44 }}>
|
<div className="right-panel collapsed" style={{ width: 44 }}>
|
||||||
<div className="icon-only">
|
<div className="icon-only">
|
||||||
<button title="展开收件箱" onClick={store.toggleRight}>◧
|
<button title="展开右栏" onClick={store.toggleRight}>◧
|
||||||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
||||||
</button>
|
</button>
|
||||||
|
<button title="文件(搜索、预览、编辑、下载)" onClick={() => store.openFiles()}><Icon name="folder" size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Workspaces />
|
<Workspaces />
|
||||||
{trackerRunId && <AgentTracker />}
|
{trackerRunId && <AgentTracker />}
|
||||||
{ui.settingsOpen && <SettingsModal />}
|
{ui.settingsOpen && <SettingsModal />}
|
||||||
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
|
{ui.projectSettingsId && <ProjectSettingsModal />}
|
||||||
|
{ui.adminZoneOpen && <AdminZone />}
|
||||||
|
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className="toast">{toast}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+111
-10
@@ -3,12 +3,17 @@
|
|||||||
import { auth } from './auth'
|
import { auth } from './auth'
|
||||||
import type {
|
import type {
|
||||||
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
|
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
|
||||||
AgentProfile, SettingDesc, StreamEvent, PlanSnapshot, TodoList, UploadInfo,
|
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
|
||||||
ActivityCard,
|
ActivityCard,
|
||||||
} from './contracts'
|
} from './contracts'
|
||||||
|
|
||||||
const BASE = ''
|
const BASE = ''
|
||||||
|
|
||||||
|
/** 管理区声明分区(WP3-I3/KTD4,backend 专属端点类型):编译插件经
|
||||||
|
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
|
||||||
|
* 自身命令面 fail-closed。 */
|
||||||
|
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||||||
|
|
||||||
async function json<T>(res: Response): Promise<T> {
|
async function json<T>(res: Response): Promise<T> {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
@@ -24,15 +29,40 @@ export const api = {
|
|||||||
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }),
|
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
|
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
|
||||||
|
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
|
||||||
|
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(r)),
|
||||||
|
setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||||||
|
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||||||
|
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
|
||||||
|
rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
|
||||||
|
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(r)),
|
||||||
|
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
|
||||||
|
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
|
||||||
|
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<any[]>(r)),
|
||||||
|
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(r)),
|
||||||
|
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
|
||||||
|
// ---- 认证与会话(管理员发放制,R2)----
|
||||||
|
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
|
||||||
|
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||||
|
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
|
||||||
|
}),
|
||||||
|
logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }),
|
||||||
|
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
|
||||||
|
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||||
|
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
|
||||||
|
}),
|
||||||
|
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)),
|
||||||
|
createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
|
||||||
|
resetAdminUserPassword: (owner: string, password: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
|
||||||
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
|
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
|
||||||
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
|
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
|
||||||
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
|
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 =>
|
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[]) =>
|
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 }) })
|
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId }) })
|
||||||
.then(async r => {
|
.then(async r => {
|
||||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
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 }>
|
return r.json() as Promise<{ message: Message; run?: Run; command?: string; reply?: string; conversation?: Conversation }>
|
||||||
@@ -53,6 +83,10 @@ export const api = {
|
|||||||
auth.fetch(`${BASE}/api/cards/${cardId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }).then(r => json<{ card: ActivityCard }>(r)),
|
auth.fetch(`${BASE}/api/cards/${cardId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }).then(r => json<{ card: ActivityCard }>(r)),
|
||||||
cardDismiss: (cardId: string) =>
|
cardDismiss: (cardId: string) =>
|
||||||
auth.fetch(`${BASE}/api/cards/${cardId}/dismiss`, { method: 'POST' }).then(r => json<{ card: ActivityCard }>(r)),
|
auth.fetch(`${BASE}/api/cards/${cardId}/dismiss`, { method: 'POST' }).then(r => json<{ card: ActivityCard }>(r)),
|
||||||
|
listProjectCards: (projectId: string) =>
|
||||||
|
auth.fetch(`${BASE}/api/projects/${projectId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
|
||||||
|
cardToProject: (cardId: string) =>
|
||||||
|
auth.fetch(`${BASE}/api/cards/${cardId}/to_project`, { method: 'POST' }).then(r => json<{ card: ActivityCard; origin: ActivityCard }>(r)),
|
||||||
|
|
||||||
upload: (projectId: string, file: File) => {
|
upload: (projectId: string, file: File) => {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
@@ -61,12 +95,19 @@ export const api = {
|
|||||||
},
|
},
|
||||||
listArtifacts: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/artifacts`).then(r => json<Artifact[]>(r)),
|
listArtifacts: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/artifacts`).then(r => json<Artifact[]>(r)),
|
||||||
artifactContentUrl: (id: string) => auth.withKey(`${BASE}/api/artifacts/${id}/content`),
|
artifactContentUrl: (id: string) => auth.withKey(`${BASE}/api/artifacts/${id}/content`),
|
||||||
|
// 下载直链:后端按 ?download=1 回 Content-Disposition: attachment(RFC 5987 文件名)。
|
||||||
|
artifactDownloadUrl: (id: string) => {
|
||||||
|
const url = auth.withKey(`${BASE}/api/artifacts/${id}/content`)
|
||||||
|
return url.includes('?') ? `${url}&download=1` : `${url}?download=1`
|
||||||
|
},
|
||||||
saveArtifactContent: (id: string, content: string) => auth.fetch(`${BASE}/api/artifacts/${id}/content`, { method: 'PUT', body: content }),
|
saveArtifactContent: (id: string, content: string) => auth.fetch(`${BASE}/api/artifacts/${id}/content`, { method: 'PUT', body: content }),
|
||||||
|
deleteArtifact: (id: string) => auth.fetch(`${BASE}/api/artifacts/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
getPlan: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/plan`).then(r => r.ok ? r.json() : null) as Promise<PlanSnapshot | null>,
|
getPlan: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/plan`).then(r => r.ok ? r.json() : null) as Promise<PlanSnapshot | null>,
|
||||||
getTodos: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/todos`).then(r => r.ok ? r.json() : null) as Promise<TodoList | null>,
|
getTodos: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/todos`).then(r => r.ok ? r.json() : null) as Promise<TodoList | null>,
|
||||||
|
|
||||||
listProfiles: () => auth.fetch(`${BASE}/api/profiles`).then(r => json<AgentProfile[]>(r)),
|
listProfiles: () => auth.fetch(`${BASE}/api/profiles`).then(r => json<AgentProfile[]>(r)),
|
||||||
|
listPromptModules: () => auth.fetch(`${BASE}/api/prompts`).then(r => json<PromptModule[]>(r)),
|
||||||
saveProfile: (p: AgentProfile) => auth.fetch(`${BASE}/api/profiles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() }),
|
saveProfile: (p: AgentProfile) => auth.fetch(`${BASE}/api/profiles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() }),
|
||||||
deleteProfile: (id: string) => auth.fetch(`${BASE}/api/profiles/${id}`, { method: 'DELETE' }),
|
deleteProfile: (id: string) => auth.fetch(`${BASE}/api/profiles/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
@@ -77,9 +118,39 @@ export const api = {
|
|||||||
|
|
||||||
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
|
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
|
||||||
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
||||||
|
adminZoneSections: () => auth.fetch(`${BASE}/api/admin-zone/sections`).then(r => r.json()) as Promise<AdminZoneSection[]>,
|
||||||
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
|
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
|
||||||
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
|
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string; enabled?: boolean }[]>,
|
||||||
|
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||||
|
excludeMessage: (id: string, excluded: boolean) => auth.fetch(`${BASE}/api/messages/${id}/exclude`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ excluded }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||||
|
selectVariant: (id: string) => auth.fetch(`${BASE}/api/messages/${id}/variant`, { method: 'POST' }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||||
|
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 }[]>,
|
listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>,
|
||||||
|
|
||||||
|
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
|
||||||
|
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
|
||||||
|
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
|
||||||
|
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ params }),
|
||||||
|
}).then(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r))
|
||||||
|
.then(out => {
|
||||||
|
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
|
||||||
|
if (envelope?.receipt?.state === 'rejected') throw new Error(envelope.receipt.error || envelope.receipt.message || '命令被拒绝')
|
||||||
|
if (envelope?.card) throw new Error('高危命令已生成确认卡——请在对话中确认执行')
|
||||||
|
if (envelope && envelope.payload !== undefined) return envelope.payload
|
||||||
|
return out as T
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
|
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
|
||||||
@@ -90,13 +161,45 @@ export interface SSEHandlers {
|
|||||||
onError?: (err: Error) => void
|
onError?: (err: Error) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers): { close: () => 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, 'surface.updated': true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers, projectId?: string): { close: () => void } {
|
||||||
let closed = false
|
let closed = false
|
||||||
let es: EventSource | null = null
|
let es: EventSource | null = null
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
es = new EventSource(auth.withKey(`${BASE}/api/events?conversationId=${encodeURIComponent(conversationId)}&since=${since}`))
|
// 项目流(CF3/D50):scope=project 订阅项目频道(伪会话键,信封带 ProjectID)。
|
||||||
|
const url = projectId
|
||||||
|
? `${BASE}/api/events?scope=project&projectId=${encodeURIComponent(projectId)}&since=${since}`
|
||||||
|
: `${BASE}/api/events?conversationId=${encodeURIComponent(conversationId)}&since=${since}`
|
||||||
|
es = new EventSource(auth.withKey(url))
|
||||||
es.onopen = () => handlers.onOpen?.()
|
es.onopen = () => handlers.onOpen?.()
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
@@ -105,9 +208,7 @@ export function connectEvents(conversationId: string, since: number, handlers: S
|
|||||||
}
|
}
|
||||||
es.onmessage = ev => handle('message', ev.data)
|
es.onmessage = ev => handle('message', ev.data)
|
||||||
// 同时监听具名事件与默认(服务端 event: 字段)。
|
// 同时监听具名事件与默认(服务端 event: 字段)。
|
||||||
const types = ['message.delta', 'thinking.delta', 'tool.call', 'tool.result', 'run.phase',
|
const types = Object.keys(streamEventListeners) as StreamEventType[]
|
||||||
'hitl.request', 'hitl.resolved', 'plan.update', 'todo.update', 'agent.spawn', 'agent.progress',
|
|
||||||
'agent.result', 'artifact.created', 'artifact.updated', 'run.completed', 'run.failed', 'error']
|
|
||||||
for (const t of types) es.addEventListener(t, ev => handle(t, (ev as MessageEvent).data))
|
for (const t of types) es.addEventListener(t, ev => handle(t, (ev as MessageEvent).data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { groupArtifactsByName } from './artifactGrouping'
|
||||||
|
import type { Artifact } from './contracts'
|
||||||
|
|
||||||
|
// 2026-10-11 实测事故:一次 run 内 register_artifact 三次 → 文件面板三份同名
|
||||||
|
// 转写报告并列。修复语义(用户裁定):存储保留版本、界面按名称分组去重。
|
||||||
|
|
||||||
|
function art(id: string, name: string, updatedAt: string, size = 40000): Artifact {
|
||||||
|
return { id, projectId: 'p1', name, kind: 'document', size, createdAt: updatedAt, updatedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('groupArtifactsByName', () => {
|
||||||
|
it('同名 3 行归一组:latest 为最新,versions 新→旧', () => {
|
||||||
|
const g = groupArtifactsByName([
|
||||||
|
art('a1', '报告.docx', '2026-10-11T15:22:20Z'),
|
||||||
|
art('a2', '报告.docx', '2026-10-11T15:24:06Z'),
|
||||||
|
art('a3', '报告.docx', '2026-10-11T15:23:38Z'),
|
||||||
|
art('b1', '其他.md', '2026-10-11T15:20:00Z', 100),
|
||||||
|
])
|
||||||
|
expect(g).toHaveLength(2)
|
||||||
|
const grp = g.find(x => x.latest.name === '报告.docx')!
|
||||||
|
expect(grp.latest.id).toBe('a2') // 最新 15:24:06
|
||||||
|
expect(grp.versions).toHaveLength(3)
|
||||||
|
expect(grp.versions.map(v => v.id)).toEqual(['a2', 'a3', 'a1'])
|
||||||
|
expect(g.find(x => x.latest.name === '其他.md')!.versions).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不同名互不影响;空列表返回空', () => {
|
||||||
|
const g = groupArtifactsByName([art('x', 'a.md', 'T1'), art('y', 'b.md', 'T2')])
|
||||||
|
expect(g).toHaveLength(2)
|
||||||
|
expect(groupArtifactsByName([])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// 同名工件版本分组(2026-10-11 实测事故的修复语义,用户裁定:存储保留版本、
|
||||||
|
// 界面去重):组内按 updated_at 新→旧,latest 为面板显示行,其余为历史版本。
|
||||||
|
import type { Artifact } from './contracts'
|
||||||
|
|
||||||
|
export interface ArtifactGroup {
|
||||||
|
latest: Artifact
|
||||||
|
versions: Artifact[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupArtifactsByName(list: Artifact[]): ArtifactGroup[] {
|
||||||
|
const byName = new Map<string, Artifact[]>()
|
||||||
|
for (const a of list) {
|
||||||
|
const arr = byName.get(a.name) ?? []
|
||||||
|
arr.push(a)
|
||||||
|
byName.set(a.name, arr)
|
||||||
|
}
|
||||||
|
const out: ArtifactGroup[] = []
|
||||||
|
for (const versions of byName.values()) {
|
||||||
|
versions.sort((x, y) => (x.updatedAt < y.updatedAt ? 1 : x.updatedAt > y.updatedAt ? -1 : 0))
|
||||||
|
out.push({ latest: versions[0], versions })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ export const auth = {
|
|||||||
apiKey(): string { return storage.getItem(KEY) ?? '' },
|
apiKey(): string { return storage.getItem(KEY) ?? '' },
|
||||||
setKey(key: string) { storage.setItem(KEY, key.trim()) },
|
setKey(key: string) { storage.setItem(KEY, key.trim()) },
|
||||||
clear() { storage.removeItem(KEY) },
|
clear() { storage.removeItem(KEY) },
|
||||||
|
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
|
||||||
|
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
|
||||||
|
beginSession() { this.clear() },
|
||||||
// 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。
|
// 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。
|
||||||
fetch(input: string, init?: RequestInit): Promise<Response> {
|
fetch(input: string, init?: RequestInit): Promise<Response> {
|
||||||
const key = this.apiKey()
|
const key = this.apiKey()
|
||||||
@@ -30,6 +33,11 @@ export const auth = {
|
|||||||
headers.set('Authorization', `Bearer ${key}`)
|
headers.set('Authorization', `Bearer ${key}`)
|
||||||
return fetch(input, { ...init, headers })
|
return fetch(input, { ...init, headers })
|
||||||
},
|
},
|
||||||
|
// 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。
|
||||||
|
logout() {
|
||||||
|
this.clear()
|
||||||
|
try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ }
|
||||||
|
},
|
||||||
// 无法带头的资源 URL(EventSource / <img> / 下载):追加 ?key=。
|
// 无法带头的资源 URL(EventSource / <img> / 下载):追加 ?key=。
|
||||||
withKey(url: string): string {
|
withKey(url: string): string {
|
||||||
const key = this.apiKey()
|
const key = this.apiKey()
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// 管理员用户管理已迁「系统管理」分区(components/adminSections.tsx UsersPanel,
|
||||||
|
// 重设计方案 阶段②)。本文件只保留登录者主动改密弹窗(与强制改密共用 API)。
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { store, useStore } from '../store'
|
||||||
|
import { Icon } from '@ui'
|
||||||
|
|
||||||
|
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||||
|
<div className="modal ad-card" role="dialog" aria-label={title} style={{ maxWidth: 640 }}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<b>{title}</b>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="ad-mini" onClick={onClose} aria-label="关闭"><Icon name="x" size={13} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="ad-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主动改密(账户菜单入口):当前密码 + 新密码 + 确认。 */
|
||||||
|
export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const me = useStore(s => s.me)
|
||||||
|
const [current, setCurrent] = useState('')
|
||||||
|
const [next, setNext] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [ok, setOk] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setErr('')
|
||||||
|
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
|
||||||
|
if (next.length < 8) { setErr('新密码至少 8 位'); return }
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.changePassword(current, next)
|
||||||
|
setOk(true)
|
||||||
|
store.toast('密码已更新')
|
||||||
|
setTimeout(onClose, 900)
|
||||||
|
} catch (e) {
|
||||||
|
setErr(String(e).includes('401') ? '当前密码不正确' : String(e))
|
||||||
|
} finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell title="修改密码" onClose={onClose}>
|
||||||
|
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
|
||||||
|
<div className="ad-changepw">
|
||||||
|
<label className="lg-label">当前密码({me?.owner ?? ''})</label>
|
||||||
|
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
|
||||||
|
<label className="lg-label">新密码(≥8 位)</label>
|
||||||
|
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} />
|
||||||
|
<label className="lg-label">确认新密码</label>
|
||||||
|
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} />
|
||||||
|
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||||
|
<button className="lg-submit" disabled={busy || !current || !next || !confirm} onClick={submit}>{busy ? '提交中…' : '更新密码'}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Shell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// 系统管理(重设计方案 阶段②):全屏独立视图(带返回,非弹窗)。
|
||||||
|
// 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。
|
||||||
|
// 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。
|
||||||
|
// WP3-I3/KTD4:编译插件可经 Registry.AddAdminZone 声明展示分区(服务端
|
||||||
|
// /api/admin-zone/sections 下发);声明分区对全部认证主体开放,区内数据
|
||||||
|
// 能力由分区组件自身的命令面 fail-closed——前端只消费、不合并角色。
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { store, useStore } from '../store'
|
||||||
|
import { EmptyState, Icon, type IconName } from '@ui'
|
||||||
|
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
|
||||||
|
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||||
|
import type { AdminZoneSection } from '../api'
|
||||||
|
|
||||||
|
interface AdminSectionDef {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
icon: IconName
|
||||||
|
desc: string
|
||||||
|
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
|
||||||
|
render: () => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfilesPanelWrapper() {
|
||||||
|
const profiles = useStore(s => s.profiles)
|
||||||
|
return <ProfilesSection profiles={profiles} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTIONS: AdminSectionDef[] = [
|
||||||
|
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
|
||||||
|
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
|
||||||
|
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
|
||||||
|
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
|
||||||
|
const DECLARED_COMPONENTS: Record<string, () => React.ReactNode> = {
|
||||||
|
'rdm.audit': () => <RdmAuditWorkspace />,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** icon 走基座 IconName 词表;声明携带未知图标时回退通用图标(不渲染空白)。 */
|
||||||
|
const ICONS: IconName[] = ['plus', 'search', 'panel-left', 'inbox', 'settings', 'sun', 'moon', 'pin', 'archive', 'undo',
|
||||||
|
'chevron-right', 'chevron-down', 'folder', 'message', 'package', 'bot', 'puzzle', 'grid', 'arrow-up', 'paperclip',
|
||||||
|
'more', 'logout', 'key', 'user', 'users', 'shield', 'x', 'check', 'alert', 'send', 'sparkles', 'clock', 'list',
|
||||||
|
'file', 'image', 'download', 'trash', 'eye', 'maximize', 'minimize']
|
||||||
|
function declaredIcon(name?: string): IconName {
|
||||||
|
return (ICONS as string[]).includes(name ?? '') ? (name as IconName) : 'grid'
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeclaredSection({ section }: { section: AdminZoneSection }) {
|
||||||
|
const render = DECLARED_COMPONENTS[section.id]
|
||||||
|
if (!render) {
|
||||||
|
return <EmptyState icon="puzzle">分区「{section.title}」由内置插件声明,但当前前端版本未携带其组件<br />(部署前端与二进制版本不匹配时会出现;升级前端即可)</EmptyState>
|
||||||
|
}
|
||||||
|
return <>{render()}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminZone() {
|
||||||
|
const me = useStore(s => s.me)
|
||||||
|
const declared = useStore(s => s.adminSections)
|
||||||
|
const [sectionKey, setSectionKey] = useState('users')
|
||||||
|
const isAdmin = !!me?.isAdmin
|
||||||
|
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
|
||||||
|
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
|
||||||
|
if (!isAdmin && declared.length === 0) return null
|
||||||
|
const sections: AdminSectionDef[] = [
|
||||||
|
...(isAdmin ? SECTIONS : []),
|
||||||
|
...declared.map(s => ({
|
||||||
|
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
|
||||||
|
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
|
||||||
|
render: () => <DeclaredSection section={s} />,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
const fallback = sections[0]?.key ?? ''
|
||||||
|
const section = sections.find(s => s.key === sectionKey) ?? sections[0]
|
||||||
|
if (!section) return null
|
||||||
|
return (
|
||||||
|
<div className="az" role="dialog" aria-label="系统管理">
|
||||||
|
<div className="az-head">
|
||||||
|
<button className="btn ghost" onClick={() => store.setUI({ adminZoneOpen: false })}>
|
||||||
|
<Icon name="chevron-down" size={14} /> 返回工作台
|
||||||
|
</button>
|
||||||
|
<span className="az-title">系统管理</span>
|
||||||
|
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<span className="az-tenant">{me?.owner ?? ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="az-body">
|
||||||
|
<nav className="az-side">
|
||||||
|
{sections.map(s => (
|
||||||
|
<button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
|
||||||
|
<Icon name={s.icon} size={15} />
|
||||||
|
<span>{s.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<main className="az-main">
|
||||||
|
<div className="az-section-head">
|
||||||
|
<h3>{section.label}</h3>
|
||||||
|
<p>{section.desc}</p>
|
||||||
|
</div>
|
||||||
|
{section.render()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,62 +19,58 @@ export function AgentTracker() {
|
|||||||
// 中间过程:该会话消息树中归属子代理 run 的消息(run_id 过滤)。
|
// 中间过程:该会话消息树中归属子代理 run 的消息(run_id 过滤)。
|
||||||
const subMessages: Record<string, Message[]> = {}
|
const subMessages: Record<string, Message[]> = {}
|
||||||
for (const m of messagesAll) {
|
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 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 (
|
return (
|
||||||
|
// 阶段③ 收敛:只保留 OverlayHost 一层壳(此前在其内又嵌 .modal 双层窗框)。
|
||||||
<OverlayHost title="代理追踪" icon="🤖" onClose={() => store.setUI({ trackerRunId: null })}>
|
<OverlayHost title="代理追踪" icon="🤖" onClose={() => store.setUI({ trackerRunId: null })}>
|
||||||
<div className="modal">
|
<div className="subagent-tree">
|
||||||
<div className="modal-header">
|
{!root && <div>加载中…</div>}
|
||||||
🤖 Sub-agent 追踪
|
{root && (
|
||||||
<span className="spacer" />
|
<>
|
||||||
<button className="btn ghost" onClick={() => store.setUI({ trackerRunId: null })}>关闭</button>
|
<div className="subagent-node">
|
||||||
</div>
|
<div className="head">
|
||||||
<div className="modal-body">
|
<span className={`status-dot status-${root.status}`} />
|
||||||
{!root && <div>加载中…</div>}
|
<strong>{root.kind === 'subagent' ? root.agentName || '子代理' : '主任务'}</strong>
|
||||||
{root && (
|
<span style={{ color: 'var(--text-faint)' }}>{root.status}{root.phase ? ` · ${root.phase}` : ''}</span>
|
||||||
<div className="subagent-tree">
|
<span className="spacer" style={{ flex: 1 }} />
|
||||||
<div className="subagent-node">
|
<UsageBadge usage={root.usage} />
|
||||||
<div className="head">
|
</div>
|
||||||
<span className={`status-dot status-${root.status}`} />
|
{children.length > 0 && (
|
||||||
<strong>{root.kind === 'subagent' ? root.agentName || '子代理' : '主任务'}</strong>
|
<div>
|
||||||
<span style={{ color: 'var(--text-faint)' }}>{root.status}{root.phase ? ` · ${root.phase}` : ''}</span>
|
{children.map(c => (
|
||||||
<span className="spacer" style={{ flex: 1 }} />
|
<div key={c.id} className="agent-row" style={{ background: focus === c.id ? 'var(--accent-soft)' : undefined, borderRadius: 6 }}
|
||||||
<UsageBadge usage={root.usage} />
|
onClick={() => setSelected(c.id)}>
|
||||||
|
<span className={`status-dot status-${c.status}`} />
|
||||||
|
<span style={{ flex: 1 }}>🤖 {c.agentName || c.id}</span>
|
||||||
|
<span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-xs)' }}>{c.status}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{children.length > 0 && (
|
)}
|
||||||
<div>
|
|
||||||
{children.map(c => (
|
|
||||||
<div key={c.id} className="agent-row" style={{ background: focus === c.id ? 'var(--accent-soft)' : undefined, borderRadius: 6 }}
|
|
||||||
onClick={() => setSelected(c.id)}>
|
|
||||||
<span className={`status-dot status-${c.status}`} />
|
|
||||||
<span style={{ flex: 1 }}>🤖 {c.agentName || c.id}</span>
|
|
||||||
<span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{c.status}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h5 style={{ margin: '12px 0 6px' }}>中间过程({focus === runId ? '主任务' : '子代理'})</h5>
|
|
||||||
<div style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, padding: 12, maxHeight: '40vh', overflowY: 'auto' }}>
|
|
||||||
{focusMsgs.length === 0 && <div style={{ color: 'var(--text-faint)', fontSize: 12.5 }}>(无中间过程消息)</div>}
|
|
||||||
{focusMsgs.map(m => (
|
|
||||||
<div key={m.id} className="part">
|
|
||||||
{m.parts.map((p, i) => (
|
|
||||||
<div key={i} style={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7 }}>
|
|
||||||
{p.type === 'text' && p.text}
|
|
||||||
{p.type === 'thinking' && <span style={{ color: 'var(--text-faint)' }}>{p.text}</span>}
|
|
||||||
{p.type === 'tool_call' && <span className="tool-chip">🛠 {p.toolCall?.name}</span>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<h5 style={{ margin: '12px 0 6px' }}>中间过程({focus === runId ? '主任务' : '子代理'})</h5>
|
||||||
</div>
|
<div style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, padding: 12, maxHeight: '40vh', overflowY: 'auto' }}>
|
||||||
|
{focusMsgs.length === 0 && <div style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-sm)' }}>(无中间过程消息)</div>}
|
||||||
|
{focusMsgs.map(m => (
|
||||||
|
<div key={m.id} className="part">
|
||||||
|
{m.parts.map((p, i) => (
|
||||||
|
<div key={i} style={{ whiteSpace: 'pre-wrap', fontSize: 'var(--fs-ui-caption)', lineHeight: 1.7 }}>
|
||||||
|
{p.type === 'text' && p.text}
|
||||||
|
{p.type === 'thinking' && <span style={{ color: 'var(--text-faint)' }}>{p.text}</span>}
|
||||||
|
{p.type === 'tool_call' && <span className="tool-chip">🛠 {p.toolCall?.name}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</OverlayHost>
|
</OverlayHost>
|
||||||
)
|
)
|
||||||
@@ -82,7 +78,10 @@ export function AgentTracker() {
|
|||||||
|
|
||||||
function UsageBadge({ usage }: { usage?: Usage | null }) {
|
function UsageBadge({ usage }: { usage?: Usage | null }) {
|
||||||
if (!usage) return null
|
if (!usage) return null
|
||||||
return <span className="effect-badge" title="token 用量与时长">
|
// N3 计费拆分:有上一请求数据时显示「+最近 · 累计(含缓存命中)」。
|
||||||
{usage.totalTokens > 0 ? `${usage.totalTokens} tok · ` : ''}{(usage.durationMs / 1000).toFixed(1)}s{usage.toolCalls > 0 ? ` · ${usage.toolCalls} 工具` : ''}
|
const last = (usage.lastPromptTokens ?? 0) + (usage.lastCompletionTokens ?? 0)
|
||||||
|
const cache = usage.cacheReadTokens ?? 0
|
||||||
|
return <span className="effect-badge" title={cache > 0 ? `token 用量与时长(缓存命中 ${cache})` : 'token 用量与时长'}>
|
||||||
|
{last > 0 ? `+${last} · ` : ''}{usage.totalTokens > 0 ? `累计 ${usage.totalTokens} tok · ` : ''}{(usage.durationMs / 1000).toFixed(1)}s{usage.toolCalls > 0 ? ` · ${usage.toolCalls} 工具` : ''}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// 文件列表(第三栏「文件」入口的主视图):搜索 + kind 过滤 + 上传;
|
||||||
|
// 行内打开预览(同入口切到预览子视图)或下载。删除仍是保留能力,系统默认不提供入口。
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { store, useStore } from '../store'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { Collapsible, Icon } from '@ui'
|
||||||
|
import { artifactIcon, humanSize } from '../messageparts'
|
||||||
|
import type { Artifact } from '../contracts'
|
||||||
|
import { groupArtifactsByName } from '../artifactGrouping'
|
||||||
|
|
||||||
|
export function ArtifactsPanel() {
|
||||||
|
const artifacts = useStore(s => s.artifacts)
|
||||||
|
const surfaces = useStore(s => s.surfaces)
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
|
||||||
|
|
||||||
|
const kinds = ['all', 'document', 'data', 'image'] as const
|
||||||
|
const visible = artifacts.filter(a =>
|
||||||
|
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
|
||||||
|
// 同名工件 = 同一交付物的版本序列(存储保留全量):面板按名称分组去重,
|
||||||
|
// 只显示最新版;组>1 时可展开看历史版本。2026-10-11 实测 3 份同名转写报告
|
||||||
|
// 并列的直接修复(存储与提示词语义「修订=新版本」保持不变)。
|
||||||
|
const groups = groupArtifactsByName(visible)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="artifacts-panel">
|
||||||
|
<div className="ap-toolbar">
|
||||||
|
<input className="search-input" placeholder="搜索文件名…" value={q} onChange={e => setQ(e.target.value)} />
|
||||||
|
{kinds.map(k => (
|
||||||
|
<button key={k} className={`kind-pill ${kind === k ? 'on' : ''}`} onClick={() => setKind(k)}>
|
||||||
|
{k === 'all' ? '全部' : k === 'document' ? '文档' : k === 'data' ? '数据' : '图片'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<label className="btn ghost ap-upload" title="上传文件到当前项目">
|
||||||
|
<Icon name="paperclip" size={14} /> 上传
|
||||||
|
<input type="file" multiple style={{ display: 'none' }} onChange={async e => {
|
||||||
|
for (const f of Array.from(e.target.files ?? [])) await store.uploadAttachment(f)
|
||||||
|
e.target.value = ''
|
||||||
|
}} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{surfaces.length > 0 && (
|
||||||
|
<div className="ap-surfaces" data-ap-surfaces>
|
||||||
|
<div className="ap-section-title">生成页面</div>
|
||||||
|
{surfaces.map(sv => (
|
||||||
|
<div key={String(sv.identity.surfaceId)} className="artifact-row" data-surface-row={String(sv.identity.surfaceId)}
|
||||||
|
onClick={() => store.openSurface(String(sv.identity.surfaceId))} title={`打开页面 ${sv.spec?.title ?? ''}`}>
|
||||||
|
<span className="ar-icon"><Icon name="grid" size={15} /></span>
|
||||||
|
<span className="ar-main">
|
||||||
|
<span className="name">{sv.spec?.title || '(未命名页面)'}</span>
|
||||||
|
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity.pluginId}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{artifacts.length === 0
|
||||||
|
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
|
||||||
|
: visible.length === 0
|
||||||
|
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
||||||
|
: groups.map(g => g.versions.length === 1
|
||||||
|
? <ArtifactRow key={g.latest.id} a={g.latest} />
|
||||||
|
: <Collapsible key={g.latest.id} id={'artv:' + g.latest.id}
|
||||||
|
trigger={
|
||||||
|
<div className="artifact-row artifact-group" data-artifact-group={g.latest.id}>
|
||||||
|
<ArtifactRowInner a={g.latest} badge={`${g.versions.length} 版`} />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{g.versions.slice(1).map(v => (
|
||||||
|
<div key={v.id} className="artifact-row artifact-version" data-artifact-version={v.id}
|
||||||
|
onClick={() => store.requestPreview(v)} title={`预览历史版本 ${v.name}`}>
|
||||||
|
<span className="ar-icon"><Icon name="clock" size={13} /></span>
|
||||||
|
<span className="ar-main">
|
||||||
|
<span className="meta">v{shortTime(v.updatedAt)} · {humanSize(v.size) || '—'}</span>
|
||||||
|
</span>
|
||||||
|
<span className="ar-acts">
|
||||||
|
<a className="icon-btn" title="下载此版本" href={api.artifactDownloadUrl(v.id)} download={v.name}
|
||||||
|
onClick={e => e.stopPropagation()}><Icon name="download" size={13} /></a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Collapsible>)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso)
|
||||||
|
const now = new Date()
|
||||||
|
return d.toDateString() === now.toDateString()
|
||||||
|
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtifactRow({ a }: { a: Artifact }) {
|
||||||
|
return (
|
||||||
|
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
|
||||||
|
<div className="artifact-row" data-artifact-row={a.id}
|
||||||
|
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
|
||||||
|
<ArtifactRowInner a={a} />
|
||||||
|
<span className="ar-acts">
|
||||||
|
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
|
||||||
|
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
|
||||||
|
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtifactRowInner({ a, badge }: { a: Artifact; badge?: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
|
||||||
|
<span className="ar-main">
|
||||||
|
<span className="name">{a.name}{badge ? <span className="ver-badge" data-ver-badge>{badge}</span> : null}</span>
|
||||||
|
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+508
-92
@@ -1,22 +1,36 @@
|
|||||||
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
|
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
|
||||||
|
// 重设计方案 阶段④:折叠区全部迁 @ui Collapsible(<details> 已禁);工具调用块走
|
||||||
|
// ToolBlock 契约(运行态扫光、完成边沿收起、展开态记忆);live 行扫光 + 已工作计时。
|
||||||
|
import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpen, SafeMarkdown } from '@ui'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { marked } from 'marked'
|
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
|
import { store, useStore, activeRun, isTerminal, type LiveState, type ContextObs } from '../store'
|
||||||
import type { Message, Part, HITLCard } from '../contracts'
|
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
|
||||||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
|
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 { docBridge } from './Workbench'
|
||||||
|
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
|
||||||
|
import { ProcessTimeline } from './ProcessTimeline'
|
||||||
|
|
||||||
marked.setOptions({ breaks: true })
|
function hasFiles(dt: DataTransfer | null): boolean {
|
||||||
|
return !!dt && Array.from(dt.types).includes('Files')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对话正文沿用 breaks:卡片渲染器曾经把全局 marked 设成 breaks,
|
||||||
|
// 去重后的同一份 marked 让对话也按行内换行显示。这里显式保持这个效果。
|
||||||
|
|
||||||
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
|
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
|
||||||
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
return <div className={live ? 'part-text cursor-blink' : 'part-text'}
|
return <SafeMarkdown className={live ? 'part-text cursor-blink' : 'part-text'} breaks
|
||||||
dangerouslySetInnerHTML={{ __html: marked.parse(part.text ?? '') }} />
|
markdown={markMissingArtifacts(part.text ?? '', id => !!findArtifact(id))} />
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
return <div className="part-thinking">{part.text}</div>
|
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
|
||||||
|
//(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。)
|
||||||
|
return <ThinkingBlock part={part} idKey={part.text?.slice(0, 24) ?? 't'} defaultOpen={!!live} />
|
||||||
case 'tool_call':
|
case 'tool_call':
|
||||||
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
|
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
|
||||||
case 'tool_result': {
|
case 'tool_result': {
|
||||||
@@ -26,17 +40,22 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
case 'artifact_ref':
|
case 'artifact_ref':
|
||||||
return <span className="artifact-chip" onClick={() => {
|
return part.artifact ? <DeliverableCard artifact={part.artifact} /> : null
|
||||||
const art = findArtifact(part.artifact!.artifactId)
|
|
||||||
if (art) store.viewArtifact(art)
|
|
||||||
}}>📄 {part.artifact?.name}</span>
|
|
||||||
case 'hitl':
|
case 'hitl':
|
||||||
return <div className="tool-chip">❓ {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
|
return <div className="tool-chip"><Icon name="message" size={12} /> {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
|
||||||
case 'plan':
|
case 'plan':
|
||||||
return null // 计划在右栏 Plan 标签呈现
|
return null // 计划在右栏 Plan 标签呈现
|
||||||
case 'card': {
|
case 'card': {
|
||||||
// cardlink 互定位(D32):点击 ↗ 定位收件箱对应卡(从 data 中解析 card:<id>)。
|
// 卡流锚点(D39):cardId 非空 = submit_card 引用部件——中栏自足(可展开全文)+ 跳收件箱。
|
||||||
const m = /card:([A-Za-z0-9_]+)/.exec(JSON.stringify(part.card?.data ?? ''))
|
if (part.card?.cardId) {
|
||||||
|
const ref = part.card.cardId
|
||||||
|
const card = findCard(ref)
|
||||||
|
const bodyChars = card?.body?.reduce((n, node) => n + (node.markdown?.length ?? 0), 0)
|
||||||
|
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={card}
|
||||||
|
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
|
||||||
|
}
|
||||||
|
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
|
||||||
|
const m = JSON.stringify(part.card?.data ?? '').match(/card:([A-Za-z0-9_]+)/)
|
||||||
return (
|
return (
|
||||||
<div className="ui-section card-link" title={m ? '点击在收件箱中定位' : undefined}
|
<div className="ui-section card-link" title={m ? '点击在收件箱中定位' : undefined}
|
||||||
onClick={() => m && store.openInbox(m[1])}>
|
onClick={() => m && store.openInbox(m[1])}>
|
||||||
@@ -44,8 +63,21 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case 'error':
|
case 'error': {
|
||||||
return <div className="part-error">{part.error?.code}: {part.error?.message}</div>
|
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:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -57,47 +89,219 @@ function findArtifact(id: string) {
|
|||||||
return artifactCache[id]
|
return artifactCache[id]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过程折叠:run 终态后把连续 process 部件折叠为摘要行(可展开回看)。
|
/** 交付物卡片的类型文案(与工件面板 kind 过滤同一词汇)。 */
|
||||||
function ProcessCollapse({ parts }: { parts: Part[] }) {
|
function kindLabelOf(kind: string | undefined): string {
|
||||||
const toolCount = parts.filter(p => p.type === 'tool_call').length
|
switch (kind) {
|
||||||
const hasThinking = parts.some(p => p.type === 'thinking' && p.text)
|
case 'document': return '文档'
|
||||||
|
case 'data': return '数据'
|
||||||
|
case 'image': return '图片'
|
||||||
|
default: return '文件'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收件箱卡片查询缓存(store.cards 的派生索引;卡流锚点取卡数据用)。
|
||||||
|
let cardCache: Record<string, ActivityCard> = {}
|
||||||
|
function findCard(id: string) {
|
||||||
|
return cardCache[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 思考块(Collapsible 契约):左线弱色,流式默认展开、终态默认收起;展开态记忆。
|
||||||
|
function ThinkingBlock({ part, idKey, defaultOpen }: { part: Part; idKey: string; defaultOpen?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<details className="process-collapse">
|
<div className="part-thinking">
|
||||||
<summary>
|
<Collapsible id={`think:${idKey}`} defaultOpen={defaultOpen} triggerClassName="part-thinking-summary"
|
||||||
<span>⚙ 处程输出</span>
|
trigger={<><span className="part-thinking-chev">▸</span>思考过程</>}>
|
||||||
<span>{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}(点击展开)</span>
|
<div className="part-thinking-body">{part.text}</div>
|
||||||
</summary>
|
</Collapsible>
|
||||||
<div className="body">
|
</div>
|
||||||
{parts.map((p, i) => <div className="part" key={i}><PartView part={p} /></div>)}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null }) {
|
// 交付物卡片:主区域和「预览」打开预览区;下载收进菜单,避免和预览抢同一个按钮。
|
||||||
|
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 || '文件'
|
||||||
|
const icon = artifactIcon(name, artifact.kind ?? art?.kind)
|
||||||
|
const size = humanSize(art?.size)
|
||||||
|
const open = () => {
|
||||||
|
if (art) { store.requestPreview(art); return }
|
||||||
|
// 工件已不在当前项目(被删/跨项目残留):给一句话反馈,不再无声无息。
|
||||||
|
store.toast(`「${name}」已不在当前项目中,无法预览`)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="deliverable-card mk-rise-in" data-artifact-chip={artifact.artifactId}>
|
||||||
|
<button type="button" className="dc-open" title={`预览 ${name}`} onClick={open}>
|
||||||
|
<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}` : ''}{updates > 0 ? ` · 已更新 ${updates} 次` : ''}{art ? '' : ' · 生成中'}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="dc-act" onClick={open} disabled={!art}>预览</button>
|
||||||
|
<button type="button" className="dc-more" aria-label={`更多 ${name}`} aria-expanded={menu}
|
||||||
|
onClick={() => setMenu(v => !v)}><Icon name="more" size={14} /></button>
|
||||||
|
{menu && (
|
||||||
|
<a className="dc-menu" href={api.artifactDownloadUrl(artifact.artifactId)} download={name}
|
||||||
|
onClick={() => setMenu(false)}>下载</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function citedArtifactCards(parts: Part[]): Artifact[] {
|
||||||
|
const seen = new Set(parts.filter(p => p.type === 'artifact_ref').map(p => p.artifact?.artifactId).filter((id): id is string => !!id))
|
||||||
|
const out: Artifact[] = []
|
||||||
|
for (const p of parts) {
|
||||||
|
if (p.type !== 'text' || !p.text) continue
|
||||||
|
for (const id of artifactIdsIn(p.text)) {
|
||||||
|
if (seen.has(id)) continue
|
||||||
|
const art = findArtifact(id)
|
||||||
|
if (!art) continue
|
||||||
|
seen.add(id)
|
||||||
|
out.push(art)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已工作计时(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(() => {
|
||||||
|
const t = window.setInterval(() => tick(n => n + 1), 1000)
|
||||||
|
return () => window.clearInterval(t)
|
||||||
|
}, [])
|
||||||
|
const s = Math.max(0, Math.floor((Date.now() - startedAt) / 1000))
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
return m > 0 ? `${m} 分 ${s % 60} 秒` : `${s} 秒`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流式状态行(独立组件:计时 hook 只在 live 行挂载期间运转)。
|
||||||
|
function LiveBar({ live }: { live: LiveState }) {
|
||||||
|
const elapsed = useElapsed(live.startedAt)
|
||||||
|
return (
|
||||||
|
<span className="live-bar">
|
||||||
|
<span className="live-dot" />
|
||||||
|
<span className="mk-sweep">{livePhaseLabel(live)}</span>
|
||||||
|
<span className="live-elapsed">已工作 {elapsed}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户在运行中手动折过的 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 autoCollapse = useStore(s => s.ui.autoCollapseProcess)
|
||||||
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
|
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 parts = msg.parts ?? []
|
||||||
const split = useMemo(() => {
|
useLiveClock(!!live)
|
||||||
const out: { process: Part[]; content: Part[] } = { process: [], content: [] }
|
const finished = !live && !!run && run.status !== 'running' && run.status !== 'pending' && run.status !== 'waiting_hitl'
|
||||||
for (const p of parts) (p.process ? out.process : out.content).push(p)
|
const split = useMemo(() => splitTimeline(parts, finished), [parts, finished])
|
||||||
return out
|
const showProcess = showProcessInline(live ?? null, autoCollapse)
|
||||||
}, [parts])
|
const memKey = `process:${msg.runId || msg.id}`
|
||||||
|
const touched = !!(msg.runId && touchedProcess.has(msg.runId))
|
||||||
// 流式中:过程部件实时展开;run 终态后:按偏好折叠为摘要行(可回看)。
|
const [open, setOpen] = useState(() => {
|
||||||
const showProcessInline = live || !autoCollapse
|
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 (
|
return (
|
||||||
<div className="msg assistant">
|
<div className={`msg assistant${msg.excluded ? ' is-excluded' : ''}`} data-msg-id={msg.id}>
|
||||||
<div className="msg-role">
|
<div className="msg-role">
|
||||||
|
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
|
||||||
<span>助手</span>
|
<span>助手</span>
|
||||||
{live && <span className="live-bar"><span className="live-dot" />{live.phase}</span>}
|
{live && <LiveBar live={live} />}
|
||||||
</div>
|
</div>
|
||||||
{split.process.length > 0 && (showProcessInline
|
{split.timeline.length > 0 && (
|
||||||
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} /></div>)
|
<div className="process-collapse">
|
||||||
: <ProcessCollapse parts={split.process} />)}
|
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
|
||||||
{split.content.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!(live && i === split.content.length - 1 && p.type === 'text')} /></div>)}
|
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 && <LiveAgentNotes live={live} />}
|
||||||
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
|
{!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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -105,22 +309,31 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
|||||||
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
|
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
|
||||||
function ContextObsView({ obs }: { obs: ContextObs }) {
|
function ContextObsView({ obs }: { obs: ContextObs }) {
|
||||||
return (
|
return (
|
||||||
<details className="process-collapse">
|
<div className="process-collapse">
|
||||||
<summary>
|
<Collapsible id={`ctxobs:${obs.estChars}`} triggerClassName="process-collapse-summary" unmountDelay={300}
|
||||||
<span>🧩 上下文构成</span>
|
trigger={<>
|
||||||
<span>{obs.items.join(' · ')}(约 {obs.estChars} 字符)</span>
|
<span className="process-collapse-chev">▸</span>
|
||||||
</summary>
|
<span>🧩 上下文构成</span>
|
||||||
<div className="body">
|
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} token)</span>
|
||||||
<ul className="ui-list">
|
</>}>
|
||||||
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
|
<div className="body">
|
||||||
</ul>
|
<ul className="ui-list">
|
||||||
{obs.truncations && obs.truncations.length > 0 && (
|
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
|
||||||
<div style={{ color: 'var(--warn)', fontSize: 12.5, marginTop: 6 }}>
|
</ul>
|
||||||
{obs.truncations.map((t, i) => <div key={i}>⚠ {t}</div>)}
|
{obs.sections && obs.sections.length > 0 && (
|
||||||
</div>
|
<div className="process-collapse-meta">
|
||||||
)}
|
{obs.sections.map(s => `${s.name} ${s.estTokens}`).join(' · ')}
|
||||||
</div>
|
{obs.prefixHash ? ` · 前缀 ${obs.prefixHash}` : ''}
|
||||||
</details>
|
</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>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,8 +356,9 @@ function HITLCardView({ card }: { card: HITLCard }) {
|
|||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="hitl-card">
|
<div className={`hitl-card${card.kind === 'confirm' ? ' wait' : ''}${card.kind === 'choice' || card.kind === 'input' ? ' ask' : ''}`}>
|
||||||
<div className="hitl-q">❓ {card.question}</div>
|
<div className="hitl-q"><Icon name="check" size={15} />{card.question}</div>
|
||||||
|
{card.detail && <pre className="hitl-detail">{card.detail}</pre>}
|
||||||
{card.choices && (
|
{card.choices && (
|
||||||
<div className="hitl-choices">
|
<div className="hitl-choices">
|
||||||
{card.choices.map(c => (
|
{card.choices.map(c => (
|
||||||
@@ -188,37 +402,107 @@ function CommandMenu({ text, onPick }: { text: string; onPick: (cmd: string) =>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OutboxPanel() {
|
||||||
|
const outbox = useStore(s => s.outbox)
|
||||||
|
const [editing, setEditing] = useState<string | null>(null)
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
if (outbox.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Collapsible id="composer-outbox" defaultOpen
|
||||||
|
trigger={<span>排队 {outbox.length}</span>}
|
||||||
|
triggerClassName="outbox-trigger">
|
||||||
|
<ul className="outbox-list">
|
||||||
|
{outbox.map(item => (
|
||||||
|
<li key={item.id} className="outbox-item">
|
||||||
|
{editing === item.id ? (
|
||||||
|
<input className="search-input outbox-edit" value={draft} onChange={e => setDraft(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') { store.updateOutbox(item.id, draft); setEditing(null) }
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
|
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}</span>
|
||||||
|
)}
|
||||||
|
{editing === item.id ? (
|
||||||
|
<button className="btn ghost" onClick={() => { store.updateOutbox(item.id, draft); setEditing(null) }}>保存</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn ghost" onClick={() => { setEditing(item.id); setDraft(item.text) }}>修改</button>
|
||||||
|
)}
|
||||||
|
<button className="btn ghost" onClick={() => store.sendOutboxNow(item.id)}>发送</button>
|
||||||
|
<button className="icon-btn" title="移出队列" onClick={() => store.removeOutbox(item.id)}><Icon name="x" size={12} /></button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
export function Conversation() {
|
||||||
const currentConvId = useStore(s => s.currentConvId)
|
const currentConvId = useStore(s => s.currentConvId)
|
||||||
const conversation = useStore(s => s.conversations.find(c => c.id === s.currentConvId))
|
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
|
||||||
const messages = useStore(s => s.messages)
|
const messages = useStore(s => s.messages)
|
||||||
|
const myName = useStore(s => s.me?.name || s.me?.owner || '你')
|
||||||
const activeHitl = useStore(s => s.activeHitl)
|
const activeHitl = useStore(s => s.activeHitl)
|
||||||
const live = useStore(s => s.live)
|
const live = useStore(s => s.live)
|
||||||
const runs = useStore(s => s.runs)
|
const runs = useStore(s => s.runs)
|
||||||
|
const posting = useStore(s => s.posting)
|
||||||
const connected = useStore(s => s.connected)
|
const connected = useStore(s => s.connected)
|
||||||
const artifacts = useStore(s => s.artifacts)
|
const artifacts = useStore(s => s.artifacts)
|
||||||
const profiles = useStore(s => s.profiles)
|
const profiles = useStore(s => s.profiles)
|
||||||
const cards = useStore(s => s.cards)
|
const cards = useStore(s => s.cards)
|
||||||
const projectName = useStore(s => s.projects.find(p => p.id === s.currentProjectId)?.name ?? '项目')
|
const projectName = useStore(s => s.projects.find(p => p.id === s.currentProjectId)?.name ?? '项目')
|
||||||
|
|
||||||
|
const runtimeMock = useStore(s => s.runtimeMock)
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
|
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
|
||||||
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
|
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
|
||||||
|
const [dragDepth, setDragDepth] = useState(0)
|
||||||
const bodyRef = useRef<HTMLDivElement>(null)
|
const bodyRef = useRef<HTMLDivElement>(null)
|
||||||
|
const stickRef = useRef(true)
|
||||||
|
const [showJump, setShowJump] = useState(false)
|
||||||
|
|
||||||
artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a]))
|
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'
|
||||||
|
// 慢通道在跑的后台子代理:前台回合结束后仍在执行,完成后经通知回到对话汇报。
|
||||||
|
const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!stickRef.current) return
|
||||||
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
|
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
|
||||||
}, [messages.length, live?.text, live?.thinking, activeHitl.length])
|
}, [messages.length, live?.items, live?.phase, activeHitl.length])
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
if (!text.trim() && attachmentIds.length === 0) return
|
if (!text.trim() && attachmentIds.length === 0) return
|
||||||
const t = text
|
const t = text
|
||||||
|
const ids = attachmentIds
|
||||||
|
const names = attachmentNames
|
||||||
setText(''); setAttachmentIds([]); setAttachmentNames([])
|
setText(''); setAttachmentIds([]); setAttachmentNames([])
|
||||||
await store.sendMessage(t, attachmentIds)
|
if (busy) {
|
||||||
|
store.enqueueOutbox(t, ids, names)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await store.sendMessage(t, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentConvId) {
|
if (!currentConvId) {
|
||||||
@@ -227,14 +511,29 @@ export function Conversation() {
|
|||||||
<div className="chat-body"><div className="empty-state">
|
<div className="chat-body"><div className="empty-state">
|
||||||
<div style={{ fontSize: 40 }}>⌘</div>
|
<div style={{ fontSize: 40 }}>⌘</div>
|
||||||
<div>选择或创建一个会话开始</div>
|
<div>选择或创建一个会话开始</div>
|
||||||
<div style={{ fontSize: 12 }}>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></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="chat-header">
|
||||||
<span className="project-badge" title="当前项目">{projectName}</span>
|
<span className="project-badge" title="当前项目">{projectName}</span>
|
||||||
<div className="chat-title">{conversation?.title ?? '会话'}</div>
|
<div className="chat-title">{conversation?.title ?? '会话'}</div>
|
||||||
@@ -246,28 +545,46 @@ export function Conversation() {
|
|||||||
}}>
|
}}>
|
||||||
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<span title={connected ? '事件流已连接' : '事件流重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
<button className="btn ghost" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||||
{active && <button className="btn ghost" onClick={() => store.cancelActive()}>■ 停止</button>}
|
title={conversation?.allowCrossRead === false
|
||||||
|
? '本会话不读取其他会话记录(点击开启)'
|
||||||
|
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
|
||||||
|
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
|
||||||
|
<Icon name="users" size={13} />跨读{conversation?.allowCrossRead === false ? '·关' : '·开'}
|
||||||
|
</button>
|
||||||
|
<SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} />
|
||||||
|
<span title={connected ? '事件流已连接' : '重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
||||||
|
{!connected && <span className="conn-label">重连中</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chat-body" ref={bodyRef}
|
<div className="chat-body" ref={bodyRef}
|
||||||
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('drag-over') }}
|
onScroll={() => {
|
||||||
onDragLeave={e => e.currentTarget.classList.remove('drag-over')}
|
const src = bodyRef.current
|
||||||
onDrop={async e => {
|
if (!src) return
|
||||||
e.preventDefault(); e.currentTarget.classList.remove('drag-over')
|
const dist = src.scrollHeight - src.scrollTop - src.clientHeight
|
||||||
for (const f of Array.from(e.dataTransfer.files ?? [])) {
|
const stick = dist < 80
|
||||||
const id = await store.uploadAttachment(f)
|
stickRef.current = stick
|
||||||
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
|
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') {
|
if (m.role === 'user') {
|
||||||
// 内联确认卡(T3):锚定本消息的危险命令确认,双现于对话流与收件箱。
|
// 内联确认卡(T3):锚定本消息的危险命令确认,双现于对话流与收件箱。
|
||||||
const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm')
|
const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm')
|
||||||
return (
|
return (
|
||||||
<div className="msg user" key={m.id}>
|
<div className={`msg user${m.excluded ? ' is-excluded' : ''}`} key={m.id} data-msg-id={m.id}>
|
||||||
<div className="msg-role">你</div>
|
<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>
|
<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 => (
|
{anchored.map(c => (
|
||||||
<InlineConfirmCard key={c.id} card={c} callbacks={{
|
<InlineConfirmCard key={c.id} card={c} callbacks={{
|
||||||
onAction: (card, actionId) => store.cardAction(card, actionId),
|
onAction: (card, actionId) => store.cardAction(card, actionId),
|
||||||
@@ -277,27 +594,65 @@ export function Conversation() {
|
|||||||
</div>
|
</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} />
|
||||||
})}
|
})}
|
||||||
|
{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} />)}
|
{activeHitl.map(c => <HITLCardView key={c.id} card={c} />)}
|
||||||
{messages.length === 0 && <div className="empty-state">
|
{messages.length === 0 && <div className="empty-state">
|
||||||
<div>发送消息开始对话</div>
|
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>
|
<div>发送消息开始对话</div>
|
||||||
|
{runtimeMock && <div style={{ fontSize: 'var(--fs-ui-sm)', color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>}
|
||||||
</div>}
|
</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">
|
<div className="input-bar">
|
||||||
|
<OutboxPanel />
|
||||||
|
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
|
||||||
|
后台任务在回合结束后仍在执行,完成后回到对话汇报。 */}
|
||||||
|
{(busy || bgRunning > 0) && (
|
||||||
|
<div className="input-status" data-testid="input-status">
|
||||||
|
<span className="live-dot" />
|
||||||
|
{busy
|
||||||
|
? <span className="mk-sweep">助手工作中{bgRunning > 0 ? ` · ${bgRunning} 个后台任务` : ''}</span>
|
||||||
|
: <span>{bgRunning} 个后台任务进行中</span>}
|
||||||
|
<span className="input-status-note">{busy ? '输入仍可排队' : '完成后回到对话汇报'}</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button type="button" className="input-status-link" onClick={() => store.openInbox()}>在工作里查看</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{attachmentNames.length > 0 && (
|
{attachmentNames.length > 0 && (
|
||||||
<div className="attachments">
|
<div className="attachments">
|
||||||
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}>📎 {n}
|
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}><Icon name='paperclip' size={12} /> {n}
|
||||||
<span style={{ cursor: 'pointer' }} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}>✕</span>
|
<span style={{ cursor: 'pointer', display: 'inline-flex' }} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}><Icon name="x" size={11} /></span>
|
||||||
</span>)}
|
</span>)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{text.startsWith('/') && <CommandMenu text={text} onPick={c => setText(c)} />}
|
{text.startsWith('/') && <CommandMenu text={text} onPick={c => setText(c)} />}
|
||||||
<div className="input-row">
|
<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="上传附件(文档解析后注入上下文)">
|
<label className="icon-btn" title="上传附件(文档解析后注入上下文)">
|
||||||
📎<input type="file" multiple style={{ display: 'none' }} onChange={async e => {
|
<Icon name="paperclip" size={16} /><input type="file" multiple style={{ display: 'none' }} onChange={async e => {
|
||||||
for (const f of Array.from(e.target.files ?? [])) {
|
for (const f of Array.from(e.target.files ?? [])) {
|
||||||
const id = await store.uploadAttachment(f)
|
const id = await store.uploadAttachment(f)
|
||||||
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
|
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
|
||||||
@@ -305,16 +660,77 @@ export function Conversation() {
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}} />
|
}} />
|
||||||
</label>
|
</label>
|
||||||
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡' : artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
|
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡'
|
||||||
|
: busy ? '继续输入以排队'
|
||||||
|
: artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
|
||||||
onChange={e => setText(e.target.value)}
|
onChange={e => setText(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
|
||||||
{active ? (
|
{showStop && (
|
||||||
<button className="btn danger" onClick={() => store.cancelActive()}>停止</button>
|
<button className="btn danger" onClick={() => store.cancelActive()}>停止</button>
|
||||||
) : (
|
|
||||||
<button className="btn" onClick={send} disabled={!text.trim() && attachmentIds.length === 0}>发送</button>
|
|
||||||
)}
|
)}
|
||||||
|
<button className="btn" onClick={send} disabled={!text.trim() && attachmentIds.length === 0}>{busy ? '排队' : '发送'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
// 右栏文档查看器(4.4):markdown 渲染 / 文本编辑保存 / 图片预览 / 其他下载。
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { marked } from 'marked'
|
|
||||||
import { api } from '../api'
|
|
||||||
import { store } from '../store'
|
|
||||||
import { OverlayHost } from '@ui'
|
|
||||||
import type { Artifact } from '../contracts'
|
|
||||||
|
|
||||||
export function DocViewer({ artifact }: { artifact: Artifact }) {
|
|
||||||
const [content, setContent] = useState<string | null>(null)
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const [dirty, setDirty] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (artifact.kind === 'image' || isBinaryKind(artifact)) return
|
|
||||||
fetch(api.artifactContentUrl(artifact.id)).then(r => r.text()).then(setContent).catch(() => setContent('(读取失败)'))
|
|
||||||
}, [artifact.id])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OverlayHost title={artifact.name} icon="📄" onClose={() => store.viewArtifact(null)}>
|
|
||||||
<div className="modal">
|
|
||||||
<div className="modal-header">
|
|
||||||
📄 {artifact.name}
|
|
||||||
<span className="spacer" />
|
|
||||||
{dirty && <span style={{ color: 'var(--warn)', fontSize: 12, marginRight: 8 }}>未保存</span>}
|
|
||||||
{isTextKind(artifact) && !isBinaryKind(artifact) && (
|
|
||||||
<>
|
|
||||||
<button className="btn ghost" onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</button>
|
|
||||||
{editing && <button className="btn" disabled={!dirty} onClick={async () => {
|
|
||||||
await api.saveArtifactContent(artifact.id, content ?? '')
|
|
||||||
setDirty(false)
|
|
||||||
store.toast('已保存')
|
|
||||||
}}>保存</button>
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<a className="btn ghost" style={{ textDecoration: 'none' }} href={api.artifactContentUrl(artifact.id)} download={artifact.name}>下载</a>
|
|
||||||
<button className="btn ghost" onClick={() => store.viewArtifact(null)}>关闭</button>
|
|
||||||
</div>
|
|
||||||
<div className="modal-body">
|
|
||||||
{artifact.kind === 'image' ? (
|
|
||||||
<img src={api.artifactContentUrl(artifact.id)} alt={artifact.name} style={{ maxWidth: '100%' }} />
|
|
||||||
) : isBinaryKind(artifact) ? (
|
|
||||||
<div style={{ color: 'var(--text-faint)' }}>二进制工件——请下载查看(docx 深度预览为远期项;md/txt 可在线查看)</div>
|
|
||||||
) : editing ? (
|
|
||||||
<textarea className="search-input" style={{ height: '60vh', fontFamily: 'var(--mono)', fontSize: 12.5 }}
|
|
||||||
value={content ?? ''} onChange={e => { setContent(e.target.value); setDirty(true) }} />
|
|
||||||
) : (
|
|
||||||
<div className="doc-viewer" dangerouslySetInnerHTML={{ __html: marked.parse(content ?? '(加载中…') as string }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</OverlayHost>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTextKind(a: Artifact): boolean {
|
|
||||||
return a.kind === 'document' && /\.(md|txt|csv|log|json)$/i.test(a.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBinaryKind(a: Artifact): boolean {
|
|
||||||
return /\.(docx|pdf|xlsx|pptx|png|jpg|jpeg|gif|webp)$/i.test(a.name)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// 登录与改密(管理员发放制,R2):
|
||||||
|
// LoginPage —— 用户名+密码(主)/ API 密钥(兼容程序化客户端)双入口;
|
||||||
|
// ChangePasswordGate —— 初始/重置密码后的首登强制改密(不完成不放行)。
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { auth } from '../auth'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { Icon, BrandMark } from '@ui'
|
||||||
|
|
||||||
|
function Err({ msg }: { msg: string }) {
|
||||||
|
if (!msg) return null
|
||||||
|
return <div className="lg-err" role="alert"><Icon name="alert" size={14} />{msg}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 宽屏右侧品牌面板(叙事 + 特性清单);窄屏隐藏。 */
|
||||||
|
function BrandPanel() {
|
||||||
|
return (
|
||||||
|
<aside className="lg-panel">
|
||||||
|
<h2>把研发记忆,<br />变成组织的第二大脑</h2>
|
||||||
|
<p>文档、决策与知识的沉淀、检索与生成——面向团队的自托管智能体工作台。</p>
|
||||||
|
<ul>
|
||||||
|
<li><Icon name="message" size={15} />项目化多任务工作区,收件箱驱动待办</li>
|
||||||
|
<li><Icon name="shield" size={15} />管理员发放账号,密码策略与密钥分级</li>
|
||||||
|
<li><Icon name="search" size={15} />知识库混合检索(全文 + 向量)</li>
|
||||||
|
<li><Icon name="package" size={15} />文档生成流水线与工件管理</li>
|
||||||
|
</ul>
|
||||||
|
<div className="lg-foot"><span>自托管 · 多租户</span><span>数据不出域</span></div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。 */
|
||||||
|
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
|
||||||
|
const [tab, setTab] = useState<'password' | 'apikey'>('password')
|
||||||
|
const [owner, setOwner] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [key, setKey] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const submitPassword = async () => {
|
||||||
|
setErr(''); setBusy(true)
|
||||||
|
try {
|
||||||
|
const me = await api.login(owner.trim(), password)
|
||||||
|
// 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。
|
||||||
|
auth.beginSession()
|
||||||
|
if (me.mustChangePassword) {
|
||||||
|
// 初始/重置密码:先完成强制改密再放行。
|
||||||
|
onMustChange(me.owner)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onAuthed()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(humanLoginError(String(e)))
|
||||||
|
} finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitKey = async () => {
|
||||||
|
setErr(''); setBusy(true)
|
||||||
|
auth.setKey(key)
|
||||||
|
try {
|
||||||
|
const r = await auth.fetch('/api/health')
|
||||||
|
if (r.ok) {
|
||||||
|
// 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录):
|
||||||
|
// 不放行进死路改密门,引导改走账号密码登录。
|
||||||
|
const me = await api.me().catch(() => null)
|
||||||
|
if (me?.mustChangePassword) {
|
||||||
|
auth.clear()
|
||||||
|
setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onAuthed(); return
|
||||||
|
}
|
||||||
|
throw new Error(String(r.status))
|
||||||
|
} catch {
|
||||||
|
auth.clear()
|
||||||
|
setErr('密钥无效或已撤销,请重试')
|
||||||
|
} finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg-wrap">
|
||||||
|
<BrandPanel />
|
||||||
|
<div className="lg-formside">
|
||||||
|
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
|
||||||
|
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||||
|
<h2 className="lg-title">登录</h2>
|
||||||
|
<p className="lg-sub">账号由管理员发放;首次登录需修改初始密码</p>
|
||||||
|
<div className="lg-tabs" role="tablist">
|
||||||
|
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
|
||||||
|
<button type="button" role="tab" aria-selected={tab === 'apikey'} className={`lg-tab ${tab === 'apikey' ? 'on' : ''}`} onClick={() => setTab('apikey')}>API 密钥</button>
|
||||||
|
</div>
|
||||||
|
{tab === 'password' ? (
|
||||||
|
<>
|
||||||
|
<label className="lg-label">用户名</label>
|
||||||
|
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
|
||||||
|
<label className="lg-label">密码</label>
|
||||||
|
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
|
||||||
|
<Err msg={err} />
|
||||||
|
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="lg-label">API 访问密钥</label>
|
||||||
|
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="ask_…" />
|
||||||
|
<Err msg={err} />
|
||||||
|
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '验证中…' : '进入'}</button>
|
||||||
|
<p className="lg-hint">密钥由服务端创建:<code>agent-system apikey create --owner <name></code>。程序化客户端也可直接带 Authorization 头。</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 强制改密门:初始密码/管理员重置后的首次登录必须完成改密才可进入系统。 */
|
||||||
|
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
|
||||||
|
const [current, setCurrent] = useState('')
|
||||||
|
const [next, setNext] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setErr('')
|
||||||
|
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
|
||||||
|
if (next.length < 8) { setErr('新密码至少 8 位'); return }
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.changePassword(current, next)
|
||||||
|
onDone()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(humanChangePasswordError(String(e)))
|
||||||
|
} finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg-wrap">
|
||||||
|
<BrandPanel />
|
||||||
|
<div className="lg-formside">
|
||||||
|
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
|
||||||
|
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||||
|
<h2 className="lg-title">修改密码</h2>
|
||||||
|
<p className="lg-sub">账号 <b>{owner}</b> 使用初始/重置密码首次登录,须设置新密码后继续</p>
|
||||||
|
<label className="lg-label">当前密码(初始/重置密码)</label>
|
||||||
|
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus autoComplete="current-password" />
|
||||||
|
<label className="lg-label">新密码(≥8 位)</label>
|
||||||
|
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoComplete="new-password" />
|
||||||
|
<label className="lg-label">确认新密码</label>
|
||||||
|
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
|
||||||
|
<Err msg={err} />
|
||||||
|
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '设置新密码并进入'}</button>
|
||||||
|
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>返回登录</button>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
|
||||||
|
export function humanLoginError(raw: string): string {
|
||||||
|
if (raw.includes('401')) return '用户名或密码不正确'
|
||||||
|
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||||
|
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||||
|
return raw.replace(/^\d{3}:\s*/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key),
|
||||||
|
* 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。
|
||||||
|
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
||||||
|
export function humanChangePasswordError(raw: string): string {
|
||||||
|
if (raw.includes('current password incorrect')) return '当前密码不正确'
|
||||||
|
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
|
||||||
|
if (raw.includes('session expired')) return '会话已过期,请重新登录'
|
||||||
|
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
|
||||||
|
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||||
|
if (raw.includes('401')) return '认证失败,请重新登录'
|
||||||
|
return raw.replace(/^\d{3}:\s*/, '')
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// 第三栏「文件」入口的多标签预览:打开/切换/关闭标签、编辑保存、展开铺满与下载。
|
||||||
|
// 列表 ⇄ 预览在同一入口内切换;宽度档(阅读档 420–1100)由 App 的分隔条控制。
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Btn, FilePreview, Icon, SurfaceView, TabStrip } from '@ui'
|
||||||
|
import { api } from '../api'
|
||||||
|
import type { Artifact } from '../contracts'
|
||||||
|
import { humanSize } from '../messageparts'
|
||||||
|
import { previewTabsKey } from '../previewtabs'
|
||||||
|
import { store, useStore } from '../store'
|
||||||
|
|
||||||
|
export function PreviewPane() {
|
||||||
|
// 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。
|
||||||
|
const tabs = useStore(s => s.previewTabs[previewTabsKey(s.currentConvId)] ?? [])
|
||||||
|
const activeId = useStore(s => s.previewActive[previewTabsKey(s.currentConvId)] ?? null)
|
||||||
|
const artifacts = useStore(s => s.artifacts)
|
||||||
|
const expanded = useStore(s => s.ui.previewExpanded)
|
||||||
|
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
|
||||||
|
const surfaces = useStore(s => s.surfaces)
|
||||||
|
const surfaceSnap = active?.id.startsWith('surface:')
|
||||||
|
? surfaces.find(x => 'surface:' + String(x.identity.surfaceId) === active.id)
|
||||||
|
: undefined
|
||||||
|
const art = active ? artifacts.find(a => a.id === active.id) : undefined
|
||||||
|
const textKind = !!art && isTextKind(art)
|
||||||
|
const [content, setContent] = useState<string | null>(null)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditing(false)
|
||||||
|
setDirty(false)
|
||||||
|
setContent(null)
|
||||||
|
if (!art || !isTextKind(art)) return
|
||||||
|
let cancelled = false
|
||||||
|
fetch(api.artifactContentUrl(art.id))
|
||||||
|
.then(r => r.text())
|
||||||
|
.then(text => { if (!cancelled) setContent(text) })
|
||||||
|
.catch(() => { if (!cancelled) setContent('(读取失败)') })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [art?.id])
|
||||||
|
|
||||||
|
if (!active) return null
|
||||||
|
if (active.id.startsWith('surface:')) {
|
||||||
|
return (
|
||||||
|
<div className="preview-pane">
|
||||||
|
<TabStrip
|
||||||
|
tabs={tabs.map(t => ({ id: t.id, label: t.name }))}
|
||||||
|
activeId={active.id}
|
||||||
|
onActivate={id => store.activatePreview(id)}
|
||||||
|
onClose={id => store.closePreview(id)}
|
||||||
|
/>
|
||||||
|
<div className="preview-pane-bar">
|
||||||
|
<button type="button" className="icon-btn" title="返回文件列表" onClick={() => store.setUI({ filesView: 'list', previewExpanded: false })}>
|
||||||
|
<Icon name="list" size={14} />
|
||||||
|
</button>
|
||||||
|
<span className="preview-pane-name" title={active.name}>{active.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="preview-pane-body">
|
||||||
|
{surfaceSnap
|
||||||
|
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), id, {})} docBridge={{ onOpenDoc: docId => {
|
||||||
|
const art = artifacts.find(a => a.id === docId)
|
||||||
|
if (art) store.requestPreview(art)
|
||||||
|
} }} />
|
||||||
|
: <p className="surface-view-empty">页面快照不可用(可能已被移除或无权限)。</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="preview-pane">
|
||||||
|
<TabStrip
|
||||||
|
tabs={tabs.map(t => ({ id: t.id, label: t.name }))}
|
||||||
|
activeId={active.id}
|
||||||
|
onActivate={id => store.activatePreview(id)}
|
||||||
|
onClose={id => store.closePreview(id)}
|
||||||
|
/>
|
||||||
|
<div className="preview-pane-bar">
|
||||||
|
<button type="button" className="icon-btn" title="返回文件列表" onClick={() => store.setUI({ filesView: 'list', previewExpanded: false })}>
|
||||||
|
<Icon name="list" size={14} />
|
||||||
|
</button>
|
||||||
|
<span className="preview-pane-name" title={active.name}>{active.name}</span>
|
||||||
|
{art && (
|
||||||
|
<span className="preview-pane-meta">{art.kind}{humanSize(art.size) ? ` · ${humanSize(art.size)}` : ''}</span>
|
||||||
|
)}
|
||||||
|
<span className="preview-pane-spacer" />
|
||||||
|
{art && (
|
||||||
|
<>
|
||||||
|
{dirty && <span className="preview-pane-dirty">未保存</span>}
|
||||||
|
{textKind && (
|
||||||
|
<>
|
||||||
|
<Btn variant="ghost" icon={editing ? 'eye' : 'list'} onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</Btn>
|
||||||
|
{editing && <Btn variant="primary" icon="check" disabled={!dirty} onClick={async () => {
|
||||||
|
await api.saveArtifactContent(art.id, content ?? '')
|
||||||
|
setDirty(false)
|
||||||
|
store.toast('已保存')
|
||||||
|
}}>保存</Btn>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button type="button" className="icon-btn" title={expanded ? '还原预览' : '展开预览'} aria-pressed={expanded} onClick={() => store.togglePreviewExpanded()}>
|
||||||
|
<Icon name={expanded ? 'minimize' : 'maximize'} size={14} />
|
||||||
|
</button>
|
||||||
|
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(art.id)} download={art.name}>
|
||||||
|
<Icon name="download" size={14} />
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="preview-pane-body">
|
||||||
|
{editing && textKind ? (
|
||||||
|
<textarea className="search-input preview-pane-editor" value={content ?? ''}
|
||||||
|
onChange={e => { setContent(e.target.value); setDirty(true) }} />
|
||||||
|
) : art ? (
|
||||||
|
<FilePreview source={{
|
||||||
|
kind: 'artifact',
|
||||||
|
id: art.id,
|
||||||
|
name: art.name,
|
||||||
|
mime: art.mime,
|
||||||
|
contentUrl: api.artifactContentUrl(art.id),
|
||||||
|
downloadUrl: api.artifactDownloadUrl(art.id),
|
||||||
|
}} />
|
||||||
|
) : <div className="empty-note" style={{ padding: 14 }}>这个文件已不在当前项目里</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextKind(a: Artifact): boolean {
|
||||||
|
return a.kind === 'document' && /\.(md|txt|csv|log|json)$/i.test(a.name)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// 工作时间线:思考、对用户的解说、同类工具合并成一行。运行中展开;
|
||||||
|
// 终轮结束后由调用方套进「已工作 X」折叠。
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Collapsible, ToolBlock } from '@ui'
|
||||||
|
import type { Part } from '../contracts'
|
||||||
|
import { formatDuration, humanSize } from '../messageparts'
|
||||||
|
import { timelineEntries, toolGroupLabel, toolTail, type ToolGroupItem } from '../toolkind'
|
||||||
|
|
||||||
|
export function ProcessTimeline({ parts, live, idKey, renderPart }: {
|
||||||
|
parts: Part[]
|
||||||
|
live: boolean
|
||||||
|
idKey: string
|
||||||
|
renderPart: (part: Part, liveText: boolean) => ReactNode
|
||||||
|
}) {
|
||||||
|
const rows = timelineEntries(parts, live)
|
||||||
|
return (
|
||||||
|
<div className="tl">
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
if (row.kind === 'thinking') {
|
||||||
|
const dur = formatDuration(row.durationMs)
|
||||||
|
const label = row.active ? '思考中' : (dur ? `思考 · 持续了 ${dur}` : '思考')
|
||||||
|
return (
|
||||||
|
<div className="part-thinking" key={`${idKey}:th:${i}`}>
|
||||||
|
<Collapsible id={`think:${idKey}:${i}:${row.active ? 'live' : 'done'}`} defaultOpen={row.active} key={row.active ? 'open' : 'shut'}
|
||||||
|
triggerClassName="part-thinking-summary"
|
||||||
|
trigger={<><span className="part-thinking-chev">▸</span>
|
||||||
|
<span className={row.active ? 'mk-sweep' : ''}>{label}</span></>}>
|
||||||
|
<div className="part-thinking-body">{row.text}</div>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (row.kind === '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 (
|
||||||
|
<div className="tl-tools" key={`${idKey}:tl:${i}`}>
|
||||||
|
<div className={`tl-tools-head${running ? ' mk-sweep' : ''}`}>{toolGroupLabel(row.toolKind, row.items.length, running)}</div>
|
||||||
|
<ul className="tl-tools-list">
|
||||||
|
{row.items.map((it, j) => (
|
||||||
|
<li key={j}>
|
||||||
|
<span className="tl-tools-name">{it.headline}</span>
|
||||||
|
{it.status === 'error' && <span className="status-error">失败</span>}
|
||||||
|
{it.summary && <span className="tl-tools-sum">{it.summary}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <div className="part" key={`${idKey}:ot:${i}`}>{renderPart(row.part, false)}</div>
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
// 左栏:项目折叠组 → 会话列表;底部账户/设置入口(4.4)。
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { store, useStore } from '../store'
|
|
||||||
import { workspaceEntries } from './Workspaces'
|
|
||||||
|
|
||||||
export function ProjectNav() {
|
|
||||||
const projects = useStore(s => s.projects)
|
|
||||||
const currentProjectId = useStore(s => s.currentProjectId)
|
|
||||||
const conversations = useStore(s => s.conversations)
|
|
||||||
const currentConvId = useStore(s => s.currentConvId)
|
|
||||||
const [collapsedProjects, setCollapsedProjects] = useState<Record<string, boolean>>({})
|
|
||||||
const [creating, setCreating] = useState(false)
|
|
||||||
const [newName, setNewName] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => { setNewName(`项目 ${projects.length + 1}`) }, [projects.length])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="nav-header">
|
|
||||||
<span className="nav-label">Agent-System</span>
|
|
||||||
<button title="新建项目" onClick={() => setCreating(v => !v)}>✚</button>
|
|
||||||
</div>
|
|
||||||
<div className="ws-quick">
|
|
||||||
<div className="nav-label">工作区</div>
|
|
||||||
<div className="ws-quick-list">
|
|
||||||
{workspaceEntries().map(e => (
|
|
||||||
<button key={e.key} className="ws-quick-item" title={`${e.title}(${e.shortcut})`}
|
|
||||||
onClick={() => store.openWorkspace(e.key)}>
|
|
||||||
<span>{e.icon}</span><span className="name">{e.title}</span><span className="sc">{e.shortcut}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{creating && (
|
|
||||||
<div style={{ padding: '0 10px 8px', display: 'flex', gap: 6 }}>
|
|
||||||
<input className="search-input" value={newName} onChange={e => setNewName(e.target.value)}
|
|
||||||
onKeyDown={async e => {
|
|
||||||
if (e.key === 'Enter' && newName.trim()) { await store.createProject(newName.trim()); setCreating(false) }
|
|
||||||
}} placeholder="项目名称" autoFocus />
|
|
||||||
<button className="btn" onClick={async () => { if (newName.trim()) { await store.createProject(newName.trim()); setCreating(false) } }}>建</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="nav-scroll">
|
|
||||||
{projects.length === 0 && <div style={{ padding: 12, color: 'var(--text-faint)', fontSize: 12.5 }}>尚无项目——点击 ✚ 创建</div>}
|
|
||||||
{projects.map(p => {
|
|
||||||
const open = !collapsedProjects[p.id]
|
|
||||||
return (
|
|
||||||
<div className="nav-project" key={p.id}>
|
|
||||||
<div className={`nav-project-name ${open ? 'open' : ''} ${p.id === currentProjectId ? '' : ''}`}
|
|
||||||
style={p.id === currentProjectId ? { color: 'var(--text)' } : undefined}
|
|
||||||
onClick={async () => {
|
|
||||||
setCollapsedProjects(c => ({ ...c, [p.id]: !(c[p.id] ?? p.id === currentProjectId ? false : true) }))
|
|
||||||
await store.openProject(p.id)
|
|
||||||
}}>
|
|
||||||
<span className="chev">▶</span>
|
|
||||||
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
|
|
||||||
</div>
|
|
||||||
{open && p.id === currentProjectId && conversations.map(c => (
|
|
||||||
<div key={c.id} className={`nav-conv ${c.id === currentConvId ? 'active' : ''}`}
|
|
||||||
onClick={() => store.openConversation(c.id)} title={c.title}>
|
|
||||||
{c.title}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="nav-footer">
|
|
||||||
<button className="nav-label" onClick={() => store.createConversation()}>+ 新会话</button>
|
|
||||||
<button className="nav-label" onClick={() => store.openSettings(true)}>⚙ 设置</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// 项目设置面板(提案 2026-09-30-cross-session-reading):首个项目级设置 UI。
|
||||||
|
// schema 驱动渲染 project scope(4.9 覆盖链:system → user → project),
|
||||||
|
// 写 putProjectSettings;后续项目级设置项经 settings schema 自动出现,零开发。
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { store, useStore } from '../store'
|
||||||
|
import type { SettingDesc } from '../contracts'
|
||||||
|
import { SettingsShell, Btn, EmptyState } from '@ui'
|
||||||
|
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
|
||||||
|
|
||||||
|
export function ProjectSettingsModal() {
|
||||||
|
const projectId = useStore(s => s.ui.projectSettingsId)
|
||||||
|
const projects = useStore(s => s.projects)
|
||||||
|
const project = projects.find(p => p.id === projectId)
|
||||||
|
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||||||
|
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) return
|
||||||
|
setLoaded(false)
|
||||||
|
api.getSettings(projectId).then(s => {
|
||||||
|
setSchema(s.schema)
|
||||||
|
setDraft(flatten(s.project))
|
||||||
|
setLoaded(true)
|
||||||
|
}).catch(() => undefined)
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const g: Record<string, SettingDesc[]> = {}
|
||||||
|
for (const d of schema) (g[d.group] ??= []).push(d)
|
||||||
|
return g
|
||||||
|
}, [schema])
|
||||||
|
|
||||||
|
if (!projectId) return null
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!projectId) return
|
||||||
|
try {
|
||||||
|
await api.putProjectSettings(projectId, unflatten(draft))
|
||||||
|
setSaved(true)
|
||||||
|
setTimeout(() => setSaved(false), 1500)
|
||||||
|
} catch (e) {
|
||||||
|
store.toast(`项目设置保存失败:${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsShell
|
||||||
|
title={`项目设置 · ${project?.name ?? projectId}`}
|
||||||
|
active="project"
|
||||||
|
onSelect={() => undefined}
|
||||||
|
onClose={() => store.openProjectSettings(null)}
|
||||||
|
sections={[{ key: 'project', label: '本项目', icon: 'folder' }]}
|
||||||
|
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{!loaded ? (
|
||||||
|
<EmptyState icon="settings">加载中…</EmptyState>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="empty-note" style={{ marginBottom: 10 }}>
|
||||||
|
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
|
||||||
|
</div>
|
||||||
|
{Object.entries(groups).map(([g, descs]) => (
|
||||||
|
<div className="settings-group" key={g}>
|
||||||
|
<h4>{groupNames[g] ?? g}</h4>
|
||||||
|
{descs.map(d => (
|
||||||
|
<div className="setting-row" key={d.key}>
|
||||||
|
<label title={d.key}>{d.label}</label>
|
||||||
|
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
|
||||||
|
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
|
||||||
|
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
// U4:项目追溯审计工作区(四入口)——消费 U3 rdm.inspect.* 命令面。
|
||||||
|
// 只读:审批/确认关系仍回原业务流程(D31:弹层不是唯一路径)。
|
||||||
|
// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
|
||||||
|
import { classifyRdmError } from '../rdmErrors'
|
||||||
|
|
||||||
|
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
|
||||||
|
|
||||||
|
interface InspectProject { projectId: string; name: string; canAudit: boolean }
|
||||||
|
interface Capabilities { actor: string; projects: InspectProject[]; crossProject: boolean }
|
||||||
|
interface CoverageItem { face: string; reason: string }
|
||||||
|
interface ListShell<T = any> { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
|
||||||
|
interface SummaryData { projectId: string; counts: Record<string, number>; watermark: number; lastOpAt: string; coverage?: CoverageItem[] }
|
||||||
|
interface TreeItem { kind: string; id: string; title: string; state?: string }
|
||||||
|
interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number }
|
||||||
|
interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string }
|
||||||
|
interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] }
|
||||||
|
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
|
||||||
|
interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
|
||||||
|
|
||||||
|
// ---- 工作区主组件 ----
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'summary', label: '概览' },
|
||||||
|
{ id: 'materials', label: '资料与文档' },
|
||||||
|
{ id: 'trace', label: '关联追溯' },
|
||||||
|
{ id: 'timeline', label: '操作记录' },
|
||||||
|
] as const
|
||||||
|
type TabId = (typeof TABS)[number]['id']
|
||||||
|
|
||||||
|
/** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断;
|
||||||
|
* 「无可审计项目」由调用处独立空态承担,不走本组件。 */
|
||||||
|
function RdmErrorState({ raw }: { raw: string }) {
|
||||||
|
const c = classifyRdmError(raw)
|
||||||
|
return (
|
||||||
|
<div className="rdm-audit-err">
|
||||||
|
<EmptyState icon="alert">{c.message}</EmptyState>
|
||||||
|
<Collapsible id="rdm-audit-err-receipt" trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
|
||||||
|
<code className="rdm-audit-err-raw">{raw}</code>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RdmAuditWorkspace() {
|
||||||
|
const [caps, setCaps] = useState<Capabilities | null>(null)
|
||||||
|
const [pid, setPid] = useState('')
|
||||||
|
const [tab, setTab] = useState<TabId>('summary')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
api.rdmCommand<Capabilities>('rdm.inspect.capabilities')
|
||||||
|
.then(c => { if (!cancelled) { setCaps(c); if (c.projects.length > 0) setPid(prev => prev || c.projects[0].projectId) } })
|
||||||
|
.catch(e => { if (!cancelled) setError(e.message) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const project = caps?.projects.find(p => p.projectId === pid)
|
||||||
|
if (error) return <RdmErrorState raw={error} />
|
||||||
|
if (!caps) return <div className="empty-note">加载中…</div>
|
||||||
|
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rdm-audit">
|
||||||
|
<div className="rdm-audit-header">
|
||||||
|
<select className="ui-input rdm-audit-project" value={pid} onChange={e => setPid(e.target.value)} aria-label="选择项目">
|
||||||
|
{caps.projects.map(p => (
|
||||||
|
<option key={p.projectId} value={p.projectId}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{project?.canAudit && <Chip tone="ok">可审计</Chip>}
|
||||||
|
{caps.crossProject && <Chip tone="info">跨项目</Chip>}
|
||||||
|
</div>
|
||||||
|
<div className="tabs rdm-audit-tabs" role="tablist">
|
||||||
|
{TABS.map(t => (
|
||||||
|
<button key={t.id} role="tab" aria-selected={tab === t.id}
|
||||||
|
className={`tab${tab === t.id ? ' active' : ''}`}
|
||||||
|
onClick={() => setTab(t.id)}>{t.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="rdm-audit-body">
|
||||||
|
{tab === 'summary' && <SummaryTab pid={pid} />}
|
||||||
|
{tab === 'materials' && <MaterialsTab pid={pid} />}
|
||||||
|
{tab === 'trace' && <TraceTab pid={pid} />}
|
||||||
|
{tab === 'timeline' && <TimelineTab pid={pid} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 概览 ----
|
||||||
|
|
||||||
|
function SummaryTab({ pid }: { pid: string }) {
|
||||||
|
const [data, setData] = useState<SummaryData | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
let c = false
|
||||||
|
setData(null); setErr('')
|
||||||
|
api.rdmCommand<SummaryData>('rdm.inspect.summary', { projectId: pid })
|
||||||
|
.then(d => { if (!c) setData(d) })
|
||||||
|
.catch(e => { if (!c) setErr(e.message) })
|
||||||
|
return () => { c = true }
|
||||||
|
}, [pid])
|
||||||
|
if (err) return <RdmErrorState raw={err} />
|
||||||
|
if (!data) return <div className="empty-note">加载中…</div>
|
||||||
|
const counts = data.counts || {}
|
||||||
|
const entries: [string, string, number][] = [
|
||||||
|
['切片', 'slices', counts.slices ?? 0],
|
||||||
|
['章节', 'sections', counts.sections ?? 0],
|
||||||
|
['文档', 'docs', counts.docs ?? 0],
|
||||||
|
['生效边', 'edgesActive', counts.edgesActive ?? 0],
|
||||||
|
['待确认边', 'edgesCandidate', counts.edgesCandidate ?? 0],
|
||||||
|
['结论', 'conclusions', counts.conclusions ?? 0],
|
||||||
|
['待办', 'openItems', counts.openItems ?? 0],
|
||||||
|
['挂起上传', 'uploadsHeld', counts.uploadsHeld ?? 0],
|
||||||
|
['空缺章节', 'emptySections', counts.emptySections ?? 0],
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionLabel>统计(截至水位 {data.watermark})</SectionLabel>
|
||||||
|
<div className="rdm-audit-cards">
|
||||||
|
{entries.map(([label, , val]) => (
|
||||||
|
<div key={label} className="rdm-audit-card">
|
||||||
|
<div className="rdm-audit-card-num">{val}</div>
|
||||||
|
<div className="rdm-audit-card-label">{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{data.lastOpAt && <div className="rdm-audit-meta">最近动作:{new Date(data.lastOpAt).toLocaleString('zh-CN')}</div>}
|
||||||
|
{data.coverage && data.coverage.length > 0 && (
|
||||||
|
<CoverageList cov={data.coverage} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 资料与文档 ----
|
||||||
|
|
||||||
|
function MaterialsTab({ pid }: { pid: string }) {
|
||||||
|
const [kind, setKind] = useState<'notes' | 'docs'>('docs')
|
||||||
|
return (
|
||||||
|
<div className="rdm-audit-materials">
|
||||||
|
<div className="rdm-audit-materials-side">
|
||||||
|
<div className="tabs">
|
||||||
|
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
|
||||||
|
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
|
||||||
|
</div>
|
||||||
|
<TreeList pid={pid} kind={kind} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||||
|
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [selected, setSelected] = useState<TreeItem | null>(null)
|
||||||
|
const [detail, setDetail] = useState<ObjectDetail | null>(null)
|
||||||
|
// docs 树是层级结构(rdm.inspect.tree parent=<docId> 才返回章节):文档节点
|
||||||
|
// 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。
|
||||||
|
const [children, setChildren] = useState<Record<string, TreeItem[]>>({})
|
||||||
|
const genRef = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const gen = ++genRef.current
|
||||||
|
setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({})
|
||||||
|
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
|
||||||
|
.then(s => { if (gen === genRef.current) setShell(s) })
|
||||||
|
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||||
|
}, [pid, kind])
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
if (!shell?.nextCursor) return
|
||||||
|
const gen = genRef.current
|
||||||
|
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: shell.nextCursor })
|
||||||
|
.then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...s.items] } : s) })
|
||||||
|
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleDoc = (item: TreeItem) => {
|
||||||
|
if (children[item.id]) {
|
||||||
|
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const gen = genRef.current
|
||||||
|
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' })
|
||||||
|
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) })
|
||||||
|
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectNode = (item: TreeItem) => {
|
||||||
|
if (item.kind === 'doc') { toggleDoc(item); return }
|
||||||
|
setSelected(item); setDetail(null)
|
||||||
|
const gen = genRef.current
|
||||||
|
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
|
||||||
|
.then(d => { if (gen === genRef.current) setDetail(d) })
|
||||||
|
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err) return <RdmErrorState raw={err} />
|
||||||
|
if (!shell) return <div className="empty-note">加载中…</div>
|
||||||
|
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rdm-audit-tree">
|
||||||
|
<div className="rdm-audit-tree-list">
|
||||||
|
{shell.items.map(item => (
|
||||||
|
<div key={item.id}>
|
||||||
|
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
|
||||||
|
onClick={() => selectNode(item)}>
|
||||||
|
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
|
||||||
|
<span className="rdm-audit-tree-kind">{item.kind}</span>
|
||||||
|
<span className="rdm-audit-tree-title">{item.title}</span>
|
||||||
|
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
|
||||||
|
</button>
|
||||||
|
{item.kind === 'doc' && children[item.id] && (
|
||||||
|
<div className="rdm-audit-tree-children">
|
||||||
|
{children[item.id].length === 0 && <div className="empty-note">无章节</div>}
|
||||||
|
{children[item.id].map(sub => (
|
||||||
|
<button key={sub.id} className={`rdm-audit-tree-node${selected?.id === sub.id ? ' sel' : ''}`}
|
||||||
|
onClick={() => selectNode(sub)}>
|
||||||
|
<span className="rdm-audit-tree-kind">{sub.kind}</span>
|
||||||
|
<span className="rdm-audit-tree-title">{sub.title}</span>
|
||||||
|
{sub.state && <Chip tone={sub.state === 'baselined' ? 'ok' : 'plain'}>{sub.state}</Chip>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
|
||||||
|
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||||
|
</div>
|
||||||
|
{selected && (
|
||||||
|
<div className="rdm-audit-detail">
|
||||||
|
<SectionLabel>对象详情</SectionLabel>
|
||||||
|
{!detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 关联追溯 ----
|
||||||
|
|
||||||
|
function TraceTab({ pid }: { pid: string }) {
|
||||||
|
const [sel, setSel] = useState<{ kind: string; id: string; title: string } | null>(null)
|
||||||
|
const [relations, setRelations] = useState<RelationEntry[] | null>(null)
|
||||||
|
const [truncated, setTruncated] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const genRef = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sel) return
|
||||||
|
const gen = ++genRef.current
|
||||||
|
setRelations(null); setErr('')
|
||||||
|
api.rdmCommand<ListShell<RelationEntry>>('rdm.inspect.relations', { projectId: pid, kind: sel.kind, id: sel.id, depth: 1 })
|
||||||
|
.then(s => { if (gen === genRef.current) { setRelations(s.items); setTruncated(s.truncated) } })
|
||||||
|
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||||
|
}, [pid, sel])
|
||||||
|
|
||||||
|
// 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
|
||||||
|
// (parent=<docId>),按文档聚合后合并展示。
|
||||||
|
const [sections, setSections] = useState<TreeItem[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
if (sel) return
|
||||||
|
let c = false
|
||||||
|
;(async () => {
|
||||||
|
const top = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
|
||||||
|
const secs: TreeItem[] = []
|
||||||
|
for (const d of top.items.filter(i => i.kind === 'doc')) {
|
||||||
|
const sub = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: d.id })
|
||||||
|
secs.push(...sub.items.filter(i => i.kind === 'section'))
|
||||||
|
}
|
||||||
|
if (!c) setSections(secs)
|
||||||
|
})().catch(() => {})
|
||||||
|
return () => { c = true }
|
||||||
|
}, [pid, sel])
|
||||||
|
|
||||||
|
if (!sel) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionLabel>选择对象查看来源与影响</SectionLabel>
|
||||||
|
{sections.length === 0 ? <EmptyState icon="folder">暂无章节</EmptyState> : (
|
||||||
|
<div className="rdm-audit-pick">
|
||||||
|
{sections.map(s => (
|
||||||
|
<button key={s.id} className="btn v-ghost" onClick={() => setSel({ kind: 'section', id: s.id, title: s.title })}>
|
||||||
|
{s.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = relations?.filter(r => r.to?.id === sel.id && r.to?.kind === sel.kind) ?? []
|
||||||
|
const impacts = relations?.filter(r => r.from?.id === sel.id && r.from?.kind === sel.kind) ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="rdm-audit-trace-head">
|
||||||
|
<button className="btn v-ghost" onClick={() => setSel(null)}>← 返回</button>
|
||||||
|
<strong>{sel.title}</strong>
|
||||||
|
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
|
||||||
|
</div>
|
||||||
|
{err && <RdmErrorState raw={err} />}
|
||||||
|
{!relations && !err && <div className="empty-note">加载中…</div>}
|
||||||
|
{relations && relations.length === 0 && <EmptyState icon="folder">没有已知关联</EmptyState>}
|
||||||
|
{relations && relations.length > 0 && (
|
||||||
|
<div className="rdm-audit-trace-cols">
|
||||||
|
<div className="rdm-audit-trace-col">
|
||||||
|
<SectionLabel>来源依据({sources.length})</SectionLabel>
|
||||||
|
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||||
|
</div>
|
||||||
|
<div className="rdm-audit-trace-col">
|
||||||
|
<SectionLabel>影响对象({impacts.length})</SectionLabel>
|
||||||
|
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RelationRow({ r }: { r: RelationEntry }) {
|
||||||
|
const other = r.from?.id && r.to?.id ? (r.from?.id < r.to?.id ? r.from : r.to) : (r.from || r.to)
|
||||||
|
return (
|
||||||
|
<div className="rdm-audit-rel">
|
||||||
|
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{r.layer}</Chip>
|
||||||
|
<span className="rdm-audit-rel-type">{r.type || r.kind || '关联'}</span>
|
||||||
|
<span className="rdm-audit-rel-endpoint">{other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''}</span>
|
||||||
|
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' ? 'warn' : 'plain'}>{r.state}</Chip>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 操作记录(事件时间线) ----
|
||||||
|
|
||||||
|
function TimelineTab({ pid }: { pid: string }) {
|
||||||
|
const [items, setItems] = useState<(OpGroup | EventDTO)[]>([])
|
||||||
|
const [cursor, setCursor] = useState('')
|
||||||
|
const [hasMore, setHasMore] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [filterOp, setFilterOp] = useState('')
|
||||||
|
const [filterActor, setFilterActor] = useState('')
|
||||||
|
const [historyMode, setHistoryMode] = useState(false)
|
||||||
|
const [historySeq, setHistorySeq] = useState(0)
|
||||||
|
const [historyData, setHistoryData] = useState<HistoryData | null>(null)
|
||||||
|
const genRef = useRef(0)
|
||||||
|
|
||||||
|
const load = useCallback((cur: string, replace: boolean) => {
|
||||||
|
const gen = ++genRef.current
|
||||||
|
if (replace) { setItems([]); setErr('') }
|
||||||
|
const params: Record<string, unknown> = { projectId: pid, cursor: cur, limit: 30 }
|
||||||
|
if (filterOp) params.op = filterOp
|
||||||
|
if (filterActor) params.actor = filterActor
|
||||||
|
api.rdmCommand<ListShell<OpGroup | EventDTO>>('rdm.inspect.events', params)
|
||||||
|
.then(s => {
|
||||||
|
if (gen !== genRef.current) return
|
||||||
|
setItems(prev => replace ? s.items : [...prev, ...s.items])
|
||||||
|
setCursor(s.nextCursor || '')
|
||||||
|
setHasMore(s.hasMore)
|
||||||
|
})
|
||||||
|
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||||
|
}, [pid, filterOp, filterActor])
|
||||||
|
|
||||||
|
useEffect(() => { if (!historyMode) load('', true) }, [load, historyMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!historyMode) { setHistoryData(null); return }
|
||||||
|
const gen = genRef.current
|
||||||
|
setHistoryData(null)
|
||||||
|
const params: Record<string, unknown> = { projectId: pid, atSeq: historySeq || undefined }
|
||||||
|
api.rdmCommand<HistoryData>('rdm.inspect.history', params)
|
||||||
|
.then(d => { if (gen === genRef.current) setHistoryData(d) })
|
||||||
|
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||||
|
}, [pid, historyMode, historySeq])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rdm-audit-timeline">
|
||||||
|
<div className="rdm-audit-timeline-filters">
|
||||||
|
<input className="ui-input" placeholder="过滤操作者…" value={filterActor}
|
||||||
|
onChange={e => setFilterActor(e.target.value)} />
|
||||||
|
<input className="ui-input" placeholder="过滤操作类型…" value={filterOp}
|
||||||
|
onChange={e => setFilterOp(e.target.value)} />
|
||||||
|
<button className={`btn v-ghost${historyMode ? ' active' : ''}`} onClick={() => setHistoryMode(!historyMode)}>
|
||||||
|
{historyMode ? '← 返回当前' : '历史模式'}
|
||||||
|
</button>
|
||||||
|
{historyMode && (
|
||||||
|
<input className="ui-input rdm-audit-seq" type="number" placeholder="截止 seq…"
|
||||||
|
value={historySeq || ''} onChange={e => setHistorySeq(Number(e.target.value) || 0)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{historyMode ? (
|
||||||
|
<div>
|
||||||
|
{!historyData ? <div className="empty-note">加载中…</div> : (
|
||||||
|
<div>
|
||||||
|
<SectionLabel>截至 seq {historyData.atSeq}(当前水位 {historyData.watermark})</SectionLabel>
|
||||||
|
{historyData.countOnlySnapshots ? <div className="rdm-audit-truncated">含 {historyData.countOnlySnapshots} 个旧计数快照(不可作恢复依据)</div> : null}
|
||||||
|
{historyData.formalMissing ? <div className="empty-note">{historyData.formalMissing}</div> : null}
|
||||||
|
{historyData.object ? (
|
||||||
|
<div className="rdm-audit-detail-body">
|
||||||
|
<div>标题:{historyData.object.title}</div>
|
||||||
|
<div>状态:{historyData.object.state}</div>
|
||||||
|
{historyData.object.contentPreview && <div>内容预览:{historyData.object.contentPreview}</div>}
|
||||||
|
</div>
|
||||||
|
) : historyData.empty ? <EmptyState icon="folder">该时点无事件</EmptyState> :
|
||||||
|
<div className="empty-note">输入对象 kind:id 查看历史快照</div>}
|
||||||
|
{historyData.formal && (
|
||||||
|
<div className="rdm-audit-formal">
|
||||||
|
正式版 R{historyData.formal.rev}(内容 v{historyData.formal.seq})——{historyData.formal.actor} 于 {new Date(historyData.formal.at).toLocaleString('zh-CN')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{err && <RdmErrorState raw={err} />}
|
||||||
|
{!err && items.length === 0 && <EmptyState icon="clock">暂无事件</EmptyState>}
|
||||||
|
{items.map((item, i) => {
|
||||||
|
if ('events' in item && Array.isArray((item as OpGroup).events)) {
|
||||||
|
return <OpGroupView key={item.operationId || i} group={item as OpGroup} />
|
||||||
|
}
|
||||||
|
return <EventRow key={(item as EventDTO).seq} ev={item as EventDTO} />
|
||||||
|
})}
|
||||||
|
{hasMore && <button className="btn v-ghost rdm-audit-more" onClick={() => load(cursor, false)}>加载更多</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpGroupView({ group }: { group: OpGroup }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
return (
|
||||||
|
<div className="rdm-audit-opgroup">
|
||||||
|
<button className="rdm-audit-opgroup-head" onClick={() => setOpen(!open)}>
|
||||||
|
<span className="rdm-audit-opgroup-actor">{group.actor}</span>
|
||||||
|
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
|
||||||
|
<Chip tone="plain">{group.events.length} 个事件</Chip>
|
||||||
|
</button>
|
||||||
|
{open && group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventRow({ ev }: { ev: EventDTO }) {
|
||||||
|
return (
|
||||||
|
<div className="rdm-audit-event">
|
||||||
|
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleTimeString('zh-CN')}</span>
|
||||||
|
<span className="rdm-audit-event-op">{ev.op}</span>
|
||||||
|
<span className="rdm-audit-event-actor">{ev.actor}</span>
|
||||||
|
{ev.detail && <span className="rdm-audit-event-detail">{ev.detail.slice(0, 80)}</span>}
|
||||||
|
{ev.disclosure !== 'ok' && <Chip tone="warn">{ev.disclosure === 'model-candidate' ? '模型候选' : '来源受限'}</Chip>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 对象详情视图 ----
|
||||||
|
|
||||||
|
function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
|
||||||
|
return (
|
||||||
|
<div className="rdm-audit-detail-body">
|
||||||
|
<div><strong>{detail.title || detail.id}</strong></div>
|
||||||
|
<div>{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}</div>
|
||||||
|
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : 'plain'}>{detail.state}</Chip></div>}
|
||||||
|
{detail.labels && <div>标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}</div>}
|
||||||
|
{detail.versions && detail.versions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel>版本链({detail.versions.length})</SectionLabel>
|
||||||
|
{detail.versions.map((v: any, i: number) => (
|
||||||
|
<div key={i} className="rdm-audit-version">
|
||||||
|
v{v.seq} · {v.actor} · {new Date(v.at).toLocaleString('zh-CN')}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.formal && (
|
||||||
|
<div className="rdm-audit-formal">
|
||||||
|
正式版 R{detail.formal.rev}(内容 v{detail.formal.seq})——{detail.formal.actor} 于 {new Date(detail.formal.at).toLocaleString('zh-CN')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.relations && detail.relations.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel>一跳关系({detail.relations.length})</SectionLabel>
|
||||||
|
{detail.relations.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.recentEvents && detail.recentEvents.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel>最近事件</SectionLabel>
|
||||||
|
{detail.recentEvents.map(ev => <EventRow key={ev.seq} ev={ev} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 覆盖范围标注(不冒充完整) ----
|
||||||
|
|
||||||
|
function CoverageList({ cov }: { cov: CoverageItem[] }) {
|
||||||
|
return (
|
||||||
|
<div className="rdm-audit-coverage">
|
||||||
|
<SectionLabel>覆盖范围</SectionLabel>
|
||||||
|
{cov.map(c => (
|
||||||
|
<div key={c.face} className="rdm-audit-coverage-item">
|
||||||
|
<Chip tone="plain">{c.face}</Chip>
|
||||||
|
<span>{c.reason}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,18 +1,31 @@
|
|||||||
// 设置中心(4.9):schema 驱动渲染 + 生效时机标注 + 密钥掩码 + 插件管理 + 画像管理。
|
// 设置中心(4.9 → UI 能力平台迁移):@ui SettingsShell + SettingRow 统一行语义,
|
||||||
|
// 保存按钮进固定 actions 底栏(不随内容滚动);schema 驱动 + 生效时机标注 + 密钥掩码。
|
||||||
|
// 重设计方案 阶段②(IA):设置瘦身——只留个人偏好(常规);智能体画像/插件管理/
|
||||||
|
// 插件工作区迁「系统管理」(AdminZone,管理员专属全屏视图)。
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { store, useStore } from '../store'
|
import { isFontSizeChoice, store, useStore } from '../store'
|
||||||
import type { AgentProfile, SettingDesc } from '../contracts'
|
import type { ThemeChoice } from '../theme'
|
||||||
|
import type { SettingDesc } from '../contracts'
|
||||||
|
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
|
||||||
|
import { BorrowPanels } from './BorrowPanels'
|
||||||
|
|
||||||
type Section = 'settings' | 'profiles' | 'plugins'
|
type Section = 'settings'
|
||||||
|
|
||||||
|
// 分组显示名(项目设置面板共用)。
|
||||||
|
export const groupNames: Record<string, string> = {
|
||||||
|
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
|
||||||
|
ui: '界面', plugins: '插件目录', crossSession: '会话协作', tools: '工具',
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsModal() {
|
export function SettingsModal() {
|
||||||
const profiles = useStore(s => s.profiles)
|
const [section] = useState<Section>('settings')
|
||||||
const [section, setSection] = useState<Section>('settings')
|
|
||||||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||||||
const [, setUser] = useState<Record<string, any>>({})
|
const [, setUser] = useState<Record<string, any>>({})
|
||||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
|
const theme = useStore(s => s.ui.theme)
|
||||||
|
const fontSize = useStore(s => s.ui.fontSize)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getSettings().then(s => {
|
api.getSettings().then(s => {
|
||||||
@@ -28,11 +41,6 @@ export function SettingsModal() {
|
|||||||
return g
|
return g
|
||||||
}, [schema])
|
}, [schema])
|
||||||
|
|
||||||
const groupNames: Record<string, string> = {
|
|
||||||
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
|
|
||||||
ui: '界面', plugins: '插件目录',
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
const values = unflatten(draft)
|
const values = unflatten(draft)
|
||||||
await api.putUserSettings(values)
|
await api.putUserSettings(values)
|
||||||
@@ -45,50 +53,66 @@ export function SettingsModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.openSettings(false) }}>
|
<SettingsShell
|
||||||
<div className="modal">
|
title="设置" active={section} onSelect={() => undefined}
|
||||||
<div className="modal-header">
|
onClose={() => store.openSettings(false)}
|
||||||
⚙ 设置
|
sections={[
|
||||||
<div className="tabs" style={{ margin: '0 16px', borderBottom: 'none' }}>
|
{ key: 'settings', label: '常规', icon: 'settings' },
|
||||||
{(['settings', 'profiles', 'plugins'] as Section[]).map(s => (
|
]}
|
||||||
<button key={s} className={`tab ${section === s ? 'active' : ''}`} onClick={() => setSection(s)}>
|
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
|
||||||
{s === 'settings' ? '设置' : s === 'profiles' ? '智能体画像' : '插件'}
|
actions={section === 'settings' && schema.length > 0 ? (
|
||||||
</button>
|
<>
|
||||||
))}
|
{saved && <span style={{ color: 'var(--ok)', fontSize: 'var(--fs-ui-sm)', marginRight: 8 }}>✓ 已保存</span>}
|
||||||
</div>
|
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
|
||||||
<span className="spacer" />
|
</>
|
||||||
{saved && <span style={{ color: 'var(--ok)', marginRight: 10, fontSize: 12.5 }}>已保存</span>}
|
) : undefined}
|
||||||
<button className="btn ghost" onClick={() => store.openSettings(false)}>关闭</button>
|
>
|
||||||
</div>
|
{section === 'settings' && (
|
||||||
<div className="modal-body">
|
<SettingsGroup title="外观">
|
||||||
{section === 'settings' && (
|
<SettingRow label="主题" desc="墨绿是可选身份皮肤。跟随系统只在深色与浅色之间切换。深色和浅色会随设置保存" effect="hot">
|
||||||
<>
|
<select value={theme} onChange={e => {
|
||||||
{Object.entries(groups).map(([g, descs]) => (
|
const next = e.target.value as ThemeChoice
|
||||||
<div className="settings-group" key={g}>
|
store.setUI({ theme: next })
|
||||||
<h4>{groupNames[g] ?? g}</h4>
|
if (next === 'dark' || next === 'light') setDraft(x => ({ ...x, 'ui.theme': next }))
|
||||||
{descs.map(d => (
|
}}>
|
||||||
<div className="setting-row" key={d.key}>
|
<option value="dark">深色</option>
|
||||||
<label title={d.key}>{d.label}</label>
|
<option value="light">浅色</option>
|
||||||
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
|
<option value="molv">墨绿</option>
|
||||||
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
|
<option value="system">跟随系统</option>
|
||||||
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
|
</select>
|
||||||
</span>
|
</SettingRow>
|
||||||
</div>
|
<SettingRow label="界面字号" desc="小、标准、大、特大。只保存在这台设备上,图标和间距不跟随缩放" effect="hot">
|
||||||
))}
|
<select value={fontSize} onChange={e => {
|
||||||
</div>
|
const next = Number(e.target.value)
|
||||||
))}
|
if (isFontSizeChoice(next)) store.setUI({ fontSize: next })
|
||||||
<button className="btn" onClick={save}>保存设置</button>
|
}}>
|
||||||
</>
|
<option value={13}>小</option>
|
||||||
)}
|
<option value={14}>标准</option>
|
||||||
{section === 'profiles' && <ProfilesSection profiles={profiles} />}
|
<option value={16}>大</option>
|
||||||
{section === 'plugins' && <PluginsSection />}
|
<option value={18}>特大</option>
|
||||||
</div>
|
</select>
|
||||||
</div>
|
</SettingRow>
|
||||||
</div>
|
</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}>
|
||||||
|
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
|
export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
|
||||||
if (desc.type === 'boolean') {
|
if (desc.type === 'boolean') {
|
||||||
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
|
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
|
||||||
}
|
}
|
||||||
@@ -111,12 +135,12 @@ function flat(m: Record<string, any>, prefix: string, out: Record<string, any>)
|
|||||||
else out[key] = v
|
else out[key] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function flatten(m: Record<string, any>): Record<string, any> {
|
export function flatten(m: Record<string, any>): Record<string, any> {
|
||||||
const out: Record<string, any> = {}
|
const out: Record<string, any> = {}
|
||||||
flat(m, '', out)
|
flat(m, '', out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
function unflatten(flatMap: Record<string, any>): Record<string, any> {
|
export function unflatten(flatMap: Record<string, any>): Record<string, any> {
|
||||||
const out: Record<string, any> = {}
|
const out: Record<string, any> = {}
|
||||||
for (const [k, v] of Object.entries(flatMap)) {
|
for (const [k, v] of Object.entries(flatMap)) {
|
||||||
if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改)
|
if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改)
|
||||||
@@ -128,103 +152,3 @@ function unflatten(flatMap: Record<string, any>): Record<string, any> {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 画像管理(数据化:导入导出 + 编辑)----
|
|
||||||
function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
|
|
||||||
const [editing, setEditing] = useState<AgentProfile | null>(null)
|
|
||||||
const [text, setText] = useState('')
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="doc-toolbar">
|
|
||||||
<button className="btn" onClick={() => { setEditing(newProfile()); setText('') }}>+ 新建画像</button>
|
|
||||||
<label className="btn ghost" style={{ cursor: 'pointer' }}>
|
|
||||||
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
|
|
||||||
const f = e.target.files?.[0]
|
|
||||||
if (!f) return
|
|
||||||
try {
|
|
||||||
const p = JSON.parse(await f.text())
|
|
||||||
p.id = p.id || 'prof_imported'
|
|
||||||
p.builtin = false
|
|
||||||
await api.saveProfile(p)
|
|
||||||
store.refreshProfiles()
|
|
||||||
store.toast('画像已导入')
|
|
||||||
} catch (err) { store.toast(`导入失败:${err}`) }
|
|
||||||
}} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{profiles.map(p => (
|
|
||||||
<div className="setting-row" key={p.id}>
|
|
||||||
<label>
|
|
||||||
{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{p.id}{p.builtin ? ' · 内置' : ''}</span>
|
|
||||||
</label>
|
|
||||||
<div style={{ color: 'var(--text-faint)', fontSize: 12 }}>{p.description}</div>
|
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
|
||||||
<button className="btn ghost" onClick={() => {
|
|
||||||
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' })
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = URL.createObjectURL(blob); a.download = `${p.id}.json`; a.click()
|
|
||||||
}}>导出</button>
|
|
||||||
{!p.builtin && <button className="btn ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</button>}
|
|
||||||
{!p.builtin && <button className="btn ghost" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</button>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{editing && (
|
|
||||||
<div className="settings-group" style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
|
||||||
<h4>编辑画像:{editing.name}</h4>
|
|
||||||
<div className="ui-form">
|
|
||||||
<label>名称<input value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} /></label>
|
|
||||||
<label>系统提示<textarea className="search-input" rows={4} style={{ height: 'auto' }} value={text} onChange={e => setText(e.target.value)} /></label>
|
|
||||||
<label>允许工具(逗号分隔,* 全部)<input value={editing.tools.allow.join(',')} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, allow: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
|
||||||
<label>执行前确认(前缀. 结尾表示族)<input value={editing.tools.confirm?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, confirm: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
|
||||||
<label>拒绝工具<input value={editing.tools.deny?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, deny: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
|
||||||
</div>
|
|
||||||
<button className="btn" onClick={async () => {
|
|
||||||
try {
|
|
||||||
await api.saveProfile({ ...editing, builtin: false, source: 'user', systemPrompt: text })
|
|
||||||
await store.refreshProfiles()
|
|
||||||
setEditing(null)
|
|
||||||
store.toast('画像已保存')
|
|
||||||
} catch (e) { store.toast(`保存失败:${e}`) }
|
|
||||||
}}>保存画像</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function newProfile(): AgentProfile {
|
|
||||||
return {
|
|
||||||
id: '', name: '新画像', systemPrompt: '', model: {}, tools: { allow: ['*'] },
|
|
||||||
context: { maxHistoryMessages: 40 }, hitl: { askOnAmbiguity: true }, budget: {},
|
|
||||||
} as AgentProfile
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 插件管理 ----
|
|
||||||
function PluginsSection() {
|
|
||||||
const [plugins, setPlugins] = useState<any[]>([])
|
|
||||||
const refresh = () => api.listPlugins().then(setPlugins).catch(() => undefined)
|
|
||||||
useEffect(() => { refresh() }, [])
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="doc-toolbar">
|
|
||||||
<button className="btn" onClick={async () => {
|
|
||||||
await api.reloadPlugins()
|
|
||||||
await refresh()
|
|
||||||
store.refreshPlugins()
|
|
||||||
store.toast('插件已热重载')
|
|
||||||
}}>⟳ 热重载</button>
|
|
||||||
<span style={{ color: 'var(--text-faint)', fontSize: 12 }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
|
|
||||||
</div>
|
|
||||||
{plugins.length === 0 && <div style={{ color: 'var(--text-faint)' }}>暂无已装载插件(目录:设置 → 插件目录;示例插件见 Dev 仓 plugins/example)</div>}
|
|
||||||
{plugins.map(p => (
|
|
||||||
<div className="setting-row" key={p.manifest.id}>
|
|
||||||
<label>{p.manifest.name} <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>v{p.manifest.version}</span></label>
|
|
||||||
<div style={{ color: 'var(--text-faint)', fontSize: 12 }}>
|
|
||||||
{[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}
|
|
||||||
</div>
|
|
||||||
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
// 左栏(找上下文):顶部切换项目,中间放会话与搜索,底部放文件、工作区和账户。
|
||||||
|
// 创建、搜索、置顶与归档仍在会话段里;领域工具由服务端工作区声明,没有时装上说明行。
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { store, useStore } from '../store'
|
||||||
|
import { auth } from '../auth'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { Icon } from '@ui'
|
||||||
|
import type { Conversation } from '../contracts'
|
||||||
|
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 }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!anchor || !ref.current) return
|
||||||
|
const r = anchor.getBoundingClientRect()
|
||||||
|
const el = ref.current
|
||||||
|
let x = r.left, y = r.bottom + 6
|
||||||
|
if (x + el.offsetWidth > window.innerWidth - 8) x = window.innerWidth - el.offsetWidth - 8
|
||||||
|
if (y + el.offsetHeight > window.innerHeight - 8) y = r.top - el.offsetHeight - 6
|
||||||
|
el.style.left = `${Math.max(8, x)}px`
|
||||||
|
el.style.top = `${y}px`
|
||||||
|
const h = (e: MouseEvent) => { if (!el.contains(e.target as Node)) onClose() }
|
||||||
|
window.addEventListener('mousedown', h)
|
||||||
|
return () => window.removeEventListener('mousedown', h)
|
||||||
|
}, [anchor])
|
||||||
|
if (!anchor) return null
|
||||||
|
return <div ref={ref} className="tsb-menu" onClick={e => e.stopPropagation()}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({ onClick, danger, icon, children }: { onClick: () => void; danger?: boolean; icon?: Parameters<typeof Icon>[0]['name']; children: React.ReactNode }) {
|
||||||
|
return <button className={`tsb-mi ${danger ? 'danger' : ''}`} onClick={onClick}>
|
||||||
|
{icon && <Icon name={icon} size={15} />}{children}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskSidebar() {
|
||||||
|
const projects = useStore(s => s.projects)
|
||||||
|
const tasks = useStore(s => s.tasks)
|
||||||
|
const me = useStore(s => s.me)
|
||||||
|
const adminSections = useStore(s => s.adminSections)
|
||||||
|
const artifacts = useStore(s => s.artifacts)
|
||||||
|
const currentConvId = useStore(s => s.currentConvId)
|
||||||
|
const currentProjectId = useStore(s => s.currentProjectId)
|
||||||
|
const showArchived = useStore(s => s.ui.showArchived)
|
||||||
|
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||||||
|
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
|
||||||
|
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
|
||||||
|
const theme = useStore(s => s.ui.theme)
|
||||||
|
const pendingBadge = useStore(s =>
|
||||||
|
s.cards.filter(c => c.status === 'action_required').length +
|
||||||
|
s.projectCards.filter(c => c.status === 'action_required').length)
|
||||||
|
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [menu, setMenu] = useState<{ kind: 'new' | 'account' | 'project' | `row:${string}`; anchor: HTMLElement } | null>(null)
|
||||||
|
const [creatingProject, setCreatingProject] = useState(false)
|
||||||
|
const [newProjectName, setNewProjectName] = useState('')
|
||||||
|
|
||||||
|
// ⌘N/Ctrl+N 新建任务(P6:创建入口唯一 + 快捷键)。
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => {
|
||||||
|
const k = e.key.toLowerCase()
|
||||||
|
if ((e.metaKey || e.ctrlKey) && k === 'n') { e.preventDefault(); store.createTask() }
|
||||||
|
if ((e.metaKey || e.ctrlKey) && k === 'b') { e.preventDefault(); store.toggleLeft() }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', h, true)
|
||||||
|
return () => window.removeEventListener('keydown', h, true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
|
||||||
|
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
|
||||||
|
const pinned = visible.filter(t => t.pinned)
|
||||||
|
const rest = visible.filter(t => !t.pinned)
|
||||||
|
const currentProject = projects.find(p => p.id === currentProjectId)
|
||||||
|
const stage = projectStage(currentProject?.settings)
|
||||||
|
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
|
||||||
|
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
|
||||||
|
|
||||||
|
const rowActions = (t: Conversation) => (
|
||||||
|
<span className="tsb-row-acts">
|
||||||
|
<button className="tsb-row-act" title="置顶 / 取消置顶" onClick={e => { e.stopPropagation(); store.pinTask(t.id, !t.pinned) }}><Icon name="pin" size={13} /></button>
|
||||||
|
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
|
||||||
|
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
|
||||||
|
</button>
|
||||||
|
<button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
|
||||||
|
onClick={e => { e.stopPropagation(); store.setCrossFlags(t.id, { crossPrivate: !t.crossPrivate }) }}>
|
||||||
|
<Icon name="shield" size={13} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 切换项目:优先恢复该项目下正在看的会话,否则进第一个未归档会话。 */
|
||||||
|
const switchProject = (pid: string) => {
|
||||||
|
const next = tasks.find(t => t.projectId === pid && t.id === currentConvId)
|
||||||
|
?? tasks.find(t => t.projectId === pid && !t.archived)
|
||||||
|
if (next) store.openTask(next.id)
|
||||||
|
else store.openProject(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskRow = (t: Conversation) => (
|
||||||
|
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
|
||||||
|
onClick={() => store.openTask(t.id)}
|
||||||
|
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
|
||||||
|
<span className="tsb-task-t" title={t.title}>{t.title || '未命名任务'}</span>
|
||||||
|
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
|
||||||
|
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
|
||||||
|
{rowActions(t)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 项目身份唯一落点:顶部切换项目(方案:项目导航置顶)。 */}
|
||||||
|
<div className="tsb-brand">
|
||||||
|
<button className="tsb-projrow" title="切换项目"
|
||||||
|
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'project' ? null : { kind: 'project', anchor: el }) }}>
|
||||||
|
<Icon name="folder" size={15} />
|
||||||
|
<span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span>
|
||||||
|
{stage && <span className="tsb-projrow-stage">{stage}</span>}
|
||||||
|
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||||||
|
</button>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 单一主创建入口(P6):新建任务 + 次级菜单(新建项目/指定项目新建) */}
|
||||||
|
<div className="tsb-actions">
|
||||||
|
<div className="tsb-newtask">
|
||||||
|
<button className="tsb-newtask-main" title="在当前项目新建任务(⌘N)" onClick={() => store.createTask()}>
|
||||||
|
<Icon name="plus" size={15} />新建任务{currentProject ? <span className="tsb-newtask-proj">{currentProject.name}</span> : null}
|
||||||
|
</button>
|
||||||
|
<button className="tsb-newtask-caret" title="更多创建选项"
|
||||||
|
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'new' ? null : { kind: 'new', anchor: el }) }}><Icon name="chevron-down" size={14} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="tsb-search">
|
||||||
|
<Icon name="search" size={14} />
|
||||||
|
<input placeholder="搜索任务…" value={q} onChange={e => setQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tsb-orgrow">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
|
||||||
|
<div className="tsb-scroll">
|
||||||
|
{creatingProject && (
|
||||||
|
<div className="tsb-newproject">
|
||||||
|
<input className="tsb-search" autoFocus value={newProjectName}
|
||||||
|
onChange={e => setNewProjectName(e.target.value)}
|
||||||
|
onKeyDown={async e => {
|
||||||
|
if (e.key === 'Enter' && newProjectName.trim()) {
|
||||||
|
await store.createProject(newProjectName.trim())
|
||||||
|
setCreatingProject(false); setNewProjectName('')
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') setCreatingProject(false)
|
||||||
|
}}
|
||||||
|
placeholder="项目名称,回车创建" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pinned.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="tsb-seclabel">置顶</div>
|
||||||
|
{pinned.map(t => taskRow(t))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rest.length === 0 && pinned.length === 0 && (
|
||||||
|
<div className="tsb-empty">这个项目还没有会话——点击「新建任务」开始</div>
|
||||||
|
)}
|
||||||
|
{rest.map(t => taskRow(t))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部(方案:文件、工作区、账户沉底)。 */}
|
||||||
|
<div className="tsb-bottom">
|
||||||
|
<button className="tsb-tool" title="文件搜索、预览、编辑与下载(第三栏「文件」入口)" onClick={() => store.openFiles()}>
|
||||||
|
<span className="tsb-tool-icon"><Icon name="folder" size={14} /></span>
|
||||||
|
<span className="tsb-tool-name">文件</span>
|
||||||
|
{artifacts.length > 0 && <span className="tsb-group-cnt">{artifacts.length}</span>}
|
||||||
|
</button>
|
||||||
|
{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) */}
|
||||||
|
<div className="tsb-footer">
|
||||||
|
<button className="tsb-user" onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'account' ? null : { kind: 'account', anchor: el }) }}>
|
||||||
|
<span className="tsb-avatar">{(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()}</span>
|
||||||
|
<span className="tsb-user-info">
|
||||||
|
<span className="tsb-user-name">{me?.name || me?.owner || '未连接'}</span>
|
||||||
|
<span className="tsb-user-sub">{me ? (me.auth ? (me.isAdmin ? '管理员 · 已认证' : '已认证') : '本地模式 · 默认租户') : '…'}</span>
|
||||||
|
</span>
|
||||||
|
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||||||
|
</button>
|
||||||
|
<button className="tsb-iconbtn" title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" size={15} /></button>
|
||||||
|
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.focusPending()}><Icon name="inbox" size={15} />{pendingBadge}</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 弹出菜单们 */}
|
||||||
|
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||||
|
{projects.map(p => {
|
||||||
|
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
||||||
|
const pstage = projectStage(p.settings)
|
||||||
|
return (
|
||||||
|
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
|
||||||
|
{p.name}
|
||||||
|
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{projects.length === 0 && <div className="tsb-tool-note">还没有项目</div>}
|
||||||
|
<div className="tsb-menu-div" />
|
||||||
|
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||||
|
<MenuItem icon="plus" onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
|
||||||
|
<div className="tsb-menu-div" />
|
||||||
|
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||||
|
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
|
||||||
|
<MenuItem key={p.id} onClick={() => { store.createTask(p.id); setMenu(null) }}>在「{p.name}」新建任务</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<Menu anchor={rowMenuAnchor} onClose={() => setMenu(null)}>
|
||||||
|
{rowTask && <>
|
||||||
|
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
|
||||||
|
<MenuItem icon={rowTask.archived ? 'undo' : 'archive'} onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
|
||||||
|
<div className="tsb-menu-div" />
|
||||||
|
<MenuItem icon="shield" onClick={() => { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'}</MenuItem>
|
||||||
|
<MenuItem icon="users" onClick={() => { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}</MenuItem>
|
||||||
|
<div className="tsb-menu-div" />
|
||||||
|
<MenuItem icon="settings" onClick={() => { if (rowTask) store.openProjectSettings(rowTask.projectId); setMenu(null) }}>项目设置</MenuItem>
|
||||||
|
</>}
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<Menu anchor={menu?.kind === 'account' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||||
|
<div className="tsb-acc-head">
|
||||||
|
<span className="tsb-avatar">{(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()}</span>
|
||||||
|
<span>
|
||||||
|
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
|
||||||
|
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="tsb-menu-div" />
|
||||||
|
<MenuItem icon="settings" onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
|
||||||
|
<MenuItem icon="inbox" onClick={() => { store.focusPending(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
|
||||||
|
<MenuItem icon="key" onClick={() => { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码…</MenuItem>
|
||||||
|
<MenuItem icon="moon" onClick={() => { store.setUI({ theme: 'dark' }); setMenu(null) }}>深色主题{theme === 'dark' ? ' · 当前' : ''}</MenuItem>
|
||||||
|
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
|
||||||
|
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
|
||||||
|
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
|
||||||
|
{(me?.isAdmin || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||||||
|
{me?.auth && <>
|
||||||
|
<div className="tsb-menu-div" />
|
||||||
|
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
|
||||||
|
</>}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// 顶栏:产品标识与待处理铃铛。项目身份在左栏顶部切换器集中显示一次(方案),
|
||||||
|
// 阶段徽章随切换器;本栏不再重复。
|
||||||
|
import { BrandMark, Icon, pendingCount } from '@ui'
|
||||||
|
import { store, useStore } from '../store'
|
||||||
|
|
||||||
|
export function TopBar() {
|
||||||
|
const pending = useStore(s => pendingCount(s.cards) + pendingCount(s.projectCards))
|
||||||
|
return (
|
||||||
|
<header className="topbar">
|
||||||
|
<BrandMark size={22} />
|
||||||
|
<span className="topbar-name">Agent-System</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+129
-17
@@ -1,39 +1,121 @@
|
|||||||
// 右栏终态(提案 T4/D25):纯收件箱 + 「当前任务」常驻卡(计划/待办并入;工件/代理/
|
// 右栏(第三栏):「工作 / 文件」双入口。
|
||||||
// 插件面板迁为工作区弹层,见 Workspaces.tsx)。
|
// 工作 = 当前任务卡 + 收件箱(待处理事项、后台任务卡、执行记录);
|
||||||
|
// 文件 = 文件列表(搜索/上传)与多标签预览(预览、编辑、下载)——预览不再是独立第四栏。
|
||||||
import { store, useStore } from '../store'
|
import { store, useStore } from '../store'
|
||||||
import type { Run } from '../contracts'
|
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, SurfaceView } from '@ui'
|
||||||
|
import { previewTabsKey } from '../previewtabs'
|
||||||
|
import { ArtifactsPanel } from './ArtifactsPanel'
|
||||||
|
import { PreviewPane } from './PreviewPane'
|
||||||
|
|
||||||
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
|
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
|
||||||
|
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
|
||||||
export const docBridge: DocBridge = {
|
export const docBridge: DocBridge = {
|
||||||
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
|
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
|
||||||
onOpenDoc: id => {
|
onOpenDoc: id => {
|
||||||
const art = store.getState().artifacts.find(a => a.id === id)
|
const art = store.getState().artifacts.find(a => a.id === id)
|
||||||
if (art) store.viewArtifact(art)
|
if (art) store.requestPreview(art)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inboxCallbacks = {
|
||||||
|
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
|
||||||
|
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
|
||||||
|
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
|
||||||
|
onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
|
||||||
|
onArchive: (c: ActivityCard) => store.cardToProject(c),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */
|
||||||
|
function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxFilter }) {
|
||||||
|
const own = cards.filter(c => !c.conversationId)
|
||||||
|
const cross = cards.filter(c => !!c.conversationId)
|
||||||
|
if (cards.length === 0) {
|
||||||
|
return <div className="inbox-empty">项目收件箱为空——「留档到项目」的产物卡与其他会话的待办会聚合在这里。</div>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="inbox-zone-label">📁 项目级卡片(跨会话可见)</div>
|
||||||
|
{own.length > 0
|
||||||
|
? <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} filter={filter} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Workbench() {
|
export function Workbench() {
|
||||||
|
const rightTab = useStore(s => s.ui.rightTab)
|
||||||
const cards = useStore(s => s.cards)
|
const cards = useStore(s => s.cards)
|
||||||
const plan = useStore(s => s.plan)
|
const plan = useStore(s => s.plan)
|
||||||
const todos = useStore(s => s.todos)
|
const todos = useStore(s => s.todos)
|
||||||
const pending = pendingCount(cards)
|
const inboxScope = useStore(s => s.inboxScope)
|
||||||
|
const projectCards = useStore(s => s.projectCards)
|
||||||
|
const filter = useStore(s => s.ui.inboxFilter)
|
||||||
|
const visible = inboxScope === 'project' ? projectCards : cards
|
||||||
|
const pending = pendingCount(visible)
|
||||||
|
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
|
||||||
|
const switchTab = (tab: 'work' | 'files') =>
|
||||||
|
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="tabs">
|
<div className="tabs">
|
||||||
<span className="tab active">收件箱{pending > 0 ? ` ${pending}` : ''}</span>
|
<button className={`tab ${rightTab === 'work' ? 'active' : ''}`} onClick={() => switchTab('work')}
|
||||||
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}>🧹 收纳</button>
|
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}</button>
|
||||||
<button className="tab" title="收起收件箱" onClick={store.toggleRight}>»</button>
|
<button className={`tab ${rightTab === 'files' ? 'active' : ''}`} onClick={() => switchTab('files')}
|
||||||
|
title="文件搜索、预览、编辑与下载">文件</button>
|
||||||
|
<span className="spacer" style={{ flex: 1 }} />
|
||||||
|
{rightTab === 'work' && (
|
||||||
|
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
|
||||||
|
)}
|
||||||
|
<button className="tab" title="收起右栏" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="tab-body">
|
{rightTab === 'files'
|
||||||
|
? <FilesTab />
|
||||||
|
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件入口:有预览标签且处于预览子视图时展示预览;否则展示文件列表(搜索/上传)。 */
|
||||||
|
function FilesTab() {
|
||||||
|
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
|
||||||
|
const filesView = useStore(s => s.ui.filesView)
|
||||||
|
if (previewOpen && filesView === 'preview') return <PreviewPane />
|
||||||
|
return <div className="tab-body"><ArtifactsPanel /></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
||||||
|
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
|
||||||
|
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
|
||||||
|
filter: InboxFilter
|
||||||
|
inboxScope: 'conv' | 'project'
|
||||||
|
projectCards: ActivityCard[]
|
||||||
|
cards: ActivityCard[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="tab-body" data-scroll-sync="inbox">
|
||||||
<CurrentTaskCard plan={plan} todos={todos} />
|
<CurrentTaskCard plan={plan} todos={todos} />
|
||||||
<InboxList cards={cards} callbacks={{
|
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
|
||||||
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
|
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
|
||||||
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
|
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
|
||||||
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
|
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
|
||||||
onLocate: (c: ActivityCard) => store.openInbox(c.id),
|
</button>
|
||||||
}} />
|
<span style={{ flex: 1 }} />
|
||||||
|
<button className={filter === 'todo' ? 'active' : ''} title="只看待处理" onClick={() => store.setUI({ inboxFilter: 'todo' })}>待处理</button>
|
||||||
|
<button className={filter === 'all' ? 'active' : ''} title="全部卡片" onClick={() => store.setUI({ inboxFilter: 'all' })}>全部</button>
|
||||||
|
</div>
|
||||||
|
{inboxScope === 'project'
|
||||||
|
? <ProjectInbox cards={projectCards} filter={filter} />
|
||||||
|
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
|
||||||
|
<WorkspaceSurfaces />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -87,7 +169,37 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
|
|||||||
<div className="agent-row" style={{ paddingLeft: 8 + depth * 18 }} onClick={() => store.openTracker(run.id)}>
|
<div className="agent-row" style={{ paddingLeft: 8 + depth * 18 }} onClick={() => store.openTracker(run.id)}>
|
||||||
<span className={`status-dot status-${run.status}`} />
|
<span className={`status-dot status-${run.status}`} />
|
||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{run.status}</span>
|
<span style={{ fontSize: 'var(--fs-ui-xs)', color: 'var(--text-faint)' }}>{run.status}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// U6 工作区任务页容器:会话内页面在"工作"入口的常驻紧凑视图——默认显示最新
|
||||||
|
// 一页,固定(pin)后不随新页面抢占;关闭插件后此处自然消失(surfaces 为空),
|
||||||
|
// 收件箱/任务卡等原有内容不受影响(禁用回退完整性)。点击标题去文件预览全屏。
|
||||||
|
function WorkspaceSurfaces() {
|
||||||
|
const surfaces = useStore(s => s.surfaces)
|
||||||
|
const pin = useStore(s => s.ui.workspaceSurfacePin)
|
||||||
|
if (surfaces.length === 0) return null
|
||||||
|
const shown = pin ? surfaces.find(x => String(x.identity.surfaceId) === pin) : surfaces[surfaces.length - 1]
|
||||||
|
if (!shown) return null
|
||||||
|
return (
|
||||||
|
<div className="workspace-surfaces" data-workspace-surfaces>
|
||||||
|
<div className="ws-head">
|
||||||
|
<span className="ws-title">任务页面</span>
|
||||||
|
<span className="ws-meta">{shown.spec?.title || '(未命名)'} · 修订 {shown.revision}</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button className="icon-btn" title={pin ? '取消固定(跟随最新)' : '固定此页(新页面不抢占)'}
|
||||||
|
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : String(shown.identity.surfaceId) })}>
|
||||||
|
{pin ? '📍' : '📌'}
|
||||||
|
</button>
|
||||||
|
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(String(shown.identity.surfaceId))}>
|
||||||
|
↗
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ws-body">
|
||||||
|
<SurfaceView snapshot={shown} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
// 工作区弹层(提案 T4/D25):工件/代理/插件面板/插件声明工作区统一经 OverlayHost 呈现;
|
// 工作区弹层(提案 T4/D25):深读经 OverlayHost 单层呈现。
|
||||||
// ⌘1–9 快捷键与左栏快捷区共享同一清单(workspaceEntries)。
|
// 服务端声明的工作区(领域插件槽位)对所有用户可见;插件管理面板仍只在系统管理区。
|
||||||
|
// 文件浏览已并入第三栏「文件」入口,本弹层只承载代理与领域工作区。
|
||||||
import { store, useStore } from '../store'
|
import { store, useStore } from '../store'
|
||||||
import type { Run } from '../contracts'
|
import type { Run } from '../contracts'
|
||||||
import { OverlayHost, type DocBridge } from '@ui'
|
import { OverlayHost, type DocBridge } from '@ui'
|
||||||
import { docBridge } from './Workbench'
|
import { docBridge } from './Workbench'
|
||||||
import { AgentRow } from './Workbench'
|
import { AgentRow } from './Workbench'
|
||||||
|
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
|
||||||
|
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||||
|
|
||||||
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区随后。 */
|
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。
|
||||||
export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] {
|
* 追溯审计按命令面能力显隐(增量 2):store.rdmAuditAvailable 为 true 才入列。 */
|
||||||
|
export function workspaceEntries(): WorkspaceEntry[] {
|
||||||
const s = store.getState()
|
const s = store.getState()
|
||||||
const out = [
|
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable })
|
||||||
{ key: 'artifacts', title: '工件', icon: '📦', shortcut: '⌘1' },
|
|
||||||
{ key: 'agents', title: '代理', icon: '🤖', shortcut: '⌘2' },
|
|
||||||
...s.pluginPanels.map((p, i) => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', shortcut: `⌘${i + 3}` })),
|
|
||||||
...s.serverWorkspaces.map((w, i) => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', shortcut: w.shortcut ? `⌘${w.shortcut}` : `⌘${i + 3 + s.pluginPanels.length}` })),
|
|
||||||
]
|
|
||||||
return out.slice(0, 9)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Workspaces() {
|
export function Workspaces() {
|
||||||
const active = useStore(s => s.ui.activeWorkspace)
|
const active = useStore(s => s.ui.activeWorkspace)
|
||||||
const artifacts = useStore(s => s.artifacts)
|
|
||||||
const runs = useStore(s => s.runs)
|
const runs = useStore(s => s.runs)
|
||||||
const pluginPanels = useStore(s => s.pluginPanels)
|
const pluginPanels = useStore(s => s.pluginPanels)
|
||||||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||||||
@@ -28,17 +25,17 @@ export function Workspaces() {
|
|||||||
const entry = workspaceEntries().find(e => e.key === active)
|
const entry = workspaceEntries().find(e => e.key === active)
|
||||||
const close = () => store.closeWorkspace()
|
const close = () => store.closeWorkspace()
|
||||||
|
|
||||||
if (active === 'artifacts') {
|
|
||||||
return <OverlayHost title="工件" icon="📦" shortcut="⌘1" badge={artifacts.length || undefined} onClose={close}>
|
|
||||||
<ArtifactsPanel />
|
|
||||||
</OverlayHost>
|
|
||||||
}
|
|
||||||
if (active === 'agents') {
|
if (active === 'agents') {
|
||||||
const convRuns = runs.filter(r => r.kind !== 'tool')
|
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} />
|
<AgentsPanel runs={convRuns} />
|
||||||
</OverlayHost>
|
</OverlayHost>
|
||||||
}
|
}
|
||||||
|
if (active === 'rdm-audit') {
|
||||||
|
return <OverlayHost title="追溯审计" icon="🔍" shortcut={entry?.shortcut} sayHint="打开追溯审计" onClose={close}>
|
||||||
|
<RdmAuditWorkspace />
|
||||||
|
</OverlayHost>
|
||||||
|
}
|
||||||
if (active.startsWith('plugin:')) {
|
if (active.startsWith('plugin:')) {
|
||||||
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
|
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
|
||||||
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
|
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
|
||||||
@@ -46,25 +43,13 @@ export function Workspaces() {
|
|||||||
}
|
}
|
||||||
if (active.startsWith('ws:')) {
|
if (active.startsWith('ws:')) {
|
||||||
const ws = serverWorkspaces.find(w => `ws:${w.id}` === active)
|
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()} />
|
nodes={ws?.body ?? []} docBridge={docBridge as DocBridge} onRefresh={() => store.refreshPlugins()} />
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 工件面板(自 Workbench tabs 迁入;点击进 DocViewer 深读)。 */
|
|
||||||
export function ArtifactsPanel() {
|
|
||||||
const artifacts = useStore(s => s.artifacts)
|
|
||||||
if (artifacts.length === 0) return <div className="empty-note">暂无工件——上传附件或让模型产出文档(mock:#doc)</div>
|
|
||||||
return <>{artifacts.map(a => (
|
|
||||||
<div key={a.id} className="artifact-row" onClick={() => store.viewArtifact(a)}>
|
|
||||||
<span>{a.kind === 'image' ? '🖼' : a.kind === 'document' ? '📄' : '🗂'}</span>
|
|
||||||
<span className="name" title={a.name}>{a.name}</span>
|
|
||||||
<span className="kind">{a.kind}</span>
|
|
||||||
</div>
|
|
||||||
))}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 代理面板(任务树;点击进 AgentTracker 深读)。 */
|
/** 代理面板(任务树;点击进 AgentTracker 深读)。 */
|
||||||
export function AgentsPanel({ runs }: { runs: Run[] }) {
|
export function AgentsPanel({ runs }: { runs: Run[] }) {
|
||||||
if (runs.length === 0) return <div className="empty-note">暂无运行——多代理任务会在此形成任务树(mock:#agents)</div>
|
if (runs.length === 0) return <div className="empty-note">暂无运行——多代理任务会在此形成任务树(mock:#agents)</div>
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
|
||||||
|
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
|
||||||
|
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { store, useStore } from '../store'
|
||||||
|
import type { AgentProfile } from '../contracts'
|
||||||
|
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
|
||||||
|
import { docBridge } from './Workbench'
|
||||||
|
import { randomPassword } from '../passwords'
|
||||||
|
|
||||||
|
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制)----
|
||||||
|
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
|
||||||
|
|
||||||
|
export function UsersPanel() {
|
||||||
|
const me = useStore(s => s.me)
|
||||||
|
const [users, setUsers] = useState<AdminUserRow[] | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [owner, setOwner] = useState('')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [pw, setPw] = useState('')
|
||||||
|
const [asAdmin, setAsAdmin] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||||
|
const [resetPw, setResetPw] = useState('')
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
|
||||||
|
}
|
||||||
|
useEffect(() => { refresh() }, [])
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
setErr(''); setNotice('')
|
||||||
|
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
|
||||||
|
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
|
||||||
|
setOwner(''); setName(''); setPw('')
|
||||||
|
await refresh()
|
||||||
|
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = async () => {
|
||||||
|
if (!resetFor) return
|
||||||
|
setErr('')
|
||||||
|
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.resetAdminUserPassword(resetFor, resetPw)
|
||||||
|
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
|
||||||
|
setResetFor(null); setResetPw('')
|
||||||
|
await refresh()
|
||||||
|
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{me?.isAdmin && (
|
||||||
|
<div className="ad-create">
|
||||||
|
<div className="ad-row">
|
||||||
|
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
|
||||||
|
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="ad-row">
|
||||||
|
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
|
||||||
|
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
|
||||||
|
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
|
||||||
|
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
|
||||||
|
</div>
|
||||||
|
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||||
|
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||||
|
{resetFor ? (
|
||||||
|
<div className="ad-reset">
|
||||||
|
<span>重置 <b>{resetFor}</b> 的密码:</span>
|
||||||
|
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
|
||||||
|
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
|
||||||
|
<button className="lg-submit ad-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
|
||||||
|
<button type="button" className="ad-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="ad-table">
|
||||||
|
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{(users ?? []).map(u => (
|
||||||
|
<tr key={u.owner}>
|
||||||
|
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||||
|
<td>{u.name || '—'}</td>
|
||||||
|
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||||
|
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||||
|
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||||
|
<td>{me?.isAdmin && (
|
||||||
|
<button className="ad-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
|
||||||
|
)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 智能体画像(原 SettingsModal ProfilesSection:数据化导入导出 + 编辑)----
|
||||||
|
export function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
|
||||||
|
const [editing, setEditing] = useState<AgentProfile | null>(null)
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="doc-toolbar">
|
||||||
|
<Btn variant="primary" icon="plus" onClick={() => { setEditing(newProfile()); setText('') }}>新建画像</Btn>
|
||||||
|
<label className="btn ghost" style={{ cursor: 'pointer' }}>
|
||||||
|
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
|
||||||
|
const f = e.target.files?.[0]
|
||||||
|
if (!f) return
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(await f.text())
|
||||||
|
p.id = p.id || 'prof_imported'
|
||||||
|
p.builtin = false
|
||||||
|
await api.saveProfile(p)
|
||||||
|
store.refreshProfiles()
|
||||||
|
store.toast('画像已导入')
|
||||||
|
} catch (err) { store.toast(`导入失败:${err}`) }
|
||||||
|
}} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{profiles.map(p => (
|
||||||
|
<SettingRow key={p.id}
|
||||||
|
label={<>{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-xs)' }}>{p.id}{p.builtin ? ' · 内置' : ''}</span></>}
|
||||||
|
desc={p.description}>
|
||||||
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||||
|
<Btn variant="ghost" icon="download" onClick={() => {
|
||||||
|
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' })
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = URL.createObjectURL(blob); a.download = `${p.id}.json`; a.click()
|
||||||
|
}}>导出</Btn>
|
||||||
|
{!p.builtin && <Btn variant="ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</Btn>}
|
||||||
|
{!p.builtin && <Btn variant="ghost" icon="trash" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</Btn>}
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
))}
|
||||||
|
{editing && (
|
||||||
|
<SettingsGroup title={`编辑画像:${editing.name}`}>
|
||||||
|
<div className="ui-form">
|
||||||
|
<label>名称<input value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} /></label>
|
||||||
|
<label>系统提示<textarea className="search-input" rows={4} style={{ height: 'auto' }} value={text} onChange={e => setText(e.target.value)} /></label>
|
||||||
|
<label>允许工具(逗号分隔,* 全部)<input value={editing.tools.allow.join(',')} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, allow: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||||||
|
<label>执行前确认(前缀. 结尾表示族)<input value={editing.tools.confirm?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, confirm: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||||||
|
<label>拒绝工具<input value={editing.tools.deny?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, deny: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||||||
|
</div>
|
||||||
|
<Btn variant="primary" icon="check" onClick={async () => {
|
||||||
|
try {
|
||||||
|
await api.saveProfile({ ...editing, builtin: false, source: 'user', systemPrompt: text })
|
||||||
|
await store.refreshProfiles()
|
||||||
|
setEditing(null)
|
||||||
|
store.toast('画像已保存')
|
||||||
|
} catch (e) { store.toast(`保存失败:${e}`) }
|
||||||
|
}}>保存画像</Btn>
|
||||||
|
</SettingsGroup>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function newProfile(): AgentProfile {
|
||||||
|
return {
|
||||||
|
id: '', name: '新画像', systemPrompt: '', model: {}, tools: { allow: ['*'] },
|
||||||
|
context: { maxHistoryMessages: 40 }, hitl: { askOnAmbiguity: true }, budget: {},
|
||||||
|
} as AgentProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 插件管理(原 SettingsModal PluginsSection:清单 + 热重载)----
|
||||||
|
export function PluginsSection() {
|
||||||
|
const [plugins, setPlugins] = useState<any[]>([])
|
||||||
|
const refresh = () => api.listPlugins().then(setPlugins).catch(() => undefined)
|
||||||
|
useEffect(() => { refresh() }, [])
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="doc-toolbar">
|
||||||
|
<Btn variant="ghost" icon="undo" onClick={async () => {
|
||||||
|
await api.reloadPlugins()
|
||||||
|
await refresh()
|
||||||
|
store.refreshPlugins()
|
||||||
|
store.toast('插件已热重载')
|
||||||
|
}}>热重载</Btn>
|
||||||
|
<span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-sm)' }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
|
||||||
|
</div>
|
||||||
|
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
|
||||||
|
{plugins.map(p => (
|
||||||
|
<SettingRow key={p.manifest.id}
|
||||||
|
label={p.manifest.name + (p.manifest.version ? ` v${p.manifest.version}` : '')}
|
||||||
|
desc={[
|
||||||
|
`工具 ${p.manifest.provides.tools?.length ?? 0}`,
|
||||||
|
`命令 ${p.manifest.provides.commands?.length ?? 0}`,
|
||||||
|
`面板 ${p.panels?.length ?? 0}`,
|
||||||
|
`意图 ${p.intents?.length ?? p.manifest.provides.intents?.length ?? 0}`,
|
||||||
|
`工作流 ${p.workflows?.length ?? 0}`,
|
||||||
|
].join(' · ')}>
|
||||||
|
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
{p.manifest.backend?.mode === 'inproc' && <span className="effect-badge">内置</span>}
|
||||||
|
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
||||||
|
</span>
|
||||||
|
</SettingRow>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 插件工作区(插件声明面板 + 服务端声明工作区;声明体经 UIRenderer 内联渲染)----
|
||||||
|
export function PluginWorkspacesPanel() {
|
||||||
|
const pluginPanels = useStore(s => s.pluginPanels)
|
||||||
|
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||||||
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const entries = [
|
||||||
|
...pluginPanels.map(p => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', body: p.body ?? [] })),
|
||||||
|
...serverWorkspaces.map(w => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', body: (w.body ?? []) as any[] })),
|
||||||
|
]
|
||||||
|
const current = entries.find(e => e.key === selected) ?? entries[0]
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return <EmptyState icon="grid">暂无插件声明工作区<br />(插件可在 manifest 里声明 panels / workspaces)</EmptyState>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="doc-toolbar" style={{ flexWrap: 'wrap' }}>
|
||||||
|
{entries.map(e => (
|
||||||
|
<button key={e.key} className={`kind-pill ${current?.key === e.key ? 'on' : ''}`} onClick={() => setSelected(e.key)}>
|
||||||
|
{e.icon} {e.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Btn variant="ghost" icon="undo" onClick={() => store.refreshPlugins()}>刷新</Btn>
|
||||||
|
</div>
|
||||||
|
{current && <UIRenderer nodes={current.body} docBridge={docBridge as DocBridge} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+144
-7
@@ -14,6 +14,10 @@ export interface Project {
|
|||||||
|
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
id: ID; projectId: ID; owner?: ID; title: string; profileId?: ID;
|
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;
|
createdAt: string; updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +25,7 @@ export type Role = 'user' | 'assistant' | 'system';
|
|||||||
|
|
||||||
export type PartType =
|
export type PartType =
|
||||||
| 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'artifact_ref'
|
| 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'artifact_ref'
|
||||||
| 'hitl' | 'plan' | 'error' | 'card';
|
| 'hitl' | 'plan' | 'error' | 'card' | 'check';
|
||||||
|
|
||||||
export interface Part {
|
export interface Part {
|
||||||
type: PartType;
|
type: PartType;
|
||||||
@@ -31,25 +35,37 @@ export interface Part {
|
|||||||
artifact?: { artifactId: ID; name: string; kind?: string };
|
artifact?: { artifactId: ID; name: string; kind?: string };
|
||||||
hitl?: { cardId: ID; question: string; resolved: boolean; answer?: string };
|
hitl?: { cardId: ID; question: string; resolved: boolean; answer?: string };
|
||||||
plan?: PlanSnapshot;
|
plan?: PlanSnapshot;
|
||||||
card?: { kind: string; data: UINode[] };
|
card?: { kind: string; data: UINode[]; cardId?: ID }; // cardId 非空 = 收件箱卡片引用部件(submit_card)
|
||||||
error?: { code: string; message: string };
|
error?: { code: string; message: string };
|
||||||
|
check?: { passed: boolean; reason?: string }; // 终轮自动检查,过程区一行
|
||||||
process?: boolean; // run 终态后折叠为摘要行
|
process?: boolean; // run 终态后折叠为摘要行
|
||||||
|
durationMs?: number; // thinking 部件的思考时长(毫秒);工具耗时在 toolCall.durationMs
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: ID; conversationId: ID; runId?: ID; role: Role;
|
id: ID; conversationId: ID; runId?: ID; role: Role;
|
||||||
parts: Part[]; createdAt: string;
|
parts: Part[]; createdAt: string;
|
||||||
|
excluded?: boolean;
|
||||||
|
variantGroup?: string;
|
||||||
|
variantIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunKind = 'conversation' | 'subagent' | 'tool';
|
export type RunKind = 'conversation' | 'subagent' | 'tool';
|
||||||
export type RunStatus =
|
export type RunStatus =
|
||||||
| 'pending' | 'running' | 'waiting_hitl' | 'completed'
|
| 'pending' | 'running' | 'waiting_hitl' | 'completed'
|
||||||
| 'failed' | 'cancelled' | 'interrupted';
|
| 'failed' | 'cancelled' | 'interrupted';
|
||||||
export type RunPhase = 'thinking' | 'acting' | 'responding' | 'done';
|
export type RunPhase = 'thinking' | 'acting' | 'responding' | 'finalizing' | 'done';
|
||||||
|
|
||||||
export interface Usage {
|
export interface Usage {
|
||||||
promptTokens: number; completionTokens: number; totalTokens: number;
|
promptTokens: number; completionTokens: number; totalTokens: number;
|
||||||
durationMs: number; toolCalls: number;
|
durationMs: number; toolCalls: number;
|
||||||
|
model?: string;
|
||||||
|
// N3 计费拆分(additive;旧记录缺省为 0):
|
||||||
|
// Last* 是最近一次请求的用量,Cache* 是缓存命中累计。
|
||||||
|
lastPromptTokens?: number;
|
||||||
|
lastCompletionTokens?: number;
|
||||||
|
cacheReadTokens?: number;
|
||||||
|
cacheWriteTokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Run {
|
export interface Run {
|
||||||
@@ -77,12 +93,14 @@ export interface TodoList {
|
|||||||
// ---- 流式事件 ----
|
// ---- 流式事件 ----
|
||||||
|
|
||||||
export type StreamEventType =
|
export type StreamEventType =
|
||||||
| 'message.delta' | 'thinking.delta' | 'tool.call' | 'tool.result'
|
| 'message.delta' | 'thinking.delta'
|
||||||
| 'run.phase' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update'
|
| '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'
|
| 'agent.spawn' | 'agent.progress' | 'agent.result'
|
||||||
| 'artifact.created' | 'artifact.updated'
|
| 'artifact.created' | 'artifact.updated'
|
||||||
| 'run.completed' | 'run.failed' | 'error'
|
| 'run.completed' | 'run.failed' | 'error'
|
||||||
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved';
|
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved'
|
||||||
|
| 'surface.updated';
|
||||||
|
|
||||||
export interface StreamEvent<P = unknown> {
|
export interface StreamEvent<P = unknown> {
|
||||||
seq: number; runId?: ID; conversationId: ID; ts: string; v: number;
|
seq: number; runId?: ID; conversationId: ID; ts: string; v: number;
|
||||||
@@ -91,6 +109,9 @@ export interface StreamEvent<P = unknown> {
|
|||||||
|
|
||||||
// payload 形状(按 type 判别;未知字段忽略)
|
// payload 形状(按 type 判别;未知字段忽略)
|
||||||
export interface MessageDeltaPayload { index: number; text: string }
|
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 AgentSpawnPayload { agentRun: ID; name: string; task: string }
|
||||||
export interface AgentProgressPayload { agentRun: ID; note?: string; phase?: RunPhase }
|
export interface AgentProgressPayload { agentRun: ID; note?: string; phase?: RunPhase }
|
||||||
export interface AgentResultPayload { agentRun: ID; status: RunStatus; summary?: string }
|
export interface AgentResultPayload { agentRun: ID; status: RunStatus; summary?: string }
|
||||||
@@ -100,11 +121,19 @@ export interface RunCompletedPayload {
|
|||||||
context?: ContextObservation
|
context?: ContextObservation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ContextSection {
|
||||||
|
name: string
|
||||||
|
estTokens: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContextObservation {
|
export interface ContextObservation {
|
||||||
items: string[]
|
items: string[]
|
||||||
truncations?: string[]
|
truncations?: string[]
|
||||||
estChars: number
|
estChars: number
|
||||||
|
sections?: ContextSection[]
|
||||||
|
prefixHash?: string
|
||||||
}
|
}
|
||||||
|
export interface RunCheckPayload { passed: boolean; reason?: string }
|
||||||
export interface RunFailedPayload { code: string; message: string }
|
export interface RunFailedPayload { code: string; message: string }
|
||||||
|
|
||||||
// ---- 收件箱卡片 ----
|
// ---- 收件箱卡片 ----
|
||||||
@@ -142,6 +171,7 @@ export interface HITLCard {
|
|||||||
choices?: HITLChoice[]; allowFreeInput: boolean; freeInputHint?: string;
|
choices?: HITLChoice[]; allowFreeInput: boolean; freeInputHint?: string;
|
||||||
timeoutSec?: number | null; form?: UINode[]; createdAt: string; resolved: boolean;
|
timeoutSec?: number | null; form?: UINode[]; createdAt: string; resolved: boolean;
|
||||||
anchorMessageId?: ID; blocked?: boolean; blockReason?: string;
|
anchorMessageId?: ID; blocked?: boolean; blockReason?: string;
|
||||||
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HITLResponse {
|
export interface HITLResponse {
|
||||||
@@ -152,10 +182,13 @@ export interface HITLResponse {
|
|||||||
|
|
||||||
export type UINodeType =
|
export type UINodeType =
|
||||||
| 'section' | 'list' | 'kv' | 'form' | 'markdown' | 'doc-ref' | 'actions' | 'chart-lite'
|
| 'section' | 'list' | 'kv' | 'form' | 'markdown' | 'doc-ref' | 'actions' | 'chart-lite'
|
||||||
| 'progress' | 'compare' | 'tree' | 'table' | 'steps' | 'tabs' | 'timeline';
|
| 'avatar' | 'progress' | 'compare' | 'tree' | 'table' | 'steps' | 'tabs' | 'timeline';
|
||||||
|
|
||||||
export interface UINode {
|
export interface UINode {
|
||||||
type: UINodeType;
|
type: UINodeType;
|
||||||
|
/** Surface 层节点级标注(Generative UI U2):整页唯一稳定 id 与事实来源标识,旧路径不填。 */
|
||||||
|
id?: string;
|
||||||
|
source?: string[];
|
||||||
title?: string;
|
title?: string;
|
||||||
items?: string[];
|
items?: string[];
|
||||||
kv?: [string, string][];
|
kv?: [string, string][];
|
||||||
@@ -166,6 +199,7 @@ export interface UINode {
|
|||||||
chart?: { kind: 'bar' | 'line'; labels: string[]; series: number[] };
|
chart?: { kind: 'bar' | 'line'; labels: string[]; series: number[] };
|
||||||
children?: UINode[];
|
children?: UINode[];
|
||||||
|
|
||||||
|
avatar?: { name?: string; sub?: string; tone?: string; stack?: { name?: string; sub?: string; tone?: string }[] };
|
||||||
progress?: { label?: string; value: number; tone?: string };
|
progress?: { label?: string; value: number; tone?: string };
|
||||||
compare?: {
|
compare?: {
|
||||||
left: { title?: string; tone?: string; nodes?: UINode[] };
|
left: { title?: string; tone?: string; nodes?: UINode[] };
|
||||||
@@ -192,11 +226,34 @@ export interface UIField {
|
|||||||
options?: string[]; required?: boolean; placeholder?: string; help?: string; default?: string;
|
options?: string[]; required?: boolean; placeholder?: string; help?: string; default?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 提示词库(prompt-library)----
|
||||||
|
|
||||||
|
export interface PromptTrigger {
|
||||||
|
kind: 'intent' | 'command' | 'attachment' | 'manual';
|
||||||
|
keywords?: string[]; command?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptModule {
|
||||||
|
id: string; layer: 'base' | 'env' | 'task' | 'agent' | 'tool' | 'aux' | 'style';
|
||||||
|
version: number; title: string; description?: string; priority?: number;
|
||||||
|
triggers?: PromptTrigger[]; requiresAttachments?: boolean;
|
||||||
|
body: string; outputContract?: string;
|
||||||
|
source?: 'builtin' | 'plugin' | 'user'; owner?: string;
|
||||||
|
basedOnVersion?: number; drifted?: boolean; disabled?: boolean;
|
||||||
|
/** 子代理 profile。只对 agent.sub.* 有意义;空字段表示沿用内置。 */
|
||||||
|
tools?: string[]; model?: string; maxTurns?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptBlock { moduleId: string; version: number; layer: string; body: string; }
|
||||||
|
export interface PromptHitRecord { moduleId: string; layer: string; reason: string; injected: boolean; }
|
||||||
|
|
||||||
// ---- 画像 / 设置 ----
|
// ---- 画像 / 设置 ----
|
||||||
|
|
||||||
export interface AgentProfile {
|
export interface AgentProfile {
|
||||||
id: string; owner?: string; name: string; description?: string; builtin?: boolean; source?: string;
|
id: string; owner?: string; name: string; description?: string; builtin?: boolean; source?: string;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
|
/** 提示词库模块引用(有序;空 = 默认 base+env 全集)。 */
|
||||||
|
promptModules?: string[];
|
||||||
model: { chat?: string; lightweight?: string; embedding?: string; rerank?: string };
|
model: { chat?: string; lightweight?: string; embedding?: string; rerank?: string };
|
||||||
tools: { allow: string[]; deny?: string[]; confirm?: string[] };
|
tools: { allow: string[]; deny?: string[]; confirm?: string[] };
|
||||||
context: {
|
context: {
|
||||||
@@ -224,18 +281,56 @@ export interface PluginManifest {
|
|||||||
tools?: string[]; workflows?: string[]; intents?: string[];
|
tools?: string[]; workflows?: string[]; intents?: string[];
|
||||||
prompts?: string[]; panels?: string[]; cards?: string[]; settings?: string[];
|
prompts?: string[]; panels?: string[]; cards?: string[]; settings?: string[];
|
||||||
commands?: string[]; workspaces?: string[];
|
commands?: string[]; workspaces?: string[];
|
||||||
|
skills?: string[]; mcpServers?: string[]; knowledge?: string[];
|
||||||
|
parsers?: string[]; agents?: string[]; channels?: string[]; pages?: string[];
|
||||||
};
|
};
|
||||||
|
scope?: { tenants?: string[]; projects?: string[] };
|
||||||
|
backend?: { mode?: 'inproc' | 'sidecar'; command?: string; args?: string[]; schemaVersion?: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginPanel {
|
export interface PluginPanel {
|
||||||
id: string; title: string; icon?: string; body: UINode[]; refreshTool?: string;
|
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 {
|
export interface PluginWorkspace {
|
||||||
id: string; title: string; icon?: string; shortcut?: string;
|
id: string; title: string; icon?: string; shortcut?: string;
|
||||||
badgeTool?: string; body: UINode[]; refreshTool?: string;
|
badgeTool?: string; body: UINode[]; refreshTool?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginPageDecl {
|
||||||
|
id: string; title: string; module: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 摄取 / 检索 ----
|
// ---- 摄取 / 检索 ----
|
||||||
|
|
||||||
export interface ParsedDocument {
|
export interface ParsedDocument {
|
||||||
@@ -261,3 +356,45 @@ export interface UploadInfo {
|
|||||||
artifactId: ID; filename: string; mime?: string; size: number;
|
artifactId: ID; filename: string; mime?: string; size: number;
|
||||||
parsed?: ParsedDocument;
|
parsed?: ParsedDocument;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Generative UI Surface(提案 U2/U4;与 Go surface.go 同步)----
|
||||||
|
|
||||||
|
export interface SurfaceCompat {
|
||||||
|
schemaVersion: number;
|
||||||
|
catalogVersion: string;
|
||||||
|
pluginVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SurfaceIdentity {
|
||||||
|
surfaceId: ID;
|
||||||
|
owner: string;
|
||||||
|
projectId?: ID;
|
||||||
|
conversationId?: ID;
|
||||||
|
sourceRunId?: ID;
|
||||||
|
pluginId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SurfaceSpec {
|
||||||
|
title: string;
|
||||||
|
nodes: UINode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SurfaceSnapshot {
|
||||||
|
identity: SurfaceIdentity;
|
||||||
|
compat: SurfaceCompat;
|
||||||
|
revision: number;
|
||||||
|
baseRevision?: number;
|
||||||
|
contextVersion?: string;
|
||||||
|
activationEpoch: number;
|
||||||
|
spec: SurfaceSpec;
|
||||||
|
semanticText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SurfaceMeta {
|
||||||
|
surfaceId: ID;
|
||||||
|
title: string;
|
||||||
|
pluginId: string;
|
||||||
|
revision: number;
|
||||||
|
conversationId?: ID;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
// live 流式投影纯逻辑(中栏过程/回复分离):事件按到达序并入 items——thinking/text
|
||||||
|
// 增量各并入其末段,工具按当前位置落位。SSE 有序且后端每轮按 思考→正文→工具 追加落库
|
||||||
|
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
|
||||||
|
// 文本段是否为过程(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
|
||||||
|
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。
|
||||||
|
export function applyThinkingDelta(items: LiveItem[], delta: string, now = Date.now()): LiveItem[] {
|
||||||
|
const last = items[items.length - 1]
|
||||||
|
if (last && last.kind === 'thinking') {
|
||||||
|
const out = items.slice()
|
||||||
|
out[out.length - 1] = { kind: 'thinking', text: last.text + delta, startedAt: last.startedAt, endedAt: now }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return [...items, { kind: 'thinking', text: delta, startedAt: now, endedAt: now }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyTextDelta 并入正文增量:末段是同 index 的文本段则追加,否则开启新文本段。
|
||||||
|
export function applyTextDelta(items: LiveItem[], index: number, delta: string): LiveItem[] {
|
||||||
|
const last = items[items.length - 1]
|
||||||
|
if (last && last.kind === 'text' && last.index === index) {
|
||||||
|
const out = items.slice()
|
||||||
|
out[out.length - 1] = { kind: 'text', index, text: last.text + delta }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return [...items, { kind: 'text', index, text: delta }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (!isTool(it) || it.state !== 'composing') continue
|
||||||
|
if (it.index !== index && !(callId && it.callId === callId)) continue
|
||||||
|
const out = items.slice()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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[] = []
|
||||||
|
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({
|
||||||
|
type: 'thinking', text: it.text, process: true,
|
||||||
|
...(durationMs > 0 ? { durationMs } : {}),
|
||||||
|
})
|
||||||
|
} else if (it.kind === 'tool') {
|
||||||
|
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 {
|
||||||
|
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { App } from './App'
|
import { App } from './App'
|
||||||
|
import { wirePreviewCapabilities } from './previewsetup'
|
||||||
import '@ui/tokens.css'
|
import '@ui/tokens.css'
|
||||||
|
import '@ui/motion.css'
|
||||||
import '@ui/ui.css'
|
import '@ui/ui.css'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
|
// 预览能力装配(D1–D4):Office 解析器与链接预览 fetcher 注入 @ui(动态加载,不进主包)。
|
||||||
|
wirePreviewCapabilities()
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// 消息切分:过程时间线 = 从开头到最后一个 process 部件(中间夹着的内容部件留在时间线里);
|
||||||
|
// 最终段 = 它之后的正文与交付卡片。渲染顺序仍是落库顺序,只在这一刀上折叠。
|
||||||
|
import type { Part } from './contracts'
|
||||||
|
|
||||||
|
export interface TimelineSplit {
|
||||||
|
timeline: Part[]
|
||||||
|
final: Part[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最后一个 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 }
|
||||||
|
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
|
||||||
|
|
||||||
|
/** 正文里出现的工件 ID,去重且保持出现顺序。 */
|
||||||
|
export function artifactIdsIn(text: string): string[] {
|
||||||
|
return [...new Set(text.match(ART_ID) ?? [])]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把正文里项目中不存在的工件 ID 换成弱化标记。存在的 ID 原样保留,由调用方补卡片。
|
||||||
|
* ID 只有十六进制,替换片段不含用户可控的 HTML。
|
||||||
|
*/
|
||||||
|
export function markMissingArtifacts(text: string, exists: (id: string) => boolean): string {
|
||||||
|
return text.replace(/`?(art_[0-9a-f]{12})`?/g, (raw, id: string) => (
|
||||||
|
exists(id) ? raw : '<span class="missing-art">未找到该文件</span>'
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 毫秒转「X 分 Y 秒」;不足 1 秒按 1 秒,无效值返回空串。 */
|
||||||
|
export function formatDuration(ms: number | undefined): string {
|
||||||
|
if (ms == null || !Number.isFinite(ms) || ms <= 0) return ''
|
||||||
|
const s = Math.max(1, Math.round(ms / 1000))
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
return m > 0 ? `${m} 分 ${s % 60} 秒` : `${s} 秒`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** run 起止时间 → 「已工作 X」。缺时间时只给「已工作」。 */
|
||||||
|
export function workedLabel(createdAt?: string, endedAt?: string): string {
|
||||||
|
if (!createdAt || !endedAt) return '已工作'
|
||||||
|
const ms = new Date(endedAt).getTime() - new Date(createdAt).getTime()
|
||||||
|
const text = formatDuration(ms)
|
||||||
|
return text ? `已工作 ${text}` : '已工作'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件大小人类可读(工件面板/预览栏共用)。 */
|
||||||
|
export function humanSize(n: number | undefined): string {
|
||||||
|
if (!n || n <= 0) return ''
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
let v = n
|
||||||
|
let i = 0
|
||||||
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||||
|
const s = v >= 100 || i === 0 ? String(Math.round(v)) : v.toFixed(1).replace(/\.0$/, '')
|
||||||
|
return `${s} ${units[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工件文件图标名(@ui Icon 名):按扩展名优先,回落 kind。 */
|
||||||
|
export function artifactIcon(name: string, kind?: string): 'file' | 'image' | 'package' {
|
||||||
|
if (/\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(name) || kind === 'image') return 'image'
|
||||||
|
if (/\.(zip|tar|gz|7z|rar)$/i.test(name)) return 'package'
|
||||||
|
return 'file'
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// 输入排队(纯数据):回合进行中先入本地队列,终态再逐条发送。
|
||||||
|
export interface QueuedMessage {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
attachmentIds: string[]
|
||||||
|
attachmentNames: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[]): QueuedMessage {
|
||||||
|
return {
|
||||||
|
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
text,
|
||||||
|
attachmentIds,
|
||||||
|
attachmentNames,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enqueueMessage(queue: QueuedMessage[], item: QueuedMessage): QueuedMessage[] {
|
||||||
|
return [...queue, item]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enqueueMessageFront(queue: QueuedMessage[], item: QueuedMessage): QueuedMessage[] {
|
||||||
|
return [item, ...queue.filter(q => q.id !== item.id)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeQueuedMessage(queue: QueuedMessage[], id: string): QueuedMessage[] {
|
||||||
|
return queue.filter(q => q.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceQueuedText(queue: QueuedMessage[], id: string, text: string): QueuedMessage[] {
|
||||||
|
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
|
||||||
|
return enqueueMessageFront(queue, item)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// 浏览器端随机初始密码(管理员未显式给定密码时):
|
||||||
|
// crypto.getRandomValues 拒绝易混淆字符(0/O/1/l/I),保证可抄写。
|
||||||
|
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'
|
||||||
|
|
||||||
|
export function randomPassword(length = 12): string {
|
||||||
|
const buf = new Uint32Array(length)
|
||||||
|
crypto.getRandomValues(buf)
|
||||||
|
let out = ''
|
||||||
|
for (let i = 0; i < length; i++) out += ALPHABET[buf[i] % ALPHABET.length]
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginPageDecl } from './contracts'
|
||||||
|
|
||||||
|
export interface PluginPageModule {
|
||||||
|
mount(el: HTMLElement, bridge: unknown): void
|
||||||
|
unmount?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPluginPage 按声明加载 ESM。模块 URL 由插件页面声明给出,宿主不内嵌领域页面。
|
||||||
|
export async function loadPluginPage(page: PluginPageDecl): Promise<PluginPageModule> {
|
||||||
|
if (!page.module) throw new Error(`plugin page ${page.id} missing module`)
|
||||||
|
const mod = await import(/* @vite-ignore */ page.module)
|
||||||
|
if (typeof mod.mount !== 'function') throw new Error(`plugin page ${page.id} has no mount`)
|
||||||
|
return mod as PluginPageModule
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// docx 分页纸面按预览栏宽度等比缩小。只缩不放:栏宽够放下整页时保持 100%。
|
||||||
|
// gutter 与 styles.css 里 .docx-wrapper 的左右 padding(12px + 12px)对齐。
|
||||||
|
|
||||||
|
export function computeFitScale(containerWidth: number, pageWidth: number, gutter: number): number {
|
||||||
|
if (!Number.isFinite(containerWidth) || !Number.isFinite(pageWidth) || pageWidth <= 0) return 1
|
||||||
|
if (!Number.isFinite(gutter)) return 1
|
||||||
|
const available = containerWidth - gutter
|
||||||
|
if (!(available > 0)) return 1
|
||||||
|
return Math.min(1, available / pageWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_GUTTER_PX = 24
|
||||||
|
|
||||||
|
/** host 是 docx-preview 的渲染根(内含 .docx-wrapper);el 是用来量栏宽的元素。
|
||||||
|
* 拖动分隔条时 el 变宽,ResizeObserver 重算。返回的函数卸下监听并清掉行内缩放。 */
|
||||||
|
export function fitPagesToWidth(host: HTMLElement, el: HTMLElement): () => void {
|
||||||
|
const apply = () => {
|
||||||
|
const wrapper = host.querySelector<HTMLElement>('.docx-wrapper')
|
||||||
|
const page = host.querySelector<HTMLElement>('section.docx')
|
||||||
|
if (!wrapper || !page) return
|
||||||
|
const scale = computeFitScale(el.clientWidth, page.offsetWidth, PAGE_GUTTER_PX)
|
||||||
|
wrapper.style.transformOrigin = 'top left'
|
||||||
|
wrapper.style.transform = scale < 1 ? `scale(${scale})` : ''
|
||||||
|
const nextHeight = `${Math.round(wrapper.offsetHeight * scale)}px`
|
||||||
|
if (host.style.height !== nextHeight) host.style.height = nextHeight
|
||||||
|
host.style.overflow = 'hidden'
|
||||||
|
host.style.minHeight = '0'
|
||||||
|
}
|
||||||
|
apply()
|
||||||
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
|
return () => clearFit(host)
|
||||||
|
}
|
||||||
|
const observer = new ResizeObserver(() => apply())
|
||||||
|
observer.observe(el)
|
||||||
|
// 页内图片后加载只改变页纸高度,不改变栏宽。el 变宽和 wrapper 变高都要重算宿主高度。
|
||||||
|
const wrapper = host.querySelector<HTMLElement>('.docx-wrapper')
|
||||||
|
if (wrapper && wrapper !== el) observer.observe(wrapper)
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
clearFit(host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFit(host: HTMLElement) {
|
||||||
|
const wrapper = host.querySelector<HTMLElement>('.docx-wrapper')
|
||||||
|
if (wrapper) {
|
||||||
|
wrapper.style.transform = ''
|
||||||
|
wrapper.style.transformOrigin = ''
|
||||||
|
}
|
||||||
|
host.style.height = ''
|
||||||
|
host.style.overflow = ''
|
||||||
|
host.style.minHeight = ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// 预览能力装配(裁定 D1–D4):把重解析器经 configureFilePreviewLoaders 注入 @ui。
|
||||||
|
// 全部 dynamic import——对应格式首次预览时才下载 chunk,主 bundle 不含解析器。
|
||||||
|
// 链接预览 fetcher 走服务端 /api/url-preview(SSRF 防护在服务端执行端)。
|
||||||
|
import { configureFilePreviewLoaders, configureLinkPreviewFetcher, renderMarkdown, setSafeHTML, stripUnsafeLinks } from '@ui'
|
||||||
|
import { auth } from './auth'
|
||||||
|
import type { LinkPreviewData } from '@ui'
|
||||||
|
import { fitPagesToWidth } from './previewfit'
|
||||||
|
|
||||||
|
const fetchBuf = async (url: string | undefined): Promise<ArrayBuffer> => {
|
||||||
|
const r = await fetch(url!)
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||||
|
return r.arrayBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// docx/OOXML 是 ZIP 容器,真文件必以 PK 魔数开头(PK\x03\x04 常规,PK\x05\x06 空包)。
|
||||||
|
// 智能体生成侧可能写出"内容是文本的伪 docx",先嗅探再喂 mammoth:
|
||||||
|
// 非 ZIP 直接按文本/markdown 回退渲染,不再抛 JSZip 底层错误。
|
||||||
|
const isZipBytes = (buf: ArrayBuffer): boolean => {
|
||||||
|
if (buf.byteLength < 4) return false
|
||||||
|
const h = new Uint8Array(buf, 0, 4)
|
||||||
|
return h[0] === 0x50 && h[1] === 0x4b &&
|
||||||
|
((h[2] === 0x03 && h[3] === 0x04) || (h[2] === 0x05 && h[3] === 0x06))
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderTextFallback = (el: HTMLElement, buf: ArrayBuffer, name: string | undefined, kindLabel: string) => {
|
||||||
|
const note = document.createElement('div')
|
||||||
|
note.className = 'ui-fp-note'
|
||||||
|
note.textContent = `「${name ?? '该文件'}」不是有效的 ${kindLabel} 文件(缺少 ZIP 结构),已按文本内容展示;需要正式格式请让智能体重新生成。`
|
||||||
|
const body = document.createElement('div')
|
||||||
|
body.className = 'doc-viewer'
|
||||||
|
setSafeHTML(body, renderMarkdown(new TextDecoder().decode(buf), { breaks: true }))
|
||||||
|
el.innerHTML = ''
|
||||||
|
el.append(note, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const docxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => {
|
||||||
|
const buf = await fetchBuf(source.contentUrl)
|
||||||
|
if (!isZipBytes(buf)) {
|
||||||
|
renderTextFallback(el, buf, source.name, 'DOCX')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { renderAsync } = await import('docx-preview')
|
||||||
|
el.innerHTML = ''
|
||||||
|
await renderAsync(buf, el, el, { className: 'docx', inWrapper: true, breakPages: true })
|
||||||
|
stripUnsafeLinks(el)
|
||||||
|
return fitPagesToWidth(el, el)
|
||||||
|
} 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 = ''
|
||||||
|
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 }) => {
|
||||||
|
const buf = await fetchBuf(source.contentUrl)
|
||||||
|
// .xls 是 OLE 复合文档,不是 ZIP;只对 OOXML(xlsx/xlsm,或未给文件名)做魔数嗅探。
|
||||||
|
if (!/\.xls$/i.test(source.name ?? '') && !isZipBytes(buf)) {
|
||||||
|
renderTextFallback(el, buf, source.name, 'XLSX')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const XLSX = await import('xlsx')
|
||||||
|
const wb = XLSX.read(buf, { type: 'array' })
|
||||||
|
el.innerHTML = ''
|
||||||
|
const tabs = document.createElement('div')
|
||||||
|
tabs.className = 'ui-fp-sheet-tabs'
|
||||||
|
const wrap = document.createElement('div')
|
||||||
|
wrap.className = 'ui-fp-sheet-wrap'
|
||||||
|
el.append(tabs, wrap)
|
||||||
|
wb.SheetNames.forEach((name, i) => {
|
||||||
|
const tab = document.createElement('button')
|
||||||
|
tab.className = `ui-fp-sheet-tab ${i === 0 ? 'on' : ''}`
|
||||||
|
tab.textContent = name
|
||||||
|
tab.onclick = () => {
|
||||||
|
tabs.querySelectorAll('.ui-fp-sheet-tab').forEach(t => t.classList.remove('on'))
|
||||||
|
tab.classList.add('on')
|
||||||
|
renderSheet(wrap, XLSX.utils.sheet_to_json<unknown[]>(wb.Sheets[name], { header: 1, defval: '' }))
|
||||||
|
}
|
||||||
|
tabs.append(tab)
|
||||||
|
if (i === 0) renderSheet(wrap, XLSX.utils.sheet_to_json<unknown[]>(wb.Sheets[name], { header: 1, defval: '' }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSheet(wrap: HTMLElement, rows: unknown[][]) {
|
||||||
|
wrap.innerHTML = ''
|
||||||
|
if (rows.length > 200) {
|
||||||
|
const note = document.createElement('div')
|
||||||
|
note.className = 'ui-fp-note'
|
||||||
|
note.textContent = `仅显示前 200 行(共 ${rows.length} 行)——完整内容请下载`
|
||||||
|
wrap.append(note)
|
||||||
|
}
|
||||||
|
const table = document.createElement('table')
|
||||||
|
table.className = 'ui-fp-sheet'
|
||||||
|
for (const row of rows.slice(0, 200)) {
|
||||||
|
const tr = document.createElement('tr')
|
||||||
|
for (const cell of row) {
|
||||||
|
const td = document.createElement('td')
|
||||||
|
td.textContent = cell == null ? '' : String(cell)
|
||||||
|
td.title = td.textContent
|
||||||
|
tr.append(td)
|
||||||
|
}
|
||||||
|
table.append(tr)
|
||||||
|
}
|
||||||
|
wrap.append(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pptxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => {
|
||||||
|
const buf = await fetchBuf(source.contentUrl)
|
||||||
|
// .ppt 是老版二进制;OOXML(pptx/ppsx)才要求 ZIP 魔数。
|
||||||
|
if (!/\.ppt$/i.test(source.name ?? '') && !isZipBytes(buf)) {
|
||||||
|
renderTextFallback(el, buf, source.name, 'PPTX')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// pptx2html@0.3.4 的 module 入口引用了大小写错配的 ./txml(Linux 打包必炸),
|
||||||
|
// 按 D2 备选条款改用 pptx-preview(init(dom).preview(arrayBuffer) 全保真渲染)。
|
||||||
|
const { init } = await import('pptx-preview')
|
||||||
|
el.innerHTML = ''
|
||||||
|
const stage = document.createElement('div')
|
||||||
|
stage.className = 'ui-fp-slide-stage'
|
||||||
|
el.append(stage)
|
||||||
|
const previewer = init(stage, { width: stage.clientWidth || 900 })
|
||||||
|
await previewer.preview(buf)
|
||||||
|
stripUnsafeLinks(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wirePreviewCapabilities() {
|
||||||
|
configureFilePreviewLoaders({ docx: docxLoader, xlsx: xlsxLoader, pptx: pptxLoader })
|
||||||
|
configureLinkPreviewFetcher(async href => {
|
||||||
|
const r = await fetch(auth.withKey(`/api/url-preview?url=${encodeURIComponent(href)}`))
|
||||||
|
if (!r.ok) return { url: href, error: `HTTP ${r.status}` }
|
||||||
|
return (await r.json()) as LinkPreviewData
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// 预览区标签的纯操作:打开已有标签只激活,关闭激活标签时选中相邻的一个。
|
||||||
|
|
||||||
|
export interface PreviewTab {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openPreviewTab(tabs: PreviewTab[], tab: PreviewTab): { tabs: PreviewTab[]; activeId: string } {
|
||||||
|
const i = tabs.findIndex(t => t.id === tab.id)
|
||||||
|
if (i >= 0) {
|
||||||
|
const next = tabs.slice()
|
||||||
|
next[i] = { ...next[i], name: tab.name || next[i].name }
|
||||||
|
return { tabs: next, activeId: tab.id }
|
||||||
|
}
|
||||||
|
return { tabs: [...tabs, tab], activeId: tab.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closePreviewTab(
|
||||||
|
tabs: PreviewTab[], activeId: string | null, id: string,
|
||||||
|
): { tabs: PreviewTab[]; activeId: string | null } {
|
||||||
|
const i = tabs.findIndex(t => t.id === id)
|
||||||
|
if (i < 0) return { tabs, activeId }
|
||||||
|
const next = tabs.filter(t => t.id !== id)
|
||||||
|
if (activeId !== id) return { tabs: next, activeId }
|
||||||
|
const neighbor = next[Math.min(i, next.length - 1)] ?? null
|
||||||
|
return { tabs: next, activeId: neighbor?.id ?? null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览标签的会话键。空态(未选会话)统一落在 '_':写入(requestPreview)与
|
||||||
|
* 渲染(previewOpen/PreviewPane)共用同一口径,空态点开工件才能出现预览区。 */
|
||||||
|
export function previewTabsKey(currentConvId: string | null | undefined): string {
|
||||||
|
return currentConvId ?? '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进入会话时把空态打开的孤儿标签('_' 键)并入会话键下;按 id 去重,
|
||||||
|
* 会话侧顺序在前,'_' 键清空。无孤儿或目标即 '_' 时原样返回。 */
|
||||||
|
export function mergeOrphanPreviewTabs(
|
||||||
|
tabsByConv: Record<string, PreviewTab[]>,
|
||||||
|
convId: string,
|
||||||
|
): Record<string, PreviewTab[]> {
|
||||||
|
const orphan = tabsByConv['_'] ?? []
|
||||||
|
if (orphan.length === 0 || convId === '_') return tabsByConv
|
||||||
|
const own = tabsByConv[convId] ?? []
|
||||||
|
const ids = new Set(own.map(t => t.id))
|
||||||
|
const merged = [...own, ...orphan.filter(t => !ids.has(t.id))]
|
||||||
|
return { ...tabsByConv, [convId]: merged, '_': [] }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// 审计命令错误分类(2026-10-12 增量 2):RdmAuditWorkspace 不再直出原始报错
|
||||||
|
//(普通基座上曾是 `400: {"receipt":…unknown or disabled command…}`),改为
|
||||||
|
//「能力缺失 / 登录已失效 / 查询失败」人话文案;raw 始终保留供「原始回执」折叠区诊断。
|
||||||
|
// 依据:api.ts json() 对非 2xx 抛 `${status}: ${body}`;后端 unknown/disabled 命令回
|
||||||
|
// 400 + "unknown or disabled command"(cmdbus.go Dispatch),总线未挂载回
|
||||||
|
// 503 "commands disabled"(server.go execCommand)。
|
||||||
|
|
||||||
|
type RdmErrorKind = 'capability' | 'auth' | 'query'
|
||||||
|
|
||||||
|
interface RdmErrorView {
|
||||||
|
kind: RdmErrorKind
|
||||||
|
message: string
|
||||||
|
raw: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyRdmError(err: unknown): RdmErrorView {
|
||||||
|
const raw = err instanceof Error ? err.message : String(err)
|
||||||
|
if (/^40[13]:/.test(raw)) {
|
||||||
|
return { kind: 'auth', message: '登录已失效,请重新登录后再查看追溯审计', raw }
|
||||||
|
}
|
||||||
|
if (/unknown or disabled command|commands disabled/.test(raw)) {
|
||||||
|
return { kind: 'capability', message: '当前服务未提供审计能力(RDM 命令未注册或已停用)', raw }
|
||||||
|
}
|
||||||
|
return { kind: 'query', message: '查询失败,请稍后重试', raw }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+643
-66
@@ -1,24 +1,52 @@
|
|||||||
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
||||||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||||||
import { useSyncExternalStore } from 'react'
|
import { useSyncExternalStore } from 'react'
|
||||||
import { api, connectEvents } from './api'
|
import { type AdminZoneSection, api, connectEvents } from './api'
|
||||||
|
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||||||
|
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
|
||||||
|
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
|
||||||
|
import { canFlushOutbox, deriveShellPhase } from './runstate'
|
||||||
|
import { flashElement } from './scrollsync'
|
||||||
|
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
|
||||||
|
import { rdmAuditReady } from './workspaces'
|
||||||
|
|
||||||
|
type FontSizeChoice = 13 | 14 | 16 | 18
|
||||||
|
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
|
||||||
|
|
||||||
|
export function isFontSizeChoice(value: number): value is FontSizeChoice {
|
||||||
|
return (FONT_SIZE_CHOICES as readonly number[]).includes(value)
|
||||||
|
}
|
||||||
import type {
|
import type {
|
||||||
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
|
ActivityCard,
|
||||||
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
|
AgentProfile,
|
||||||
|
Artifact,
|
||||||
|
Conversation,
|
||||||
|
HITLCard,
|
||||||
|
Message,
|
||||||
|
PlanSnapshot,
|
||||||
|
PluginPanel,
|
||||||
|
Project,
|
||||||
|
Run,
|
||||||
|
StreamEvent,
|
||||||
|
SurfaceMeta,
|
||||||
|
SurfaceSnapshot,
|
||||||
|
TodoList
|
||||||
} from './contracts'
|
} from './contracts'
|
||||||
|
|
||||||
export interface ContextObs {
|
export interface ContextObs {
|
||||||
items: string[]
|
items: string[]
|
||||||
truncations?: string[]
|
truncations?: string[]
|
||||||
estChars: number
|
estChars: number
|
||||||
|
sections?: { name: string; estTokens: number }[]
|
||||||
|
prefixHash?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LiveState {
|
export interface LiveState {
|
||||||
runId: string
|
runId: string
|
||||||
phase: string
|
phase: string
|
||||||
thinking: string
|
// 到达序部件流(见 live.ts LiveItem):事件到达序即落库序,与落库部件的轮次穿插序
|
||||||
text: string
|
// 同构;文本段是否为过程由「本段内是否出现工具调用」判定,tool.call 一到即改标。
|
||||||
toolCalls: { name: string; status?: string; summary?: string }[]
|
items: LiveItem[]
|
||||||
agentNotes: Record<string, string>
|
agentNotes: Record<string, string>
|
||||||
startedAt: number
|
startedAt: number
|
||||||
}
|
}
|
||||||
@@ -28,20 +56,42 @@ export interface UIState {
|
|||||||
leftCollapsed: boolean
|
leftCollapsed: boolean
|
||||||
rightWidth: number
|
rightWidth: number
|
||||||
rightCollapsed: boolean
|
rightCollapsed: boolean
|
||||||
rightTab: string
|
/** 第三栏双入口:工作(当前任务/收件箱)/ 文件(搜索、预览、编辑、下载)。 */
|
||||||
theme: 'dark' | 'light'
|
rightTab: 'work' | 'files'
|
||||||
|
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
|
||||||
|
filesView: 'list' | 'preview'
|
||||||
|
/** U6:工作区任务页固定(surfaceId;null=跟随最新;固定后新页面不抢占显示)。 */
|
||||||
|
workspaceSurfacePin: string | null
|
||||||
|
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
|
||||||
|
inboxFilter: 'all' | 'todo'
|
||||||
|
theme: ThemeChoice
|
||||||
|
/** 界面字号,只写本机。对应 --ui-font-size。 */
|
||||||
|
fontSize: FontSizeChoice
|
||||||
autoCollapseProcess: boolean
|
autoCollapseProcess: boolean
|
||||||
settingsOpen: boolean
|
settingsOpen: boolean
|
||||||
trackerRunId: string | null
|
trackerRunId: string | null
|
||||||
viewingArtifact: Artifact | null
|
/** 预览区宽度。标签列表按会话放在 AppState,不进本地偏好。 */
|
||||||
|
previewWidth: number
|
||||||
|
/** 预览栏铺满主区。标签不持久化,这个标志在 loadUI 时强制复位。 */
|
||||||
|
previewExpanded: boolean
|
||||||
activeWorkspace: string | null
|
activeWorkspace: string | null
|
||||||
|
taskOrg: 'project' | 'timeline'
|
||||||
|
showArchived: boolean
|
||||||
|
lastProjectId: string | null
|
||||||
|
/** 系统管理全屏视图(阶段② IA:仅管理员;入口账户菜单,纵深防御在 AdminZone 再拦一次)。 */
|
||||||
|
adminZoneOpen: boolean
|
||||||
|
changePwOpen: boolean
|
||||||
|
projectSettingsId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
contextObs: Record<string, ContextObs>
|
contextObs: Record<string, ContextObs>
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
currentProjectId: string | null
|
currentProjectId: string | null
|
||||||
conversations: Conversation[]
|
tasks: Conversation[]
|
||||||
|
me: Me | null
|
||||||
currentConvId: string | null
|
currentConvId: string | null
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
runs: Run[]
|
runs: Run[]
|
||||||
@@ -51,45 +101,90 @@ interface AppState {
|
|||||||
plan: PlanSnapshot | null
|
plan: PlanSnapshot | null
|
||||||
todos: TodoList | null
|
todos: TodoList | null
|
||||||
artifacts: Artifact[]
|
artifacts: Artifact[]
|
||||||
|
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
|
||||||
|
surfaces: SurfaceSnapshot[]
|
||||||
|
/** generative-ui U6:项目级跨会话页面聚合(打开项目时权威装载)。 */
|
||||||
|
projectSurfaces: SurfaceMeta[]
|
||||||
cards: ActivityCard[]
|
cards: ActivityCard[]
|
||||||
|
projectCards: ActivityCard[]
|
||||||
|
inboxScope: 'conv' | 'project'
|
||||||
|
/** runtime.mock:只控制演示指令提示,不改变对话能力。 */
|
||||||
|
runtimeMock: boolean
|
||||||
profiles: AgentProfile[]
|
profiles: AgentProfile[]
|
||||||
pluginPanels: PluginPanel[]
|
pluginPanels: PluginPanel[]
|
||||||
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
|
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
|
||||||
|
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
|
||||||
|
rdmAuditAvailable: boolean
|
||||||
|
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
|
||||||
|
adminSections: AdminZoneSection[]
|
||||||
ui: UIState
|
ui: UIState
|
||||||
connected: boolean
|
connected: boolean
|
||||||
|
/** 发送请求已经出去,run 记录还没回来。 */
|
||||||
|
posting: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
toast: string | null
|
toast: string | null
|
||||||
|
outbox: QueuedMessage[]
|
||||||
|
outboxDraining: boolean
|
||||||
|
previewTabs: Record<string, PreviewTab[]>
|
||||||
|
previewActive: Record<string, string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_UI: UIState = {
|
const DEFAULT_UI: UIState = {
|
||||||
leftWidth: 260,
|
leftWidth: 260,
|
||||||
leftCollapsed: false,
|
leftCollapsed: false,
|
||||||
rightWidth: 340,
|
rightWidth: 360,
|
||||||
rightCollapsed: false,
|
rightCollapsed: false,
|
||||||
rightTab: 'inbox',
|
rightTab: 'work',
|
||||||
|
filesView: 'list', workspaceSurfacePin: null,
|
||||||
|
inboxFilter: 'all',
|
||||||
activeWorkspace: null,
|
activeWorkspace: null,
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
|
fontSize: 14,
|
||||||
autoCollapseProcess: true,
|
autoCollapseProcess: true,
|
||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
trackerRunId: null,
|
trackerRunId: null,
|
||||||
viewingArtifact: null,
|
previewWidth: 640,
|
||||||
|
previewExpanded: false,
|
||||||
|
taskOrg: 'project',
|
||||||
|
showArchived: false,
|
||||||
|
lastProjectId: null,
|
||||||
|
adminZoneOpen: false,
|
||||||
|
changePwOpen: false,
|
||||||
|
projectSettingsId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadUI(): UIState {
|
function loadUI(): UIState {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('as.ui')
|
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 (!isFontSizeChoice(ui.fontSize)) ui.fontSize = 14
|
||||||
|
if (ui.inboxFilter !== 'all' && ui.inboxFilter !== 'todo') ui.inboxFilter = 'all'
|
||||||
|
// 旧本地偏好里存的是 'inbox'——第三栏双入口上线时归一到 'work'。
|
||||||
|
if (ui.rightTab !== 'work' && ui.rightTab !== 'files') ui.rightTab = 'work'
|
||||||
|
if (ui.filesView !== 'list' && ui.filesView !== 'preview') ui.filesView = 'list'
|
||||||
|
// 预览标签不进本地偏好。展开态若随刷新残留,会在没有标签时藏起对话。
|
||||||
|
ui.previewExpanded = false
|
||||||
|
return ui
|
||||||
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return { ...DEFAULT_UI }
|
return { ...DEFAULT_UI }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const surfaceActionsInflight: Record<string, boolean> = {}
|
||||||
|
|
||||||
let state: AppState = {
|
let state: AppState = {
|
||||||
contextObs: {},
|
contextObs: {},
|
||||||
projects: [], currentProjectId: null, conversations: [], currentConvId: null,
|
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||||
plan: null, todos: null, artifacts: [], cards: [], profiles: [], pluginPanels: [],
|
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||||
serverWorkspaces: [],
|
serverWorkspaces: [],
|
||||||
ui: loadUI(), connected: false, error: null, toast: null,
|
rdmAuditAvailable: false,
|
||||||
|
adminSections: [],
|
||||||
|
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
||||||
|
outbox: [], outboxDraining: false,
|
||||||
|
previewTabs: {}, previewActive: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
const listeners = new Set<() => void>()
|
const listeners = new Set<() => void>()
|
||||||
@@ -99,19 +194,78 @@ function set(patch: Partial<AppState>) {
|
|||||||
listeners.forEach(l => l())
|
listeners.forEach(l => l())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 第三栏「文件」入口联动(方案:预览并入第三栏):开第一张标签自动切到文件入口的
|
||||||
|
// 预览子视图并展开右栏;关掉最后一张回到文件列表。不再收起右栏——预览与工作同居第三栏。
|
||||||
|
function syncFilesEntry(hasTabs: boolean) {
|
||||||
|
if (hasTabs) {
|
||||||
|
setUI({ rightTab: 'files', rightCollapsed: false, filesView: 'preview' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.ui.filesView === 'preview') setUI({ filesView: 'list' })
|
||||||
|
}
|
||||||
|
|
||||||
function setUI(patch: Partial<UIState>) {
|
function setUI(patch: Partial<UIState>) {
|
||||||
const ui = { ...state.ui, ...patch }
|
const ui = { ...state.ui, ...patch }
|
||||||
state = { ...state, ui }
|
state = { ...state, ui }
|
||||||
try { localStorage.setItem('as.ui', JSON.stringify(ui)) } catch { /* ignore */ }
|
try { localStorage.setItem('as.ui', JSON.stringify(ui)) } catch { /* ignore */ }
|
||||||
applyTheme(ui.theme)
|
applyTheme(ui.theme)
|
||||||
|
applyFontSize(ui.fontSize)
|
||||||
listeners.forEach(l => l())
|
listeners.forEach(l => l())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 会话恢复(空态点工件无反应的高频前置):currentConvId 不持久化时刷新必回空态,
|
||||||
|
// 空态下 requestPreview 的标签挂在 '_' 键、渲染判空键——点击只剩弹层关闭,没有任何反馈。
|
||||||
|
const CONV_KEY = 'as.conv'
|
||||||
|
let convRestoreDone = false
|
||||||
|
function persistConvId(id: string | null) {
|
||||||
|
try { id ? localStorage.setItem(CONV_KEY, id) : localStorage.removeItem(CONV_KEY) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
function savedConvId(): string | null {
|
||||||
|
try { return localStorage.getItem(CONV_KEY) } catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
let systemQuery: MediaQueryList | null = null
|
||||||
|
let systemHandler: (() => void) | null = null
|
||||||
|
|
||||||
function applyTheme(theme: string) {
|
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)
|
||||||
|
applyFontSize(state.ui.fontSize)
|
||||||
|
|
||||||
|
function applyFontSize(size: FontSizeChoice) {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
document.documentElement.style.setProperty('--ui-font-size', `${size}px`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
let sse: { close: () => void } | null = null
|
||||||
|
let projectSse: { close: () => void } | null = null
|
||||||
|
let projectSeq = 0
|
||||||
|
|
||||||
export function useStore<T>(selector: (s: AppState) => T): T {
|
export function useStore<T>(selector: (s: AppState) => T): T {
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
@@ -126,12 +280,34 @@ export const store = {
|
|||||||
async refreshProjects() {
|
async refreshProjects() {
|
||||||
try {
|
try {
|
||||||
const projects = await api.listProjects()
|
const projects = await api.listProjects()
|
||||||
const current = projects.find(p => p.id === state.currentProjectId) ?? projects[0]
|
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
||||||
set({ projects, currentProjectId: current?.id ?? null, error: null })
|
set({ projects, currentProjectId: current?.id ?? null, error: null })
|
||||||
if (current) await store.openProject(current.id)
|
if (current) await store.openProject(current.id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
set({ error: String(e) })
|
set({ error: String(e) })
|
||||||
}
|
}
|
||||||
|
store.refreshTasks()
|
||||||
|
store.refreshMe()
|
||||||
|
},
|
||||||
|
|
||||||
|
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
|
||||||
|
async refreshTasks(includeArchived = state.ui.showArchived) {
|
||||||
|
try {
|
||||||
|
const tasks = await api.listTasks(includeArchived)
|
||||||
|
set({ tasks })
|
||||||
|
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
|
||||||
|
if (!convRestoreDone) {
|
||||||
|
convRestoreDone = true
|
||||||
|
const saved = savedConvId()
|
||||||
|
if (saved && !state.currentConvId && tasks.some(t => t.id === saved && !t.archived)) {
|
||||||
|
store.openConversation(saved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* 列表竞态,静默 */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshMe() {
|
||||||
|
try { set({ me: await api.me() }) } catch { /* 身份不可用时侧栏只显示通用入口 */ }
|
||||||
},
|
},
|
||||||
|
|
||||||
async createProject(name: string) {
|
async createProject(name: string) {
|
||||||
@@ -142,31 +318,115 @@ export const store = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async openProject(projectId: string) {
|
async openProject(projectId: string) {
|
||||||
const conversations = await api.listConversations(projectId)
|
set({ currentProjectId: projectId })
|
||||||
set({ currentProjectId: projectId, conversations })
|
if (store.getState().inboxScope === 'project') {
|
||||||
|
store.loadProjectCards()
|
||||||
|
store.connectProject()
|
||||||
|
}
|
||||||
|
store.setUI({ lastProjectId: projectId })
|
||||||
await store.refreshArtifacts(projectId)
|
await store.refreshArtifacts(projectId)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。
|
||||||
|
* 空库时主按钮与空状态都写着「点击新建任务开始」,这里先落一个默认项目再开会话。 */
|
||||||
|
async createTask(projectId?: string) {
|
||||||
|
let pid = projectId ?? state.currentProjectId
|
||||||
|
if (!pid && state.projects.length > 0) pid = state.projects[0].id
|
||||||
|
if (!pid) {
|
||||||
|
const p = await store.createProject('默认项目')
|
||||||
|
pid = p.id
|
||||||
|
}
|
||||||
|
if (pid !== state.currentProjectId) await store.openProject(pid)
|
||||||
|
const c = await api.createConversation(pid)
|
||||||
|
await store.refreshTasks()
|
||||||
|
await store.openTask(c.id)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 兼容旧入口(⌘/命令总线) */
|
||||||
async createConversation(title?: string) {
|
async createConversation(title?: string) {
|
||||||
if (!state.currentProjectId) return
|
if (title) store.toast('已在当前项目创建任务')
|
||||||
const c = await api.createConversation(state.currentProjectId, title)
|
await store.createTask()
|
||||||
await store.openProject(state.currentProjectId)
|
},
|
||||||
await store.openConversation(c.id)
|
|
||||||
|
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
|
||||||
|
async openTask(convId: string) {
|
||||||
|
const t = state.tasks.find(x => x.id === convId)
|
||||||
|
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
|
||||||
|
await store.openConversation(convId)
|
||||||
|
},
|
||||||
|
|
||||||
|
async pinTask(convId: string, pinned: boolean) {
|
||||||
|
const tasks = state.tasks.map(t => t.id === convId ? { ...t, pinned } : t)
|
||||||
|
set({ tasks })
|
||||||
|
try { await api.setConversationFlags(convId, { pinned }) } catch (e) { store.toast(`置顶失败:${e}`); store.refreshTasks() }
|
||||||
|
},
|
||||||
|
|
||||||
|
async archiveTask(convId: string, archived: boolean) {
|
||||||
|
const tasks = state.tasks.map(t => t.id === convId ? { ...t, archived } : t)
|
||||||
|
set({ tasks })
|
||||||
|
try {
|
||||||
|
await api.setConversationFlags(convId, { archived })
|
||||||
|
if (state.currentConvId === convId && archived) store.toast('任务已归档')
|
||||||
|
} catch (e) { store.toast(`归档失败:${e}`) }
|
||||||
|
store.refreshTasks()
|
||||||
|
},
|
||||||
|
|
||||||
|
// 跨会话标记(提案 2026-09-30-cross-session-reading):出站 allowCrossRead / 入站 crossPrivate。
|
||||||
|
async 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 })
|
||||||
|
try { await api.setConversationFlags(convId, flags) } catch (e) { store.toast(`跨会话设置失败:${e}`); store.refreshTasks() }
|
||||||
},
|
},
|
||||||
|
|
||||||
async openConversation(convId: string) {
|
async openConversation(convId: string) {
|
||||||
if (sse) { sse.close(); sse = null }
|
if (sse) { sse.close(); sse = null }
|
||||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [] })
|
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||||
await store.reconcile()
|
persistConvId(convId)
|
||||||
await store.connectLive()
|
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
|
||||||
|
if ((state.previewTabs['_'] ?? []).length > 0) {
|
||||||
|
const active = state.previewActive[convId] ?? state.previewActive['_'] ?? null
|
||||||
|
set({
|
||||||
|
previewTabs: mergeOrphanPreviewTabs(state.previewTabs, convId),
|
||||||
|
previewActive: { ...state.previewActive, [convId]: active },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
|
||||||
|
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
|
||||||
|
api.surfaceList(convId).then(list => {
|
||||||
|
if (state.currentConvId !== convId) return
|
||||||
|
set({ surfaces: list ?? [] })
|
||||||
|
// 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切
|
||||||
|
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。
|
||||||
|
const conv = previewTabsKey(state.currentConvId)
|
||||||
|
const tabs = state.previewTabs[conv] ?? []
|
||||||
|
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
|
||||||
|
if (!hasSurfaceTab && (list?.length ?? 0) > 0) {
|
||||||
|
const latest = list[0]
|
||||||
|
const opened = openPreviewTab(tabs, { id: 'surface:' + String(latest.surfaceId), name: latest.title || '页面' })
|
||||||
|
set({
|
||||||
|
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||||
|
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
const resumeSeq = await store.reconcile()
|
||||||
|
store.connectLive(resumeSeq ?? state.lastSeq)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 终态对账:以 Query 的权威消息树/运行树/游标为准收敛。
|
// 终态对账:以 Query 的权威消息树/运行树/游标为准收敛。
|
||||||
async reconcile() {
|
// 返回值是实时流应从这个 seq 之后重放;有进行中的 run 时它早于 lastSeq。
|
||||||
|
async reconcile(): Promise<number | undefined> {
|
||||||
if (!state.currentConvId) return
|
if (!state.currentConvId) return
|
||||||
try {
|
try {
|
||||||
const d = await api.conversationDetail(state.currentConvId)
|
const d = await api.conversationDetail(state.currentConvId)
|
||||||
const liveRunActive = state.live && !isTerminal(d.runs.find(r => r.id === state.live!.runId)?.status)
|
const liveRunActive = state.live && !isTerminal(d.runs.find(r => r.id === state.live!.runId)?.status)
|
||||||
|
const resumeSeq = d.resumeSeq ?? d.lastSeq
|
||||||
set({
|
set({
|
||||||
messages: d.messages,
|
messages: d.messages,
|
||||||
runs: d.runs,
|
runs: d.runs,
|
||||||
@@ -178,15 +438,17 @@ export const store = {
|
|||||||
const [plan, todos] = await Promise.all([api.getPlan(d.conversation.id), api.getTodos(d.conversation.id)])
|
const [plan, todos] = await Promise.all([api.getPlan(d.conversation.id), api.getTodos(d.conversation.id)])
|
||||||
set({ plan, todos })
|
set({ plan, todos })
|
||||||
store.refreshCards(d.conversation.id)
|
store.refreshCards(d.conversation.id)
|
||||||
|
store.refreshTasks()
|
||||||
|
return resumeSeq
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
set({ error: String(e) })
|
set({ error: String(e) })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
connectLive() {
|
connectLive(since?: number) {
|
||||||
if (!state.currentConvId) return
|
if (!state.currentConvId) return
|
||||||
const convId = state.currentConvId
|
const convId = state.currentConvId
|
||||||
sse = connectEvents(convId, state.lastSeq, {
|
sse = connectEvents(convId, since ?? state.lastSeq, {
|
||||||
onOpen: () => set({ connected: true }),
|
onOpen: () => set({ connected: true }),
|
||||||
onError: () => set({ connected: false }),
|
onError: () => set({ connected: false }),
|
||||||
onEvent: e => store.applyEvent(e),
|
onEvent: e => store.applyEvent(e),
|
||||||
@@ -194,34 +456,50 @@ export const store = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
applyEvent(e: StreamEvent) {
|
applyEvent(e: StreamEvent) {
|
||||||
|
const now = eventTime(e)
|
||||||
|
const project = (mutate: (l: LiveState) => void) => touchLive(e.runId ?? '', mutate, now)
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
case 'message.delta': {
|
case 'message.delta': {
|
||||||
const p = e.payload as { index: number; text: string }
|
const p = e.payload as { index: number; text: string }
|
||||||
set({ live: touchLive(e.runId ?? '', l => { l.text += p.text }) })
|
set({ live: project(l => { l.items = applyTextDelta(l.items, p.index, p.text) }) })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'thinking.delta': {
|
case 'thinking.delta': {
|
||||||
const p = e.payload as { index: number; text: string }
|
const p = e.payload as { index: number; text: string }
|
||||||
set({ live: touchLive(e.runId ?? '', l => { l.thinking += 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
|
break
|
||||||
}
|
}
|
||||||
case 'tool.call': {
|
case 'tool.call': {
|
||||||
const p = e.payload as { callId: string; name: string }
|
const p = e.payload as { callId: string; name: string; arguments?: string }
|
||||||
set({ live: touchLive(e.runId ?? '', l => { l.toolCalls.push({ name: p.name }) }) })
|
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
|
break
|
||||||
}
|
}
|
||||||
case 'tool.result': {
|
case 'tool.result': {
|
||||||
const p = e.payload as { callId: string; status: string; summary?: string }
|
const p = e.payload as { callId: string; status: string; summary?: string }
|
||||||
set({ live: touchLive(e.runId ?? '', l => {
|
set({ live: project(l => { l.items = applyToolResult(l.items, p.callId, p.status, p.summary, now) }) })
|
||||||
for (let i = l.toolCalls.length - 1; i >= 0; i--) {
|
|
||||||
if (l.toolCalls[i].status === undefined) { l.toolCalls[i] = { ...l.toolCalls[i], status: p.status, summary: p.summary }; break }
|
|
||||||
}
|
|
||||||
}) })
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'run.phase': {
|
case 'run.phase': {
|
||||||
const p = e.payload as { phase: string }
|
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
|
break
|
||||||
}
|
}
|
||||||
case 'hitl.request': {
|
case 'hitl.request': {
|
||||||
@@ -234,17 +512,17 @@ export const store = {
|
|||||||
}
|
}
|
||||||
case 'agent.spawn': {
|
case 'agent.spawn': {
|
||||||
const p = e.payload as { agentRun: string; name: string; task: string }
|
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
|
break
|
||||||
}
|
}
|
||||||
case 'agent.progress': {
|
case 'agent.progress': {
|
||||||
const p = e.payload as { agentRun: string; note?: string }
|
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
|
break
|
||||||
}
|
}
|
||||||
case 'agent.result': {
|
case 'agent.result': {
|
||||||
const p = e.payload as { agentRun: string; summary?: string }
|
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()
|
store.reconcile()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -258,6 +536,37 @@ export const store = {
|
|||||||
set({ todos: p.list })
|
set({ todos: p.list })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'surface.updated': {
|
||||||
|
// KTD8:单一状态所有者——服务端快照为权威,本地按 revision 守卫:
|
||||||
|
// 旧修订/重复事件不覆盖新修订;切会话由 selectConversation 清空。
|
||||||
|
const p = e.payload as SurfaceSnapshot
|
||||||
|
if (!p?.identity?.surfaceId) break
|
||||||
|
const id = String(p.identity.surfaceId)
|
||||||
|
const surfaces = state.surfaces.filter(x => String(x.identity.surfaceId) !== id)
|
||||||
|
const existing = state.surfaces.find(x => String(x.identity.surfaceId) === id)
|
||||||
|
if (!existing || p.revision > existing.revision) {
|
||||||
|
surfaces.push(p)
|
||||||
|
set({ surfaces })
|
||||||
|
// 新页面到达:没有页面标签或当前正在看页面 → 开标签并把右栏切到
|
||||||
|
// 文件预览(与 requestPreview 同语义,用户能立刻看到);否则不抢占
|
||||||
|
// 已打开的对象,但留 toast 痕迹(KTD「新结果不抢占」)。
|
||||||
|
const conv = previewTabsKey(state.currentConvId)
|
||||||
|
const tabs = state.previewTabs[conv] ?? []
|
||||||
|
const activeId = state.previewActive[conv] ?? null
|
||||||
|
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
|
||||||
|
if (!hasSurfaceTab || (activeId ?? '').startsWith('surface:')) {
|
||||||
|
const opened = openPreviewTab(tabs, { id: 'surface:' + id, name: p.spec?.title || '页面' })
|
||||||
|
set({
|
||||||
|
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||||
|
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||||
|
})
|
||||||
|
syncFilesEntry(true)
|
||||||
|
} else {
|
||||||
|
store.toast(`页面已更新:${p.spec?.title || id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'card.created':
|
case 'card.created':
|
||||||
case 'card.updated': {
|
case 'card.updated': {
|
||||||
const p = e.payload as { card: ActivityCard }
|
const p = e.payload as { card: ActivityCard }
|
||||||
@@ -276,7 +585,15 @@ export const store = {
|
|||||||
set({ cards })
|
set({ cards })
|
||||||
break
|
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) {
|
||||||
|
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)
|
if (state.currentProjectId) store.refreshArtifacts(state.currentProjectId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -285,38 +602,109 @@ export const store = {
|
|||||||
if (p?.context && e.runId) {
|
if (p?.context && e.runId) {
|
||||||
set({ contextObs: { ...state.contextObs, [e.runId]: p.context } })
|
set({ contextObs: { ...state.contextObs, [e.runId]: p.context } })
|
||||||
}
|
}
|
||||||
setTimeout(() => store.reconcile(), 250)
|
setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'run.failed':
|
case 'run.failed':
|
||||||
case 'error': {
|
case 'error': {
|
||||||
// 终态:短暂延迟后对账(等待消息树落库),折叠过程输出。
|
// 终态:短暂延迟后对账(等待消息树落库),折叠过程输出,再发送排队消息。
|
||||||
setTimeout(() => store.reconcile(), 250)
|
setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
default: {
|
||||||
|
const unexpected: never = e.type
|
||||||
|
void unexpected
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async sendMessage(text: string, attachmentIds: string[]) {
|
async sendMessage(text: string, attachmentIds: string[], requestId?: string): Promise<'ok' | 'busy'> {
|
||||||
if (!state.currentConvId) return
|
if (!state.currentConvId) return 'ok'
|
||||||
|
const id = requestId ?? newQueuedMessage(text, attachmentIds, []).id
|
||||||
|
set({ posting: true })
|
||||||
try {
|
try {
|
||||||
const out = await api.sendMessage(state.currentConvId, text, attachmentIds)
|
const out = await api.sendMessage(state.currentConvId, text, attachmentIds, id)
|
||||||
if (out.command) {
|
if (out.command) {
|
||||||
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
|
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
|
||||||
if (out.conversation) await store.openConversation(out.conversation.id)
|
if (out.conversation) await store.openConversation(out.conversation.id)
|
||||||
else await store.reconcile()
|
else await store.reconcile()
|
||||||
return
|
await store.flushOutbox()
|
||||||
|
return 'ok'
|
||||||
}
|
}
|
||||||
if (out.run) {
|
if (out.run) {
|
||||||
set({
|
const started = out.run
|
||||||
live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() },
|
// 立刻把 run 合进列表,停止/排队不必等终态对账。已有同 id 记录保持不动。
|
||||||
messages: [...state.messages, out.message],
|
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。
|
||||||
|
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()
|
store.connectIfNeeded()
|
||||||
}
|
}
|
||||||
|
return 'ok'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (isConversationBusy(e)) {
|
||||||
|
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [] }) })
|
||||||
|
return 'busy'
|
||||||
|
}
|
||||||
store.toast(`发送失败:${e}`)
|
store.toast(`发送失败:${e}`)
|
||||||
await store.reconcile()
|
await store.reconcile()
|
||||||
|
return 'ok'
|
||||||
|
} finally {
|
||||||
|
set({ posting: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[]) {
|
||||||
|
set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames)) })
|
||||||
|
},
|
||||||
|
|
||||||
|
updateOutbox(id: string, text: string) {
|
||||||
|
set({ outbox: replaceQueuedText(state.outbox, id, text) })
|
||||||
|
},
|
||||||
|
|
||||||
|
removeOutbox(id: string) {
|
||||||
|
set({ outbox: removeQueuedMessage(state.outbox, id) })
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendOutboxNow(id: string) {
|
||||||
|
const item = state.outbox.find(q => q.id === id)
|
||||||
|
if (!item) return
|
||||||
|
const rest = removeQueuedMessage(state.outbox, id)
|
||||||
|
if (!canFlushOutbox(shellPhase())) {
|
||||||
|
set({ outbox: promoteQueuedMessage(state.outbox, id) })
|
||||||
|
store.toast('回合进行中,已放到队首,结束后自动发送')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set({ outbox: rest })
|
||||||
|
await store.sendMessage(item.text, item.attachmentIds, deliveryRequestId(item))
|
||||||
|
},
|
||||||
|
|
||||||
|
async flushOutbox() {
|
||||||
|
if (state.outboxDraining) 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, deliveryRequestId(next))
|
||||||
|
} finally {
|
||||||
|
set({ outboxDraining: false })
|
||||||
|
}
|
||||||
|
if (result === 'busy') return
|
||||||
|
if (canFlushOutbox(shellPhase()) && state.outbox.length > 0) {
|
||||||
|
await store.flushOutbox()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -325,7 +713,7 @@ export const store = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async cancelActive() {
|
async cancelActive() {
|
||||||
const active = state.runs.find(r => !isTerminal(r.status))
|
const active = activeRun(state.runs)
|
||||||
if (!active) return
|
if (!active) return
|
||||||
await api.cancelRun(active.id).catch(() => undefined)
|
await api.cancelRun(active.id).catch(() => undefined)
|
||||||
setTimeout(() => store.reconcile(), 200)
|
setTimeout(() => store.reconcile(), 200)
|
||||||
@@ -365,6 +753,10 @@ export const store = {
|
|||||||
const out = await api.cardAction(card.id, actionId, freeText)
|
const out = await api.cardAction(card.id, actionId, freeText)
|
||||||
store.upsertCard(out.card)
|
store.upsertCard(out.card)
|
||||||
if (out.receipt) set({ messages: [...state.messages, out.receipt as Message] })
|
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) {
|
} catch (e) {
|
||||||
store.toast(`操作失败:${e}`)
|
store.toast(`操作失败:${e}`)
|
||||||
}
|
}
|
||||||
@@ -399,6 +791,21 @@ export const store = {
|
|||||||
const ws = await api.listWorkspaces()
|
const ws = await api.listWorkspaces()
|
||||||
set({ serverWorkspaces: ws ?? [] })
|
set({ serverWorkspaces: ws ?? [] })
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
// 审计入口能力门控(增量 2):与工作区清单同源刷新——能力撤销(插件卸载/命令停用)后入口随之隐藏。
|
||||||
|
try {
|
||||||
|
const cmds = await api.listCommands()
|
||||||
|
set({ rdmAuditAvailable: rdmAuditReady(cmds) })
|
||||||
|
} catch { set({ rdmAuditAvailable: false }) }
|
||||||
|
try {
|
||||||
|
const sections = await api.adminZoneSections()
|
||||||
|
set({ adminSections: sections ?? [] })
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
try {
|
||||||
|
const settings = await api.getSettings()
|
||||||
|
const runtime = settings.user.runtime
|
||||||
|
const mock = !!runtime && typeof runtime === 'object' && (runtime as { mock?: unknown }).mock === true
|
||||||
|
set({ runtimeMock: mock })
|
||||||
|
} catch { /* ignore */ }
|
||||||
},
|
},
|
||||||
|
|
||||||
async uploadAttachment(file: File): Promise<string | null> {
|
async uploadAttachment(file: File): Promise<string | null> {
|
||||||
@@ -419,6 +826,72 @@ export const store = {
|
|||||||
toggleRight() { setUI({ rightCollapsed: !state.ui.rightCollapsed }) },
|
toggleRight() { setUI({ rightCollapsed: !state.ui.rightCollapsed }) },
|
||||||
openWorkspace(id: string) { setUI({ activeWorkspace: id }) },
|
openWorkspace(id: string) { setUI({ activeWorkspace: id }) },
|
||||||
/** 收纳策略(T5):一键收纳已完成卡(done→dismissed;resolved 盖章留痕不动)。 */
|
/** 收纳策略(T5):一键收纳已完成卡(done→dismissed;resolved 盖章留痕不动)。 */
|
||||||
|
// 项目收件箱(CF3/D48-D51):加载聚合视图 + 项目流订阅 + 留档。
|
||||||
|
async loadProjectCards() {
|
||||||
|
const pid = store.getState().currentProjectId
|
||||||
|
if (!pid) return
|
||||||
|
try { set({ projectCards: await api.listProjectCards(pid) }) } catch { /* 静默:切项目竞态 */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
setInboxScope(scope: 'conv' | 'project') {
|
||||||
|
set({ inboxScope: scope })
|
||||||
|
if (scope === 'project') {
|
||||||
|
store.loadProjectCards()
|
||||||
|
store.connectProject()
|
||||||
|
} else if (projectSse) {
|
||||||
|
projectSse.close()
|
||||||
|
projectSse = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
connectProject() {
|
||||||
|
const pid = store.getState().currentProjectId
|
||||||
|
if (!pid) return
|
||||||
|
projectSse?.close()
|
||||||
|
projectSse = connectEvents('', projectSeq, { onEvent: e => store.applyProjectEvent(e) }, pid)
|
||||||
|
},
|
||||||
|
|
||||||
|
applyProjectEvent(e: StreamEvent) {
|
||||||
|
projectSeq = Math.max(projectSeq, e.seq)
|
||||||
|
const upsert = (card: ActivityCard) => {
|
||||||
|
const list = store.getState().projectCards
|
||||||
|
const i = list.findIndex(c => c.id === card.id)
|
||||||
|
set({ projectCards: i >= 0 ? list.map(c => (c.id === card.id ? card : c)) : [card, ...list] })
|
||||||
|
}
|
||||||
|
switch (e.type) {
|
||||||
|
case 'card.created':
|
||||||
|
case 'card.updated':
|
||||||
|
if ((e.payload as { card?: ActivityCard })?.card) upsert((e.payload as { card: ActivityCard }).card)
|
||||||
|
break
|
||||||
|
case 'card.status': {
|
||||||
|
const p = e.payload as { cardId: string; status: ActivityCard['status']; tone?: string }
|
||||||
|
const list = store.getState().projectCards
|
||||||
|
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)) })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'card.resolved': {
|
||||||
|
const p = e.payload as { cardId: string }
|
||||||
|
const list = store.getState().projectCards
|
||||||
|
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)) })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async cardToProject(card: ActivityCard) {
|
||||||
|
try {
|
||||||
|
const out = await api.cardToProject(card.id)
|
||||||
|
store.upsertCard(out.origin) // 会话视图:原卡 dismissed 留痕
|
||||||
|
const list = store.getState().projectCards
|
||||||
|
const i = list.findIndex(c => c.id === out.card.id)
|
||||||
|
set({ projectCards: i >= 0 ? list.map(c => (c.id === out.card.id ? out.card : c)) : [out.card, ...list] })
|
||||||
|
store.toast('已留档到项目收件箱')
|
||||||
|
store.setInboxScope('project')
|
||||||
|
} catch (e) {
|
||||||
|
store.toast(`留档失败:${e}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async tidyCards() {
|
async tidyCards() {
|
||||||
const done = state.cards.filter(c => c.status === 'done')
|
const done = state.cards.filter(c => c.status === 'done')
|
||||||
for (const c of done) {
|
for (const c of done) {
|
||||||
@@ -427,24 +900,101 @@ export const store = {
|
|||||||
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
|
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
|
||||||
},
|
},
|
||||||
closeWorkspace() { setUI({ activeWorkspace: null }) },
|
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: 'work', inboxFilter: 'todo' })
|
||||||
|
},
|
||||||
openInbox(cardId?: string) {
|
openInbox(cardId?: string) {
|
||||||
setUI({ rightCollapsed: false, rightTab: 'inbox' })
|
setUI({ rightCollapsed: false, rightTab: 'work' })
|
||||||
if (cardId) setTimeout(() => {
|
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
|
||||||
document.querySelector(`[data-card-id="${cardId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
},
|
||||||
}, 60)
|
/** 左栏/收起轨的「文件」入口:打开第三栏文件入口(列表或既有预览)。 */
|
||||||
|
openFiles() { setUI({ rightTab: 'files', rightCollapsed: false }) },
|
||||||
|
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
|
||||||
|
locateMessage(messageId?: string) {
|
||||||
|
if (!messageId) return
|
||||||
|
setTimeout(() => flashLocate('.chat', 'data-msg-id', messageId), 60)
|
||||||
},
|
},
|
||||||
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
|
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
|
||||||
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
|
/**
|
||||||
|
* 工件预览唯一入口:在第三栏「文件」入口打开或激活标签(多文件多标签)。
|
||||||
|
* 打开第一张自动切到预览子视图;关掉最后一张回到文件列表,并收回展开态。
|
||||||
|
*/
|
||||||
|
requestPreview(a: Artifact) {
|
||||||
|
const conv = previewTabsKey(state.currentConvId)
|
||||||
|
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
|
||||||
|
set({
|
||||||
|
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||||
|
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||||
|
})
|
||||||
|
syncFilesEntry(true)
|
||||||
|
},
|
||||||
|
openSurface(surfaceId: string) {
|
||||||
|
// generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。
|
||||||
|
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
|
||||||
|
const conv = previewTabsKey(state.currentConvId)
|
||||||
|
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: 'surface:' + surfaceId, name: snap?.spec?.title || '页面' })
|
||||||
|
set({
|
||||||
|
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||||
|
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||||
|
})
|
||||||
|
syncFilesEntry(true)
|
||||||
|
},
|
||||||
|
// U5:页面动作提交——requestId 由客户端生成(服务端幂等键);草稿只在此刻
|
||||||
|
// 随动作外发(未提交输入不上传、不进模型上下文)。在途防抖挡双击。
|
||||||
|
async handleSurfaceAction(surfaceId: string, actionId: string, data: Record<string, unknown>) {
|
||||||
|
const inflight = surfaceActionsInflight[surfaceId + ':' + actionId]
|
||||||
|
if (inflight) return
|
||||||
|
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
|
||||||
|
if (!snap) { store.toast('页面快照不可用,无法提交'); return }
|
||||||
|
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2))
|
||||||
|
surfaceActionsInflight[surfaceId + ':' + actionId] = true
|
||||||
|
try {
|
||||||
|
const res = await api.submitSurfaceAction(surfaceId, {
|
||||||
|
actionId, revision: snap.revision, requestId, data,
|
||||||
|
})
|
||||||
|
if (res?.state === 'awaiting_confirm') store.toast('高危动作已发确认卡,请确认后执行')
|
||||||
|
else if (res?.state === 'committed') store.toast('已提交')
|
||||||
|
else store.toast('动作状态:' + (res?.state ?? 'unknown'))
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = String(e?.message ?? e)
|
||||||
|
if (msg.includes('revision conflict')) store.toast('页面已更新,请刷新后重试')
|
||||||
|
else if (msg.includes('not enabled') || msg.includes('403')) store.toast('动作被拒绝:插件未启用')
|
||||||
|
else if (msg.includes('different params')) store.toast('重复请求参数不一致,已拒绝')
|
||||||
|
else store.toast('动作失败:' + msg)
|
||||||
|
} finally {
|
||||||
|
delete surfaceActionsInflight[surfaceId + ':' + actionId]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
|
||||||
|
activatePreview(id: string) {
|
||||||
|
const conv = previewTabsKey(state.currentConvId)
|
||||||
|
set({ previewActive: { ...state.previewActive, [conv]: id } })
|
||||||
|
},
|
||||||
|
closePreview(id: string) {
|
||||||
|
const conv = previewTabsKey(state.currentConvId)
|
||||||
|
const next = closePreviewTab(state.previewTabs[conv] ?? [], state.previewActive[conv] ?? null, id)
|
||||||
|
set({
|
||||||
|
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
|
||||||
|
previewActive: { ...state.previewActive, [conv]: next.activeId },
|
||||||
|
})
|
||||||
|
if (next.tabs.length === 0) setUI({ previewExpanded: false })
|
||||||
|
syncFilesEntry(next.tabs.length > 0)
|
||||||
|
},
|
||||||
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
|
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
|
||||||
|
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
|
||||||
toast(msg: string) {
|
toast(msg: string) {
|
||||||
set({ toast: msg })
|
set({ toast: msg })
|
||||||
setTimeout(() => { if (state.toast === msg) set({ toast: null }) }, 2600)
|
setTimeout(() => { if (state.toast === msg) set({ toast: null }) }, 2600)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function touchLive(_runId: string, mutate: (l: LiveState) => void): LiveState | null {
|
function touchLive(_runId: string, mutate: (l: LiveState) => void, now = Date.now()): LiveState | null {
|
||||||
const base = state.live ?? { runId: _runId, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() }
|
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: now }
|
||||||
const next: LiveState = { ...base, toolCalls: [...base.toolCalls], agentNotes: { ...base.agentNotes } }
|
const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } }
|
||||||
mutate(next)
|
mutate(next)
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
@@ -453,6 +1003,33 @@ export function isTerminal(status?: string): boolean {
|
|||||||
return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'interrupted'
|
return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'interrupted'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+585
-155
@@ -1,203 +1,633 @@
|
|||||||
/* 设计令牌与 UI 组件样式已迁入 Agent-System-UI(@ui/tokens.css、@ui/ui.css,main.tsx 引入)。 */
|
/* 宿主样式(R5/Zai 复刻,2026-09-30;权威规则见父仓 DESIGN.md):
|
||||||
|
- 颜色/圆角/字号只消费 @ui/tokens.css 令牌(--fs-ui-* 标尺);
|
||||||
|
- 动效只消费 @ui/motion.css 的 mk-* 类(本文件不得出现 @keyframes,守护测试拦截);
|
||||||
|
- 布局面板化:三栏为浮在 --bg-inset 底座上的圆角面板,间隙 4px; */
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body, #root { height: 100%; margin: 0; }
|
html, body, #root { height: 100%; margin: 0; }
|
||||||
body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 14px; }
|
body {
|
||||||
button { font-family: inherit; }
|
font-family: var(--font); background: var(--bg-inset); color: var(--text);
|
||||||
|
font-size: var(--fs-ui-base); line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||||
|
::selection { background: var(--accent-soft-2); }
|
||||||
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
|
||||||
|
|
||||||
.app { display: flex; height: 100vh; overflow: hidden; }
|
/* ---- 面板化三栏:独立圆角面板 + 4px 间隙(DESIGN.md §6) ---- */
|
||||||
|
.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; }
|
||||||
.left-nav { background: var(--bg-panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; flex-shrink: 0; transition: width .08s ease; }
|
.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-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; }
|
.left-nav.collapsed { width: 52px !important; }
|
||||||
.divider { width: 4px; cursor: col-resize; background: transparent; flex-shrink: 0; position: relative; }
|
.divider { width: 4px; cursor: col-resize; background: transparent; flex-shrink: 0; position: relative; }
|
||||||
.divider:hover, .divider.dragging { background: var(--accent); }
|
.divider::before { content: ''; position: absolute; left: 1px; top: 12px; bottom: 12px; width: 2px; border-radius: 2px; background: transparent; transition: background .12s; }
|
||||||
|
.divider:hover::before, .divider.dragging::before { background: var(--border-strong); }
|
||||||
.divider::after { content: ''; position: absolute; inset: 0 -3px; } /* 拖拽热区加宽 */
|
.divider::after { content: ''; position: absolute; inset: 0 -3px; } /* 拖拽热区加宽 */
|
||||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); }
|
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
|
||||||
.right-panel { background: var(--bg-panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
|
/* 作者 display 声明会压过 UA 的 [hidden],此处显式恢复;展开预览靠它隐藏对话列 */
|
||||||
|
.main[hidden] { display: none !important; }
|
||||||
|
/* 第三栏(工作|文件):文件入口承载预览,展开态铺满主区 */
|
||||||
|
.right-panel { background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
|
||||||
|
.right-panel.expanded { flex: 1 1 auto; width: auto; }
|
||||||
.right-panel.collapsed { width: 44px !important; }
|
.right-panel.collapsed { width: 44px !important; }
|
||||||
|
/* 预览嵌进第三栏后去掉独立面板外壳(圆角/描边由右栏提供,纸面仍用 --bg) */
|
||||||
|
.right-panel .preview-pane { flex: 1; min-height: 0; border: none; border-radius: 0; background: var(--bg); }
|
||||||
|
|
||||||
/* ---- 左栏 ---- */
|
/* ---- 通用控件 ---- */
|
||||||
.nav-header { padding: 14px 14px 8px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
|
.btn {
|
||||||
.nav-header button { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px; padding: 4px; border-radius: var(--radius-sm); }
|
background: var(--primary); color: var(--primary-foreground); border: none;
|
||||||
.nav-header button:hover { background: var(--bg-hover); color: var(--text); }
|
border-radius: var(--radius-sm); padding: 8px 16px; cursor: pointer;
|
||||||
.nav-scroll { flex: 1; overflow-y: auto; padding: 4px 8px 8px; }
|
font-size: var(--fs-ui-caption); font-weight: 550; white-space: nowrap;
|
||||||
.nav-project { margin-bottom: 4px; }
|
box-shadow: 0 1px 2px rgba(0, 0, 0, .25);
|
||||||
.nav-project-name { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 500; cursor: pointer; user-select: none; }
|
transition: background .12s var(--ease), transform .12s var(--ease);
|
||||||
.nav-project-name:hover { background: var(--bg-hover); }
|
}
|
||||||
.nav-project-name .chev { font-size: 10px; transition: transform .12s; }
|
.btn:hover:not(:disabled) { background: color-mix(in srgb, var(--primary) 86%, var(--bg)); }
|
||||||
.nav-project-name.open .chev { transform: rotate(90deg); }
|
.btn:active:not(:disabled) { transform: translateY(1px); }
|
||||||
.nav-conv { padding: 5px 8px 5px 26px; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.btn:disabled { opacity: .42; cursor: not-allowed; box-shadow: none; }
|
||||||
.nav-conv:hover { background: var(--bg-hover); color: var(--text); }
|
.btn.ghost { background: var(--bg-raised); border: 1px solid var(--border-strong); color: var(--text-dim); box-shadow: none; }
|
||||||
.nav-conv.active { background: var(--accent-soft); color: var(--accent); }
|
.btn.ghost:hover:not(:disabled) { color: var(--text); border-color: var(--text-faint); background: var(--bg-raised); }
|
||||||
.nav-footer { border-top: 1px solid var(--border); padding: 8px; display: flex; gap: 6px; }
|
.btn.danger { background: var(--err-text); color: var(--text-on-semantic); }
|
||||||
.nav-footer button { flex: 1; background: none; border: none; color: var(--text-dim); padding: 7px; border-radius: var(--radius-sm); cursor: pointer; font-size: 12px; }
|
.icon-btn {
|
||||||
.nav-footer button:hover { background: var(--bg-hover); color: var(--text); }
|
background: none; border: none; color: var(--text-faint);
|
||||||
.left-nav.collapsed .nav-label { display: none; }
|
border-radius: var(--radius-sm); padding: 0; width: 28px; height: 28px;
|
||||||
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 10px; }
|
display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
|
||||||
.icon-only button { background: none; border: none; color: var(--text-dim); font-size: 16px; cursor: pointer; padding: 8px; border-radius: var(--radius-sm); }
|
transition: background .12s, color .12s;
|
||||||
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
|
}
|
||||||
|
.icon-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.search-input, .cmd-menu input {
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
color: var(--text); padding: 7px 11px; font-family: inherit; font-size: var(--fs-ui-caption); width: 100%;
|
||||||
|
transition: border-color .12s, box-shadow .12s;
|
||||||
|
}
|
||||||
|
.search-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
|
||||||
|
|
||||||
/* ---- 中栏对话 ---- */
|
/* ---- 中栏对话 ---- */
|
||||||
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
/* min-height:0 须随列向 flex 链下传(.main→.chat→.chat-body):flex 项的自动最小
|
||||||
.chat-header { padding: 10px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; background: var(--bg-raised); }
|
高度是内容高度,缺失时消息会撑破 .chat——chat-body 永不滚动、输入栏被顶出视口。 */
|
||||||
.chat-title { font-weight: 600; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.chat { position: relative; flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||||||
.chat-body { flex: 1; overflow-y: auto; padding: 18px max(18px, 12%); }
|
.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; }
|
||||||
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 8px; }
|
.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; }
|
||||||
.msg { margin-bottom: 18px; }
|
.chat-body { flex: 1; overflow-y: auto; min-height: 0; padding: 22px max(22px, 13%); }
|
||||||
.msg-role { font-size: 12px; color: var(--text-faint); margin-bottom: 4px; display: flex; gap: 8px; align-items: center; }
|
.jump-bottom {
|
||||||
.msg.user .bubble { background: var(--accent-soft); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; white-space: pre-wrap; }
|
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-text); font-size: var(--fs-ui-sm);
|
||||||
|
}
|
||||||
|
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-dim); 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-dim); 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;
|
||||||
|
}
|
||||||
.part { margin: 4px 0; }
|
.part { margin: 4px 0; }
|
||||||
.part-thinking { color: var(--text-faint); font-size: 13px; border-left: 2px solid var(--border); padding-left: 10px; white-space: pre-wrap; }
|
/* 思考块(Collapsible 契约:触发行 + mk-col 动画体) */
|
||||||
.part-tool { font-size: 12.5px; }
|
.part-thinking { color: var(--text-dim); font-size: var(--fs-ui-sm); border-left: 2px solid var(--border); padding-left: 10px; margin: 4px 0; }
|
||||||
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 8px; margin: 2px 4px 2px 0; font-family: var(--mono); }
|
.part-thinking .part-thinking-summary { color: var(--text-faint); user-select: none; }
|
||||||
.tool-chip .status-ok { color: var(--ok); }
|
.part-thinking .part-thinking-chev { display: inline-flex; color: var(--text-faint); font-size: var(--fs-ui-xs); transition: transform .12s; }
|
||||||
.tool-chip .status-error { color: var(--err); }
|
.part-thinking .mk-col-trigger[aria-expanded="true"] .part-thinking-chev { transform: rotate(90deg); }
|
||||||
.part-text { white-space: pre-wrap; line-height: 1.7; }
|
.part-thinking .part-thinking-body { white-space: pre-wrap; margin-top: 4px; }
|
||||||
.part-text :is(h1,h2,h3) { margin: 0.6em 0 0.3em; }
|
.part-tool { font-size: var(--fs-ui-sm); }
|
||||||
.part-text pre { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; }
|
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 4px 9px; margin: 2px 4px 2px 0; font-family: var(--mono); font-size: var(--fs-code); }
|
||||||
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
|
.tool-chip .status-ok { color: var(--ok-text); }
|
||||||
.part-text :is(p,ul,ol) { margin: 0.4em 0; }
|
.tool-chip .status-error { color: var(--err-text); }
|
||||||
.part-error { color: var(--err); background: color-mix(in srgb, var(--err) 8%, transparent); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
|
.part-text { line-height: 1.7; overflow-wrap: break-word; }
|
||||||
.artifact-chip { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); background: var(--accent-soft); border-radius: var(--radius-sm); padding: 4px 10px; cursor: pointer; font-size: 13px; }
|
/* 块级间距只由标签自身控制:禁用 pre-wrap(marked 输出的块间换行会被渲染成空行,
|
||||||
.artifact-chip:hover { filter: brightness(1.15); }
|
叠加出"列表逐项空一行"的松散排版);行内换行语义交给 Markdown 段落。 */
|
||||||
|
.part-text > *:first-child { margin-top: 0; }
|
||||||
|
.part-text > *:last-child { margin-bottom: 0; }
|
||||||
|
.part-text p { margin: 0.5em 0; }
|
||||||
|
.part-text :is(h1,h2,h3,h4) { margin: 0.8em 0 0.35em; line-height: 1.35; font-weight: 600; letter-spacing: -0.01em; }
|
||||||
|
.part-text h1 { font-size: 1.3em; }
|
||||||
|
.part-text h2 { font-size: 1.18em; }
|
||||||
|
.part-text h3 { font-size: 1.06em; }
|
||||||
|
.part-text :is(ul,ol) { margin: 0.4em 0; padding-left: 1.6em; }
|
||||||
|
.part-text li { margin: 0.15em 0; }
|
||||||
|
.part-text li > p { margin: 0.2em 0; }
|
||||||
|
.part-text pre { background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 11px 13px; overflow-x: auto; font-family: var(--mono); font-size: var(--fs-code); }
|
||||||
|
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: var(--radius-xs); padding: 1px 5px; font-size: var(--fs-ui-sm); }
|
||||||
|
.part-text pre code { background: none; padding: 0; }
|
||||||
|
.part-text blockquote { margin: 0.5em 0; padding: 0.1em 0 0.1em 10px; border-left: 3px solid var(--border-strong); color: var(--text-dim); }
|
||||||
|
.part-text table { border-collapse: collapse; margin: 0.6em 0; font-size: 0.95em; display: block; max-width: 100%; overflow-x: auto; }
|
||||||
|
.part-text th, .part-text td { border: 1px solid var(--border); padding: 4px 10px; text-align: left; }
|
||||||
|
.part-text th { background: var(--bg-raised); font-weight: 600; }
|
||||||
|
.part-text hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
|
||||||
|
.part-text a { color: var(--accent-text); }
|
||||||
|
.part-error { color: var(--err-text); 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-text); background: none; border: none; padding: 5px 10px; cursor: pointer; font: inherit; max-width: 340px; }
|
||||||
|
.artifact-chip .ac-main span, .artifact-chip .ac-main { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.artifact-chip:hover { border-color: var(--accent); }
|
||||||
|
.artifact-chip .ac-act { display: inline-flex; align-items: center; padding: 5px 8px; color: var(--text-dim); border-left: 1px solid var(--border-soft); background: none; cursor: pointer; }
|
||||||
|
.artifact-chip .ac-act:hover { color: var(--accent-text); background: var(--bg-hover); }
|
||||||
|
|
||||||
/* 过程折叠(run 终态后) */
|
/* 交付物卡片:主区域与「预览」打开预览区,下载收在菜单里。 */
|
||||||
.process-collapse { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-raised); margin: 6px 0; }
|
.deliverable-card { position: relative; display: flex; align-items: center; gap: 8px; width: fit-content; max-width: 100%; padding: 8px 8px 8px 8px; margin: 6px 0 2px;
|
||||||
.process-collapse summary { list-style: none; cursor: pointer; padding: 6px 12px; color: var(--text-faint); font-size: 12.5px; display: flex; align-items: center; gap: 8px; user-select: none; }
|
background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); }
|
||||||
.process-collapse summary::before { content: '▸'; transition: transform .12s; font-size: 10px; }
|
.deliverable-card:hover { border-color: var(--accent); }
|
||||||
.process-collapse[open] summary::before { transform: rotate(90deg); }
|
.deliverable-card .dc-open { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 0; border: none; background: none; color: inherit; font: inherit; cursor: pointer; text-align: left; }
|
||||||
|
.deliverable-card .dc-icon { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: var(--radius); flex: none;
|
||||||
|
background: var(--bg-inset); color: var(--accent); border: 1px solid var(--border-soft); }
|
||||||
|
.deliverable-card .dc-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.deliverable-card .dc-name { color: var(--text); font-weight: 600; font-size: var(--fs-ui-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 380px; }
|
||||||
|
.deliverable-card .dc-meta { color: var(--text-dim); font-size: var(--fs-ui-sm); white-space: nowrap; }
|
||||||
|
.deliverable-card .dc-act { flex: none; margin-left: 8px; padding: 5px 12px; border-radius: var(--radius-sm); color: var(--accent-text);
|
||||||
|
font-size: var(--fs-ui-caption); border: 1px solid var(--border); background: var(--bg-panel); cursor: pointer; }
|
||||||
|
.deliverable-card .dc-act:hover:not(:disabled) { border-color: var(--accent); }
|
||||||
|
.deliverable-card .dc-act:disabled { opacity: .45; cursor: default; }
|
||||||
|
.deliverable-card .dc-more { flex: none; width: 28px; height: 28px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); cursor: pointer; }
|
||||||
|
.deliverable-card .dc-more:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.deliverable-card .dc-menu { position: absolute; right: 8px; top: calc(100% - 4px); z-index: 2; padding: 6px 12px; border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-panel); border: 1px solid var(--border); box-shadow: var(--shadow-md); color: var(--text); font-size: var(--fs-ui-sm); text-decoration: none; }
|
||||||
|
.missing-art { color: var(--text-faint); border-bottom: 1px dashed var(--border-strong); }
|
||||||
|
|
||||||
|
/* 工作时间线 */
|
||||||
|
.tl-tools { margin: 4px 0; }
|
||||||
|
.tl-tools-head { font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||||
|
.tl-tools-list { margin: 2px 0 0; padding: 0 0 0 16px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||||
|
.tl-tools-list li { margin: 1px 0; }
|
||||||
|
.tl-tools-name { color: var(--text-dim); }
|
||||||
|
.tl-tools-sum { margin-left: 8px; color: var(--text-dim); }
|
||||||
|
.tl-check { color: var(--text-dim); font-size: var(--fs-ui-sm); margin: 4px 0; }
|
||||||
|
.tl-tools .status-error { margin-left: 8px; color: var(--err-text); }
|
||||||
|
|
||||||
|
/* 第三栏「文件」入口内的多标签预览(外壳样式见 .right-panel .preview-pane) */
|
||||||
|
.preview-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; }
|
||||||
|
.preview-pane-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border-soft); }
|
||||||
|
.preview-pane-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||||
|
.preview-pane-spacer { flex: 1; }
|
||||||
|
.preview-pane-body { flex: 1; min-height: 0; overflow: auto; }
|
||||||
|
/* 只给下限,不钉死高度:内容超出时由本层滚动。PDF iframe 靠自身 flex:1 撑满。 */
|
||||||
|
.preview-pane-body .ui-fp { min-height: 100%; }
|
||||||
|
.preview-pane-body .ui-fp-pdf { height: 100%; min-height: 480px; border: none; }
|
||||||
|
.preview-pane-meta { flex-shrink: 0; font-size: var(--fs-ui-xs); color: var(--text-faint); text-transform: capitalize; }
|
||||||
|
.preview-pane-dirty { color: var(--warn-text); font-size: var(--fs-ui-sm); }
|
||||||
|
.preview-pane-editor {
|
||||||
|
display: block; width: 100%; height: 100%; min-height: 0; resize: none;
|
||||||
|
font-family: var(--mono); font-size: var(--fs-code); background: var(--bg); color: var(--text);
|
||||||
|
}
|
||||||
|
.docx-wrapper { background: var(--bg-inset) !important; padding: 12px; display: flex; flex-direction: column; align-items: flex-start !important; }
|
||||||
|
/* 宽栏时纸张居中。auto margin 优先于 align-items;栏比纸窄时剩余空间为负,margin 归零,
|
||||||
|
纸张仍贴左,previewfit 的 transform-origin: top left 不用改。 */
|
||||||
|
.docx-wrapper > section.docx { background: var(--paper) !important; color: var(--paper-ink); box-shadow: var(--shadow-md); margin-inline: auto; }
|
||||||
|
/* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */
|
||||||
|
.part-group { margin: 2px 0; }
|
||||||
|
|
||||||
|
/* 过程折叠(run 终态后;Collapsible 契约:触发行 + mk-col 动画体) */
|
||||||
|
.process-collapse { border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); margin: 6px 0; }
|
||||||
|
.process-collapse .process-collapse-summary { padding: 7px 12px; color: var(--text-dim); font-size: var(--fs-ui-sm); user-select: none; }
|
||||||
|
.process-collapse .process-collapse-summary:hover { color: var(--text-dim); }
|
||||||
|
.process-collapse .process-collapse-chev { display: inline-flex; color: var(--text-faint); font-size: var(--fs-ui-xs); transition: transform .12s; }
|
||||||
|
.process-collapse .mk-col-trigger[aria-expanded="true"] .process-collapse-chev { transform: rotate(90deg); }
|
||||||
|
.process-collapse .process-collapse-meta { color: var(--text-dim); }
|
||||||
|
.process-collapse .process-collapse-excerpt { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
|
||||||
.process-collapse .body { padding: 4px 12px 10px; }
|
.process-collapse .body { padding: 4px 12px 10px; }
|
||||||
|
|
||||||
/* 流式状态行 */
|
/* 流式状态行(呼吸/扫光走 mk-* 类;已工作计时弱色等宽) */
|
||||||
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: 12.5px; margin-bottom: 6px; }
|
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: var(--fs-ui-sm); margin-bottom: 6px; }
|
||||||
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s infinite; }
|
.live-elapsed { color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||||
@keyframes pulse { 0%,100% { opacity: .4 } 50% { opacity: 1 } }
|
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: mk-breathe 1.2s infinite; }
|
||||||
.cursor-blink::after { content: '▍'; animation: pulse 1s infinite; color: var(--accent); }
|
.cursor-blink::after { content: '▍'; animation: mk-breathe 1s infinite; color: var(--accent); }
|
||||||
|
|
||||||
/* HITL 卡片 */
|
/* HITL 卡片(确认类交互统一绿色语言) */
|
||||||
.hitl-card { border: 1px solid var(--accent); background: var(--bg-raised); border-radius: var(--radius); padding: 14px; margin: 10px 0; }
|
.hitl-card { border: 1px solid var(--ok-border); background: var(--ok-soft); border-radius: var(--radius-lg); padding: 14px; margin: 10px 0; }
|
||||||
.hitl-q { font-weight: 600; margin-bottom: 10px; }
|
.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-dim); 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; }
|
.hitl-choices { display: flex; flex-direction: column; gap: 8px; }
|
||||||
.hitl-choice { text-align: left; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: var(--radius-sm); padding: 10px 12px; cursor: pointer; }
|
.hitl-choice { text-align: left; border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); border-radius: var(--radius); padding: 10px 12px; cursor: pointer; transition: border-color .12s, background .12s; }
|
||||||
.hitl-choice:hover { border-color: var(--accent); background: var(--accent-soft); }
|
.hitl-choice:hover { border-color: var(--accent); background: var(--accent-soft); }
|
||||||
.hitl-choice .label { font-weight: 600; display: flex; gap: 8px; align-items: center; }
|
.hitl-choice .label { font-weight: 600; display: flex; gap: 8px; align-items: center; }
|
||||||
.hitl-choice .desc { color: var(--text-dim); font-size: 12.5px; margin-top: 3px; }
|
.hitl-choice .desc { color: var(--text-dim); font-size: var(--fs-ui-sm); margin-top: 3px; }
|
||||||
.hitl-choice .impact { color: var(--warn); font-size: 12px; margin-top: 3px; }
|
.hitl-choice .impact { color: var(--warn-text); font-size: var(--fs-ui-sm); margin-top: 3px; }
|
||||||
.hitl-choice .rec { font-size: 10.5px; color: var(--accent); border: 1px solid var(--accent); padding: 0 5px; border-radius: 8px; }
|
.hitl-choice .rec { font-size: var(--fs-ui-xs); color: var(--accent-text); border: 1px solid var(--accent); padding: 0 5px; border-radius: var(--radius-full); }
|
||||||
.hitl-free { display: flex; gap: 8px; margin-top: 10px; }
|
.hitl-free { display: flex; gap: 8px; margin-top: 10px; }
|
||||||
|
|
||||||
/* 输入区 */
|
/* 输入区:悬浮卡片式输入壳(16px 大圆角 = DESIGN.md 特批例外) */
|
||||||
.input-bar { border-top: 1px solid var(--border); padding: 12px 18px; background: var(--bg-raised); }
|
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
|
||||||
|
/* 轻量状态提示行(快慢通道):一行以内,弱色;后台任务在回合结束后仍显示 */
|
||||||
|
.input-status { display: flex; align-items: center; gap: 8px; padding: 0 4px 6px; color: var(--text-faint); font-size: var(--fs-ui-sm); }
|
||||||
|
.input-status-note { color: var(--text-faint); }
|
||||||
|
.input-status-link { border: none; background: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; padding: 0; }
|
||||||
|
.input-status-link:hover { text-decoration: underline; }
|
||||||
|
.outbox-trigger { width: 100%; text-align: left; background: var(--bg-inset); color: var(--text-dim); border: 1px solid var(--border-soft); border-radius: var(--radius-xs); padding: 6px 10px; font-size: var(--fs-ui-sm); cursor: pointer; }
|
||||||
|
.outbox-list { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.outbox-item { display: flex; gap: 6px; align-items: center; background: var(--bg-inset); border-radius: var(--radius-xs); padding: 6px 8px; }
|
||||||
|
.outbox-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-size: var(--fs-ui-sm); }
|
||||||
|
.outbox-edit { flex: 1; min-width: 0; }
|
||||||
.attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
.attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||||
.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-hover); border-radius: var(--radius-sm); padding: 3px 8px; font-size: 12px; }
|
.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 3px 8px; font-size: var(--fs-ui-sm); }
|
||||||
.input-row { display: flex; gap: 8px; align-items: flex-end; }
|
.input-row { display: flex; gap: 8px; align-items: flex-end; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 8px 8px 8px 14px; box-shadow: var(--shadow-md); transition: border-color .12s, box-shadow .12s; }
|
||||||
.input-row textarea { flex: 1; resize: none; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); padding: 10px 12px; font-family: inherit; font-size: 14px; max-height: 160px; outline: none; }
|
.input-row:focus-within { border-color: var(--accent); box-shadow: var(--shadow-md), var(--ring); }
|
||||||
.input-row textarea:focus { border-color: var(--accent); }
|
.input-row textarea { flex: 1; resize: none; background: transparent; border: none; color: var(--text); padding: 6px 0; font-family: inherit; font-size: var(--fs-ui-caption); max-height: 160px; outline: none; }
|
||||||
.btn { background: var(--accent); color: #fff; border: none; border-radius: var(--radius-sm); padding: 9px 16px; cursor: pointer; font-size: 13px; white-space: nowrap; }
|
.input-row textarea::placeholder { color: var(--text-faint); }
|
||||||
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
.cmd-menu { border: 1px solid var(--border); background: var(--bg-raised); border-radius: var(--radius); margin-bottom: 6px; max-height: 180px; overflow-y: auto; box-shadow: var(--shadow-md); }
|
||||||
.btn.ghost { background: none; border: 1px solid var(--border); color: var(--text-dim); }
|
.cmd-item { padding: 7px 12px; cursor: pointer; display: flex; gap: 10px; font-size: var(--fs-ui-caption); }
|
||||||
.btn.ghost:hover { color: var(--text); border-color: var(--text-faint); }
|
.cmd-item:hover, .cmd-item.active { background: var(--bg-hover); }
|
||||||
.btn.danger { background: var(--err); }
|
.cmd-item .name { color: var(--accent-text); font-family: var(--mono); }
|
||||||
.icon-btn { background: none; border: 1px solid var(--border); color: var(--text-dim); border-radius: var(--radius-sm); padding: 8px 10px; cursor: pointer; font-size: 14px; }
|
|
||||||
.icon-btn:hover { color: var(--text); border-color: var(--text-faint); }
|
|
||||||
.cmd-menu { border: 1px solid var(--border); background: var(--bg-raised); border-radius: var(--radius-sm); margin-bottom: 6px; max-height: 180px; overflow-y: auto; }
|
|
||||||
.cmd-item { padding: 7px 12px; cursor: pointer; display: flex; gap: 10px; font-size: 13px; }
|
|
||||||
.cmd-item:hover, .cmd-item.active { background: var(--accent-soft); }
|
|
||||||
.cmd-item .name { color: var(--accent); font-family: var(--mono); }
|
|
||||||
|
|
||||||
/* ---- 右栏 ---- */
|
/* ---- 右栏 ---- */
|
||||||
.tabs { display: flex; border-bottom: 1px solid var(--border); overflow-x: auto; }
|
.tabs { display: flex; border-bottom: 1px solid var(--border-soft); overflow-x: auto; padding: 0 8px; }
|
||||||
.tab { background: none; border: none; color: var(--text-dim); padding: 9px 12px; cursor: pointer; font-size: 12.5px; border-bottom: 2px solid transparent; white-space: nowrap; }
|
.tab { background: none; border: none; color: var(--text-faint); padding: 10px 10px; cursor: pointer; font-size: var(--fs-ui-sm); font-weight: 550; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; }
|
||||||
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
.tab:hover { color: var(--text-dim); }
|
||||||
|
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||||
.tab-body { flex: 1; overflow-y: auto; padding: 12px; }
|
.tab-body { flex: 1; overflow-y: auto; padding: 12px; }
|
||||||
.step { display: flex; gap: 8px; align-items: flex-start; padding: 6px 4px; font-size: 13px; }
|
.step { display: flex; gap: 8px; align-items: flex-start; padding: 6px 4px; font-size: var(--fs-ui-caption); }
|
||||||
.step-icon { width: 16px; text-align: center; flex-shrink: 0; }
|
.step-icon { width: 16px; text-align: center; flex-shrink: 0; }
|
||||||
.step.pending .step-icon { color: var(--text-faint); }
|
.step.pending .step-icon { color: var(--text-faint); }
|
||||||
.step.in_progress .step-icon { color: var(--accent); }
|
.step.in_progress .step-icon { color: var(--accent-text); }
|
||||||
.step.done .step-icon { color: var(--ok); }
|
.step.done .step-icon { color: var(--ok-text); }
|
||||||
.step.skipped { color: var(--text-faint); text-decoration: line-through; }
|
.step.skipped { color: var(--text-faint); text-decoration: line-through; }
|
||||||
.artifact-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; }
|
/* ---- 工件面板(工作区文件管理)---- */
|
||||||
|
.artifacts-panel { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.ap-toolbar { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||||
|
.ap-toolbar .search-input { flex: 1; min-width: 120px; height: 30px; font-size: var(--fs-ui-sm); }
|
||||||
|
.kind-pill { border: 1px solid var(--border); background: none; color: var(--text-dim); border-radius: var(--radius-full); padding: 3px 10px; font-size: var(--fs-ui-sm); cursor: pointer; }
|
||||||
|
.kind-pill:hover { color: var(--text); background: var(--bg-hover); }
|
||||||
|
.kind-pill.on { color: var(--accent-text); border-color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.ap-upload { cursor: pointer; }
|
||||||
|
.artifact-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: var(--radius-sm); }
|
||||||
.artifact-row:hover { background: var(--bg-hover); }
|
.artifact-row:hover { background: var(--bg-hover); }
|
||||||
.artifact-row .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
|
.artifact-row .ar-icon { display: flex; color: var(--text-dim); cursor: pointer; flex: none; }
|
||||||
.artifact-row .kind { font-size: 11px; color: var(--text-faint); }
|
.artifact-row .ar-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; cursor: pointer; }
|
||||||
.agent-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; }
|
.artifact-row .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--fs-ui-caption); }
|
||||||
|
.artifact-row .meta { font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.artifact-row .ar-acts { display: none; align-items: center; gap: 2px; flex: none; }
|
||||||
|
.artifact-row:hover .ar-acts { display: inline-flex; }
|
||||||
|
.artifact-row .ar-acts .icon-btn { width: 26px; height: 26px; }
|
||||||
|
.artifact-row .ar-acts .icon-btn.danger { color: var(--err-text); }
|
||||||
|
.empty-note { color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 14px 6px; }
|
||||||
|
.agent-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--fs-ui-caption); }
|
||||||
.agent-row:hover { background: var(--bg-hover); }
|
.agent-row:hover { background: var(--bg-hover); }
|
||||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
.status-running { background: var(--accent); animation: pulse 1.2s infinite; }
|
.status-running { background: var(--accent); animation: mk-breathe 1.2s infinite; }
|
||||||
.status-completed { background: var(--ok); }
|
.status-completed { background: var(--ok); }
|
||||||
.status-failed, .status-cancelled, .status-interrupted { background: var(--err); }
|
.status-failed, .status-cancelled, .status-interrupted { background: var(--err); }
|
||||||
.status-pending, .status-waiting_hitl { background: var(--warn); }
|
.status-pending, .status-waiting_hitl { background: var(--warn); }
|
||||||
|
|
||||||
/* ---- 弹窗 / 模态 ---- */
|
/* ---- 弹窗 / 模态(动画走 mk-* 类) ---- */
|
||||||
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, .55); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 50; animation: mk-overlay-in .14s var(--ease); }
|
||||||
.modal { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 12px; width: min(860px, 92vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: 0 18px 60px rgba(0,0,0,.4); }
|
.modal { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xl); width: min(860px, 92vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: var(--shadow-lg); animation: mk-modal-in .16s var(--ease); }
|
||||||
.modal-header { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; font-weight: 600; }
|
.modal-header { padding: 16px 20px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; font-weight: 600; font-size: var(--fs-ui-base); letter-spacing: -0.01em; }
|
||||||
.modal-header .spacer { flex: 1; }
|
.modal-header .spacer { flex: 1; }
|
||||||
.modal-body { flex: 1; overflow-y: auto; padding: 16px 18px; }
|
.modal-body { flex: 1; overflow-y: auto; padding: 18px 20px; }
|
||||||
.subagent-tree { font-size: 13px; }
|
.subagent-tree { font-size: var(--fs-ui-caption); }
|
||||||
.subagent-node { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; margin-bottom: 8px; }
|
.subagent-node { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; margin-bottom: 8px; background: var(--bg-panel); }
|
||||||
.subagent-node .head { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
.subagent-node .head { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
||||||
|
|
||||||
/* ---- 设置(schema 驱动)---- */
|
/* ---- 设置(schema 驱动;行组件来自 @ui SettingRow,这里只补控件样式)---- */
|
||||||
.settings-group { margin-bottom: 18px; }
|
.ui-setting-control { display: flex; align-items: center; justify-content: flex-end; }
|
||||||
.settings-group h4 { margin: 0 0 8px; font-size: 13px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; }
|
.ui-setting-control input, .ui-setting-control select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 7px 10px; font-family: inherit; width: 100%; max-width: 340px; }
|
||||||
.setting-row { display: grid; grid-template-columns: 200px 1fr auto; gap: 10px; align-items: center; padding: 7px 0; border-bottom: 1px dashed var(--border); font-size: 13px; }
|
.ui-setting-control input:focus, .ui-setting-control select:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
|
||||||
.setting-row label { color: var(--text-dim); }
|
.ui-setting-control input[type="checkbox"] { width: auto; }
|
||||||
.setting-row input, .setting-row select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 7px 10px; font-family: inherit; }
|
.effect-badge { font-size: var(--fs-ui-xs); padding: 2px 7px; border-radius: var(--radius-full); border: 1px solid var(--border); color: var(--text-faint); white-space: nowrap; }
|
||||||
.effect-badge { font-size: 10.5px; padding: 2px 7px; border-radius: 8px; border: 1px solid var(--border); color: var(--text-faint); white-space: nowrap; }
|
.effect-hot { color: var(--ok-text); border-color: var(--ok); }
|
||||||
.effect-hot { color: var(--ok); border-color: var(--ok); }
|
.effect-new-run { color: var(--warn-text); border-color: var(--warn); }
|
||||||
.effect-new-run { color: var(--warn); border-color: var(--warn); }
|
|
||||||
|
|
||||||
/* UI schema 渲染器样式(.ui-*)与 .doc-viewer 已迁入 @ui/ui.css。 */
|
|
||||||
|
|
||||||
/* ---- 杂项 ---- */
|
/* ---- 杂项 ---- */
|
||||||
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: 20px; box-shadow: 0 8px 30px rgba(0,0,0,.35); z-index: 90; font-size: 13px; }
|
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: var(--radius-full); box-shadow: var(--shadow-lg); z-index: 90; font-size: var(--fs-ui-caption); }
|
||||||
.conn-dot { width: 8px; height: 8px; border-radius: 50%; }
|
.conn-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||||
.conn-dot.on { background: var(--ok); }
|
.conn-dot.on { background: var(--ok); }
|
||||||
.conn-dot.off { background: var(--err); }
|
.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; }
|
.doc-toolbar { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; }
|
||||||
.search-input { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 6px 10px; font-family: inherit; width: 100%; }
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
::-webkit-scrollbar { width: 9px; height: 9px; }
|
::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 6px; border: 3px solid transparent; background-clip: content-box; }
|
||||||
::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 5px; }
|
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); background-clip: content-box; border: 3px solid transparent; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
/* ---- 登录门(API key 认证,PG 档默认开启) ---- */
|
/* ---- 品牌标(侧栏 / 登录页 / 头像共用的星芒渐变底;辅色为品牌令牌 --brand-2) ---- */
|
||||||
.login-gate {
|
.brand-mark { background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); display: inline-flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.22); }
|
||||||
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
|
|
||||||
background: var(--bg, #14161a); z-index: 1000;
|
|
||||||
}
|
|
||||||
.login-card {
|
|
||||||
width: 360px; padding: 28px; border-radius: 12px; text-align: center;
|
|
||||||
background: #1d2026; border: 1px solid #2c313a; box-shadow: 0 8px 32px rgba(0,0,0,.45);
|
|
||||||
}
|
|
||||||
.login-card h2 { margin: 0 0 8px; }
|
|
||||||
.login-hint { font-size: 12px; color: #9aa3b2; margin-bottom: 16px; line-height: 1.6; }
|
|
||||||
.login-card input {
|
|
||||||
width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 8px;
|
|
||||||
border: 1px solid #2c313a; background: #14161a; color: inherit; font-size: 14px;
|
|
||||||
}
|
|
||||||
.login-card button {
|
|
||||||
margin-top: 12px; width: 100%; padding: 9px 0; border: none; border-radius: 8px;
|
|
||||||
background: #3b82f6; color: #fff; font-size: 14px; cursor: pointer;
|
|
||||||
}
|
|
||||||
.login-err { margin-top: 10px; font-size: 12px; color: #f87171; }
|
|
||||||
|
|
||||||
/* 工作区快捷区(T4/D26 左栏)与当前任务常驻卡(D25 R4) */
|
/* ---- 左栏:项目导航(方案 A;三栏工作台重排:切换置顶、会话居中、文件沉底) ---- */
|
||||||
.ws-quick{padding:6px 8px 8px;border-bottom:1px solid var(--border)}
|
.tsb-brand { display: flex; align-items: center; gap: 6px; padding: 12px 12px 8px; }
|
||||||
.ws-quick-list{display:flex;flex-direction:column;gap:2px;margin-top:4px}
|
.tsb-projrow { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; height: 32px; padding: 0 9px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); cursor: pointer; font-size: var(--fs-ui-caption); font-weight: 600; }
|
||||||
.ws-quick-item{display:flex;align-items:center;gap:8px;padding:4px 8px;border:none;background:transparent;color:inherit;cursor:pointer;border-radius:6px;text-align:left;font-size:12px}
|
.tsb-projrow:hover { border-color: var(--accent-sidebar); }
|
||||||
.ws-quick-item:hover{background:rgba(120,160,255,.12)}
|
.tsb-projrow-name { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.ws-quick-item .sc{margin-left:auto;font-size:10px;color:var(--text-faint);border:1px solid var(--border);border-radius:3px;padding:0 4px}
|
.tsb-projrow-stage { flex: none; font-size: var(--fs-ui-xs); font-weight: 450; color: var(--text-faint); border: 1px solid var(--border); border-radius: var(--radius-full); padding: 0 6px; }
|
||||||
.current-task{border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:8px;background:rgba(120,160,255,.05)}
|
.tsb-projrow .tsb-user-chev { color: var(--text-faint); display: inline-flex; flex: none; }
|
||||||
.ct-head{display:flex;align-items:center;gap:8px;font-weight:600;margin-bottom:6px}
|
.tsb-logo { width: 24px; height: 24px; border-radius: var(--radius); background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
|
||||||
.ct-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.tsb-name { font-weight: 650; font-size: var(--fs-ui-caption); letter-spacing: -0.01em; }
|
||||||
.ct-progress{font-size:11px;color:var(--text-faint)}
|
.tsb-iconbtn { width: 26px; height: 26px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: var(--fs-ui-caption); display: inline-flex; align-items: center; justify-content: center; position: relative; }
|
||||||
.project-badge{font-size:11px;padding:1px 8px;border-radius:8px;background:rgba(120,160,255,.15);color:var(--accent);white-space:nowrap}
|
.tsb-iconbtn:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
.tab-hint{font-size:10px;color:var(--text-faint);align-self:center}
|
.tsb-iconbtn.active { color: var(--accent-sidebar); background: var(--accent-soft); }
|
||||||
.empty-note{color:var(--text-faint);font-size:12px;padding:12px 4px}
|
|
||||||
|
|
||||||
/* 收纳策略(T5):收起态角标 + 收纳按钮 */
|
.tsb-actions { padding: 6px 12px 8px; display: flex; flex-direction: column; gap: 7px; }
|
||||||
.rail-badge{position:absolute;top:-4px;right:-6px;font-size:10px;background:var(--warn,#d29922);color:#fff;border-radius:8px;padding:0 5px;min-width:14px}
|
.tsb-newtask { display: flex; height: 36px; border-radius: var(--radius); background: var(--primary-sidebar); color: var(--primary-sidebar-foreground); overflow: hidden; box-shadow: 0 1px 3px rgba(0, 0, 0, .3); transition: background .12s; }
|
||||||
.right-panel.collapsed .icon-only button{position:relative}
|
.tsb-newtask:hover { background: color-mix(in srgb, var(--primary-sidebar) 86%, var(--bg-sidebar)); }
|
||||||
.tab-action{font-size:11px;color:var(--text-faint)}
|
.tsb-newtask-main { flex: 1; display: flex; align-items: center; gap: 7px; padding: 0 11px; background: none; border: none; color: inherit; font-weight: 600; font-size: var(--fs-ui-caption); cursor: pointer; min-width: 0; }
|
||||||
|
.tsb-newtask-main svg { flex: none; }
|
||||||
|
.tsb-newtask-proj { font-weight: 450; font-size: var(--fs-ui-xs); opacity: .75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.tsb-newtask-caret { width: 30px; border: none; border-left: 1px solid color-mix(in srgb, var(--primary-sidebar-foreground) 18%, transparent); background: none; color: inherit; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.tsb-newtask-caret:hover { background: color-mix(in srgb, var(--primary-sidebar-foreground) 8%, transparent); }
|
||||||
|
.tsb-search { display: flex; align-items: center; gap: 7px; height: 32px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-faint); padding: 0 10px; transition: border-color .12s, box-shadow .12s; }
|
||||||
|
.tsb-search:focus-within { border-color: var(--accent-sidebar); box-shadow: var(--ring); }
|
||||||
|
.tsb-search svg { flex: none; }
|
||||||
|
.tsb-search input { flex: 1; border: none; background: none; color: var(--text); font-size: var(--fs-ui-sm); outline: none; min-width: 0; }
|
||||||
|
.tsb-search input::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.tsb-orgrow { display: flex; align-items: center; gap: 6px; padding: 0 12px 8px; }
|
||||||
|
.tsb-pills { flex: 1; display: flex; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 2px; gap: 2px; }
|
||||||
|
.tsb-pill { flex: 1; height: 22px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: var(--fs-ui-sm); cursor: pointer; transition: all .12s; }
|
||||||
|
.tsb-pill:hover { color: var(--text-dim); }
|
||||||
|
.tsb-pill.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
|
||||||
|
|
||||||
|
.tsb-scroll { flex: 1; overflow-y: auto; padding: 0 10px 8px; min-height: 0; }
|
||||||
|
.tsb-seclabel { font-size: var(--fs-ui-sm); color: var(--text-dim); padding: 12px 8px 4px; font-weight: 600; }
|
||||||
|
.tsb-empty { padding: 20px 14px; color: var(--text-dim); font-size: var(--fs-ui-sm); line-height: 1.9; text-align: center; }
|
||||||
|
.tsb-newproject { padding: 0 2px 8px; }
|
||||||
|
|
||||||
|
.tsb-group { margin-bottom: 2px; }
|
||||||
|
.tsb-group-head { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 6px 0 4px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 550; font-size: var(--fs-ui-caption); cursor: pointer; user-select: none; }
|
||||||
|
.tsb-group-head:hover { background: var(--bg-hover); }
|
||||||
|
.tsb-group.closed .tsb-chev { transform: rotate(0deg); }
|
||||||
|
.tsb-chev { display: inline-flex; color: var(--text-faint); transform: rotate(90deg); transition: transform .12s; }
|
||||||
|
.tsb-group-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.tsb-group-cnt { font-size: var(--fs-ui-xs); color: var(--text-faint); font-weight: 400; font-variant-numeric: tabular-nums; }
|
||||||
|
.tsb-group-add { visibility: hidden; width: 22px; height: 22px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||||
|
.tsb-group-head:hover .tsb-group-add { visibility: visible; }
|
||||||
|
.tsb-group-add:hover { background: var(--bg-active); color: var(--text); }
|
||||||
|
|
||||||
|
.tsb-task { display: flex; align-items: center; gap: 8px; height: 31px; padding: 0 8px; margin: 1px 0; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; position: relative; transition: background .1s, color .1s; }
|
||||||
|
.tsb-task:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.tsb-task.active { background: var(--bg-active); color: var(--text); }
|
||||||
|
.tsb-task.active::before { content: ''; position: absolute; left: -2px; top: 6px; bottom: 6px; width: 2.5px; border-radius: 2px; background: var(--accent-sidebar); }
|
||||||
|
.tsb-task.archived .tsb-task-t { text-decoration: line-through; opacity: .6; }
|
||||||
|
.tsb-task-t { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--fs-ui-caption); }
|
||||||
|
.tsb-task-tm { font-size: var(--fs-ui-xs); color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||||
|
.tsb-pbadge { font-size: var(--fs-ui-xs); color: var(--text-dim); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 0 5px; line-height: 1.5; white-space: nowrap; }
|
||||||
|
.tsb-row-acts { display: none; gap: 2px; }
|
||||||
|
.tsb-task:hover .tsb-row-acts { display: inline-flex; }
|
||||||
|
.tsb-task:hover .tsb-task-tm { display: none; }
|
||||||
|
.tsb-row-act { border: none; background: none; color: var(--text-faint); cursor: pointer; padding: 3px; border-radius: var(--radius-xs); display: inline-flex; }
|
||||||
|
.tsb-row-act:hover { background: var(--bg-active); color: var(--text); }
|
||||||
|
|
||||||
|
.tsb-footer { display: flex; align-items: center; gap: 4px; padding: 10px 12px; border-top: 1px solid var(--border-soft); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
|
||||||
|
/* 底部沉底区(方案:文件、工作区在账户上方) */
|
||||||
|
.tsb-bottom { display: flex; flex-direction: column; gap: 1px; padding: 6px 10px 2px; border-top: 1px solid var(--border-soft); }
|
||||||
|
.tsb-user { flex: 1; display: flex; align-items: center; gap: 9px; min-width: 0; height: 40px; padding: 0 7px; border: none; border-radius: var(--radius); background: none; cursor: pointer; text-align: left; transition: background .12s; }
|
||||||
|
.tsb-user:hover { background: var(--bg-hover); }
|
||||||
|
.tsb-avatar { width: 27px; height: 27px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.2); }
|
||||||
|
.tsb-user-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.tsb-user-name { font-size: var(--fs-ui-caption); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.tsb-user-sub { font-size: var(--fs-ui-xs); color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.tsb-user-chev { color: var(--text-faint); display: inline-flex; }
|
||||||
|
|
||||||
|
.tsb-menu { position: fixed; z-index: 300; min-width: 216px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius); box-shadow: var(--shadow-lg); padding: 5px; display: flex; flex-direction: column; animation: mk-modal-in .12s var(--ease);
|
||||||
|
/* 菜单挂在侧栏里,但纸面是主区底。侧栏对 --text* 的重映射不能漏进来。 */
|
||||||
|
--text: var(--text-workspace); --text-dim: var(--text-workspace-dim); --text-faint: var(--text-workspace-faint);
|
||||||
|
color: var(--text-workspace); }
|
||||||
|
.tsb-mi { display: flex; align-items: center; gap: 9px; min-height: 31px; padding: 4px 9px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); font-size: var(--fs-ui-caption); cursor: pointer; text-align: left; }
|
||||||
|
.tsb-mi svg { color: var(--text-faint); flex: none; }
|
||||||
|
.tsb-mi:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.tsb-mi:hover svg { color: var(--text-dim); }
|
||||||
|
.tsb-mi.danger { color: var(--err-text); }
|
||||||
|
.tsb-mi.danger:hover { background: var(--err-text); color: var(--text-on-semantic); }
|
||||||
|
.tsb-mi.danger:hover svg { color: var(--text-on-semantic); }
|
||||||
|
.tsb-mi kbd { margin-left: auto; font-size: var(--fs-ui-xs); font-family: var(--mono); color: var(--text-faint); }
|
||||||
|
.tsb-menu-div { height: 1px; background: var(--border-soft); margin: 5px 4px; }
|
||||||
|
.tsb-acc-head { display: flex; gap: 10px; align-items: center; padding: 8px 9px 6px; }
|
||||||
|
.tsb-acc-name { font-size: var(--fs-ui-caption); font-weight: 600; }
|
||||||
|
.tsb-acc-mail { font-size: var(--fs-ui-xs); color: var(--text-faint); }
|
||||||
|
|
||||||
|
/* ---- 登录页与改密门(Zai 复刻:近单色深底 + 深海军品牌面;品牌面板固定深底浅字) ---- */
|
||||||
|
.lg-wrap { position: fixed; inset: 0; display: flex; align-items: stretch; background: var(--bg-inset); z-index: 500; }
|
||||||
|
.lg-panel { flex: none; width: 46%; max-width: 620px; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; padding: 64px; color: #dfe4f2; background: linear-gradient(160deg, #0e1116 0%, #101826 55%, #0b1f38 100%); }
|
||||||
|
.lg-panel::before { content: ''; position: absolute; width: 560px; height: 560px; border-radius: 50%; background: radial-gradient(closest-side, rgba(64,153,255,.16), transparent 70%); top: -140px; right: -160px; }
|
||||||
|
.lg-panel::after { content: ''; position: absolute; width: 440px; height: 440px; border-radius: 50%; background: radial-gradient(closest-side, rgba(123,92,229,.12), transparent 70%); bottom: -150px; left: -120px; }
|
||||||
|
.lg-panel h2 { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.35; margin: 0 0 12px; position: relative; }
|
||||||
|
.lg-panel p { font-size: var(--fs-ui-base); opacity: .72; line-height: 1.75; margin: 0; position: relative; }
|
||||||
|
.lg-panel ul { margin: 28px 0 0; list-style: none; display: flex; flex-direction: column; gap: 13px; position: relative; }
|
||||||
|
.lg-panel li { display: flex; gap: 10px; align-items: flex-start; font-size: var(--fs-ui-caption); opacity: .88; }
|
||||||
|
.lg-panel li svg { margin-top: 2px; color: var(--accent-strong); flex: none; }
|
||||||
|
.lg-panel .lg-foot { position: absolute; bottom: 28px; left: 64px; right: 64px; display: flex; justify-content: space-between; font-size: var(--fs-ui-xs); opacity: .45; }
|
||||||
|
@media (max-width: 960px) { .lg-panel { display: none; } }
|
||||||
|
.lg-formside { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; min-width: 0; }
|
||||||
|
.lg-card { width: 392px; max-width: 100%; }
|
||||||
|
.lg-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: var(--fs-ui-base); letter-spacing: -0.01em; margin-bottom: 26px; }
|
||||||
|
.lg-title { margin: 0 0 4px; font-size: var(--fs-ui-xl); font-weight: 700; letter-spacing: -0.02em; }
|
||||||
|
.lg-sub { margin: 0 0 18px; font-size: var(--fs-ui-sm); color: var(--text-faint); line-height: 1.6; }
|
||||||
|
.lg-tabs { display: flex; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 3px; gap: 3px; margin-bottom: 16px; }
|
||||||
|
.lg-tab { flex: 1; height: 30px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: var(--fs-ui-sm); cursor: pointer; transition: all .12s; }
|
||||||
|
.lg-tab:hover { color: var(--text-dim); }
|
||||||
|
.lg-tab.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
|
||||||
|
.lg-label { display: block; font-size: var(--fs-ui-sm); font-weight: 550; color: var(--text-dim); margin: 12px 0 6px; }
|
||||||
|
.lg-input { width: 100%; height: 38px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); padding: 0 12px; font-size: var(--fs-ui-caption); transition: border-color .12s, box-shadow .12s; }
|
||||||
|
.lg-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
|
||||||
|
.lg-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||||
|
.lg-err { margin-top: 12px; font-size: var(--fs-ui-sm); color: var(--err-text); background: var(--err-soft); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
|
||||||
|
.lg-err svg { flex: none; margin-top: 1px; }
|
||||||
|
.lg-submit {
|
||||||
|
width: 100%; height: 38px; margin-top: 16px; border: none; border-radius: var(--radius);
|
||||||
|
background: var(--primary); color: var(--primary-foreground); font-weight: 600; font-size: var(--fs-ui-caption); cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, .3);
|
||||||
|
transition: background .12s, transform .12s;
|
||||||
|
}
|
||||||
|
.lg-submit:disabled { opacity: .45; cursor: default; box-shadow: none; }
|
||||||
|
.lg-submit:not(:disabled):hover { background: color-mix(in srgb, var(--primary) 86%, var(--bg)); }
|
||||||
|
.lg-submit:not(:disabled):active { transform: translateY(1px); }
|
||||||
|
.lg-cancel { width: 100%; margin-top: 8px; border: none; background: none; color: var(--text-faint); font-size: var(--fs-ui-sm); cursor: pointer; }
|
||||||
|
.lg-cancel:hover { color: var(--text-dim); text-decoration: underline; }
|
||||||
|
.lg-hint { margin: 14px 0 0; font-size: var(--fs-ui-xs); color: var(--text-faint); line-height: 1.7; }
|
||||||
|
.lg-hint code { font-family: var(--mono); font-size: var(--fs-ui-xs); background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 1px 5px; }
|
||||||
|
|
||||||
|
/* ---- 系统管理(AdminZone,重设计方案 阶段②):管理员专属全屏视图 ---- */
|
||||||
|
.az { position: fixed; inset: 4px; z-index: 70; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; animation: mk-modal-in .16s var(--ease); }
|
||||||
|
.az-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border-soft); background: var(--bg-panel); }
|
||||||
|
.az-title { font-weight: 650; font-size: var(--fs-ui-base); letter-spacing: -0.01em; }
|
||||||
|
.az-sub { font-size: var(--fs-ui-xs); color: var(--text-faint); }
|
||||||
|
.az-tenant { font-size: var(--fs-ui-xs); color: var(--text-faint); font-family: var(--mono); border: 1px solid var(--border); border-radius: var(--radius-full); padding: 2px 10px; }
|
||||||
|
.az-body { flex: 1; min-height: 0; display: flex; }
|
||||||
|
.az-side { width: 220px; flex: none; border-right: 1px solid var(--border-soft); background: var(--bg-panel); padding: 10px 8px; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.az-nav-item { display: flex; align-items: center; gap: 9px; padding: 9px 11px; border: none; background: none; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); cursor: pointer; text-align: left; }
|
||||||
|
.az-nav-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.az-nav-item.on { background: var(--bg-active); color: var(--text); font-weight: 550; }
|
||||||
|
.az-nav-item.on svg { color: var(--accent-text); }
|
||||||
|
.az-main { flex: 1; min-width: 0; overflow-y: auto; padding: 20px 24px; }
|
||||||
|
.az-section-head { margin-bottom: 14px; }
|
||||||
|
.az-section-head h3 { margin: 0 0 4px; font-size: var(--fs-ui-lg); font-weight: 600; letter-spacing: -0.01em; }
|
||||||
|
.az-section-head p { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-faint); }
|
||||||
|
|
||||||
|
/* ---- 用户管理 / 改密弹层 ---- */
|
||||||
|
.ad-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; }
|
||||||
|
.ad-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); }
|
||||||
|
.ad-row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.ad-row .lg-input { flex: 1; min-width: 0; height: 34px; }
|
||||||
|
.ad-check { display: flex; align-items: center; gap: 5px; font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; }
|
||||||
|
.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; }
|
||||||
|
.ad-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.ad-mini:hover { color: var(--text); border-color: var(--accent); }
|
||||||
|
.ad-hint { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||||
|
.ad-notice { font-size: var(--fs-ui-sm); color: var(--ok-text); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
|
||||||
|
.ad-notice svg { flex: none; margin-top: 1px; }
|
||||||
|
.ad-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
|
||||||
|
.ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
|
||||||
|
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||||
|
.ad-table tr:last-child td { border-bottom: none; }
|
||||||
|
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||||
|
.ad-dim { color: var(--text-dim); }
|
||||||
|
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
|
||||||
|
.ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
|
||||||
|
.ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
|
||||||
|
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
|
||||||
|
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
|
||||||
|
.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
|
||||||
|
|
||||||
|
/* ---- 收起轨道(左栏收起态) ---- */
|
||||||
|
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; }
|
||||||
|
.icon-only button { background: none; border: none; color: var(--text-faint); cursor: pointer; padding: 0; width: 34px; height: 34px; border-radius: var(--radius); display: inline-flex; align-items: center; justify-content: center; position: relative; }
|
||||||
|
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.rail-badge { position: absolute; top: 3px; right: 3px; min-width: 14px; height: 14px; border-radius: 7px; background: var(--warn-text); color: var(--text-on-semantic); font-size: var(--fs-ui-xs); font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; }
|
||||||
|
|
||||||
|
/* ---- UI 能力平台消费(消息头像列 / 设置外壳 / 管理弹层头像) ---- */
|
||||||
|
.role-mark { width: 20px; height: 20px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
|
||||||
|
.role-mark.role-assistant { background: none; border: none; padding: 0; }
|
||||||
|
.msg.user .msg-role { align-items: center; }
|
||||||
|
.ad-user-cell { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
.artifact-chip .ac-act { border: none; background: none; }
|
||||||
|
|
||||||
|
/* 项目收件箱(CF3/D51):会话/项目范围分段控件(令牌对齐 DESIGN.md,阶段④合入主线时归一) */
|
||||||
|
.inbox-scope {
|
||||||
|
display: flex; gap: 4px; padding: 6px 14px 2px;
|
||||||
|
}
|
||||||
|
.inbox-scope button {
|
||||||
|
border: 1px solid var(--border); background: transparent; color: inherit;
|
||||||
|
border-radius: var(--radius-full); padding: 2px 12px; font-size: var(--fs-ui-sm); cursor: pointer; opacity: .72;
|
||||||
|
}
|
||||||
|
.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-dim); 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-dim); 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-text); color: var(--text-on-semantic); 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-text); font-size: var(--fs-ui-sm); }
|
||||||
|
|
||||||
|
/* ---- U4 追溯审计工作区(令牌驱动;@ui/tokens.css 消费) ---- */
|
||||||
|
.rdm-audit { display: flex; flex-direction: column; min-height: 300px; gap: 0; }
|
||||||
|
.rdm-audit-header { display: flex; align-items: center; gap: 10px; padding: 0 0 10px; }
|
||||||
|
.rdm-audit-project { max-width: 220px; height: 32px; font-size: var(--fs-ui-sm); }
|
||||||
|
.rdm-audit-tabs { padding: 0 0 10px; }
|
||||||
|
.rdm-audit-body { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
|
||||||
|
.rdm-audit-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; margin-bottom: 12px; }
|
||||||
|
.rdm-audit-card { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; text-align: center; }
|
||||||
|
.rdm-audit-card-num { font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
|
||||||
|
.rdm-audit-card-label { font-size: var(--fs-ui-xs); color: var(--text-dim); margin-top: 2px; }
|
||||||
|
.rdm-audit-meta { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 8px; }
|
||||||
|
.rdm-audit-materials { display: flex; gap: 0; min-height: 200px; }
|
||||||
|
.rdm-audit-materials-side { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.rdm-audit-tree { display: flex; gap: 14px; min-height: 200px; }
|
||||||
|
.rdm-audit-tree-list { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.rdm-audit-tree-node { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: none; border: none; border-radius: var(--radius-sm); cursor: pointer; text-align: left; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||||
|
.rdm-audit-tree-node:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.rdm-audit-tree-node.sel { background: var(--bg-active); color: var(--text); }
|
||||||
|
.rdm-audit-tree-caret { width: 14px; flex: none; color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||||
|
.rdm-audit-tree-children { padding-left: 18px; display: flex; flex-direction: column; gap: 0; }
|
||||||
|
.rdm-audit-tree-kind { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 3.2em; }
|
||||||
|
.rdm-audit-tree-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.rdm-audit-more { margin-top: 8px; }
|
||||||
|
.rdm-audit-truncated { font-size: var(--fs-ui-sm); color: var(--text-faint); padding: 6px 0; }
|
||||||
|
.rdm-audit-detail { flex: 1; min-width: 0; border-left: 1px solid var(--border-soft); padding-left: 14px; }
|
||||||
|
.rdm-audit-detail-body { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-ui-sm); }
|
||||||
|
.rdm-audit-version { padding: 3px 0; color: var(--text-dim); }
|
||||||
|
.rdm-audit-formal { margin-top: 8px; padding: 8px 10px; background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--ok-text); }
|
||||||
|
.rdm-audit-pick { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.rdm-audit-trace-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||||
|
.rdm-audit-trace-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||||
|
.rdm-audit-trace-col { min-width: 0; }
|
||||||
|
.rdm-audit-rel { display: flex; align-items: center; gap: 6px; padding: 4px 0; font-size: var(--fs-ui-sm); }
|
||||||
|
.rdm-audit-rel-type { color: var(--text-dim); }
|
||||||
|
.rdm-audit-rel-endpoint { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.rdm-audit-timeline-filters { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
|
.rdm-audit-timeline-filters .ui-input { max-width: 160px; height: 30px; font-size: var(--fs-ui-sm); }
|
||||||
|
.rdm-audit-seq { max-width: 100px; }
|
||||||
|
.rdm-audit-opgroup { border: 1px solid var(--border-soft); border-radius: var(--radius-sm); margin-bottom: 4px; overflow: hidden; }
|
||||||
|
.rdm-audit-opgroup-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; background: var(--bg-panel); border: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text-dim); text-align: left; }
|
||||||
|
.rdm-audit-opgroup-head:hover { background: var(--bg-hover); }
|
||||||
|
.rdm-audit-opgroup-actor { font-weight: 550; color: var(--text); }
|
||||||
|
.rdm-audit-opgroup-time { font-size: var(--fs-ui-xs); color: var(--text-faint); }
|
||||||
|
.rdm-audit-event { display: flex; align-items: center; gap: 8px; padding: 4px 10px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
|
||||||
|
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 5em; }
|
||||||
|
.rdm-audit-event-op { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--accent-text); min-width: 8em; }
|
||||||
|
.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; }
|
||||||
|
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
|
||||||
|
.rdm-audit-coverage { margin-top: 12px; }
|
||||||
|
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||||
|
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
|
||||||
|
.rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { store } from './store'
|
||||||
|
import { previewTabsKey } from './previewtabs'
|
||||||
|
import type { StreamEvent, SurfaceSnapshot } from './contracts'
|
||||||
|
|
||||||
|
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
|
||||||
|
// 服务端快照是唯一权威(KTD8 单一状态所有者)。
|
||||||
|
|
||||||
|
function surfEvent(seq: number, surfaceId: string, revision: number, title: string): StreamEvent<SurfaceSnapshot> {
|
||||||
|
return {
|
||||||
|
seq, conversationId: 'c1', ts: new Date().toISOString(), v: 1, type: 'surface.updated',
|
||||||
|
payload: {
|
||||||
|
identity: { surfaceId, owner: 'alice', pluginId: 'generative-ui', conversationId: 'c1' },
|
||||||
|
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' },
|
||||||
|
revision, activationEpoch: 1,
|
||||||
|
spec: { title, nodes: [{ type: 'markdown', id: 'n1', markdown: 'x' }] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('surface.updated projection', () => {
|
||||||
|
it('新事件入库;旧修订与重复事件不覆盖', () => {
|
||||||
|
store.applyEvent(surfEvent(1, 's1', 1, '初版'))
|
||||||
|
store.applyEvent(surfEvent(2, 's1', 2, '修订二'))
|
||||||
|
store.applyEvent(surfEvent(3, 's1', 1, '迟到的旧修订'))
|
||||||
|
store.applyEvent(surfEvent(4, 's1', 2, '重复事件'))
|
||||||
|
const list = store.getState().surfaces
|
||||||
|
expect(list).toHaveLength(1)
|
||||||
|
expect(list[0].revision).toBe(2)
|
||||||
|
expect(list[0].spec.title).toBe('修订二')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多页面并存,互不覆盖', () => {
|
||||||
|
store.applyEvent(surfEvent(5, 's2', 1, '页面二'))
|
||||||
|
const list = store.getState().surfaces
|
||||||
|
expect(list.filter(x => String(x.identity.surfaceId) === 's1')).toHaveLength(1)
|
||||||
|
expect(list.filter(x => String(x.identity.surfaceId) === 's2')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('surface.updated 可见性联动', () => {
|
||||||
|
it('自动开标签时右栏切到文件预览(syncFilesEntry 语义)', () => {
|
||||||
|
store.applyEvent(surfEvent(101, 'sv1', 1, '可见性页'))
|
||||||
|
const ui = store.getState().ui
|
||||||
|
expect(ui.rightTab).toBe('files')
|
||||||
|
expect(ui.rightCollapsed).toBe(false)
|
||||||
|
expect(ui.filesView).toBe('preview')
|
||||||
|
const tabs = store.getState().previewTabs[previewTabsKey(store.getState().currentConvId)] ?? []
|
||||||
|
expect(tabs.some(t => t.id === 'surface:sv1')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('抑制开标签时留 toast 痕迹', () => {
|
||||||
|
// 预置:已有 surface 标签但 active 是别的工件标签 → 新页面不开标签
|
||||||
|
store.applyEvent(surfEvent(102, 'sv2', 1, '第二页'))
|
||||||
|
// sv2 已入 surfaces 数组(事件投影),标签未强开——至少状态一致
|
||||||
|
expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('U6 工作区任务页', () => {
|
||||||
|
it('pin 固定后新页面事件不改变固定对象;取消后跟随最新', () => {
|
||||||
|
store.setUI({ workspaceSurfacePin: null })
|
||||||
|
store.applyEvent(surfEvent(201, 'w1', 1, '第一页'))
|
||||||
|
const first = store.getState().ui.workspaceSurfacePin
|
||||||
|
expect(first).toBeNull() // 默认跟随(不自动 pin)
|
||||||
|
// 用户固定 w1
|
||||||
|
store.setUI({ workspaceSurfacePin: 'w1' })
|
||||||
|
store.applyEvent(surfEvent(202, 'w2', 1, '第二页'))
|
||||||
|
// pin 不变,且 surfaces 数组两页并存(禁用回退时容器随空数组自然消失)
|
||||||
|
expect(store.getState().ui.workspaceSurfacePin).toBe('w1')
|
||||||
|
const w2 = store.getState().surfaces.find(x => String(x.identity.surfaceId) === 'w2')
|
||||||
|
expect(w2?.spec.title).toBe('第二页')
|
||||||
|
store.setUI({ workspaceSurfacePin: null })
|
||||||
|
expect(store.getState().ui.workspaceSurfacePin).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
// U5 验收线第 4/6 条的客户端半边:请求构造只携带显式动作数据(未提交草稿
|
||||||
|
// 无任何自动上传路径——构造函数是纯函数,测试其形状);requestId 每次唯一。
|
||||||
|
|
||||||
|
function buildActionRequest(snapshot: { revision: number }, actionId: string, drafts: Record<string, unknown>) {
|
||||||
|
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now())
|
||||||
|
return {
|
||||||
|
actionId,
|
||||||
|
revision: snapshot.revision,
|
||||||
|
requestId,
|
||||||
|
data: drafts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('surface action request', () => {
|
||||||
|
it('携带当前修订与显式草稿;requestId 唯一', () => {
|
||||||
|
const snap = { revision: 3 }
|
||||||
|
const a = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
|
||||||
|
const b = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
|
||||||
|
expect(a.revision).toBe(3)
|
||||||
|
expect(a.actionId).toBe('generative-ui.submit')
|
||||||
|
expect(a.data).toEqual({ f1: { years: '5' } })
|
||||||
|
expect(a.requestId).not.toBe(b.requestId)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// 任务列表组织(方案 A,提案 2026-09-30):纯函数,供 TaskSidebar 与测试消费。
|
||||||
|
// 组织是本端视图偏好(P8),不改数据模型——数据源恒为跨项目 tasks(updated_at 倒序)。
|
||||||
|
import type { Conversation, Project } from './contracts'
|
||||||
|
|
||||||
|
export type TaskOrg = 'project' | 'timeline'
|
||||||
|
|
||||||
|
export interface TaskGroup {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
/** 项目分组时携带(组头「在此项目新建」入口与计数)。 */
|
||||||
|
projectId?: string
|
||||||
|
tasks: Conversation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按项目分组:项目按服务端序(更新倒序),组内任务按列表序(置顶已在服务端排首)。 */
|
||||||
|
export function groupByProject(tasks: Conversation[], projects: Project[]): TaskGroup[] {
|
||||||
|
const byProject = new Map<string, Conversation[]>()
|
||||||
|
for (const t of tasks) {
|
||||||
|
const list = byProject.get(t.projectId) ?? []
|
||||||
|
list.push(t)
|
||||||
|
byProject.set(t.projectId, list)
|
||||||
|
}
|
||||||
|
const groups: TaskGroup[] = []
|
||||||
|
for (const p of projects) {
|
||||||
|
const list = byProject.get(p.id)
|
||||||
|
if (!list || list.length === 0) continue
|
||||||
|
groups.push({ key: `prj:${p.id}`, label: p.name, projectId: p.id, tasks: list })
|
||||||
|
byProject.delete(p.id)
|
||||||
|
}
|
||||||
|
// 项目已删但会话仍在(竞态残留):收进"未分组",不静默丢弃。
|
||||||
|
for (const [pid, list] of byProject) {
|
||||||
|
groups.push({ key: `prj:${pid}`, label: '未分组', projectId: pid, tasks: list })
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按时间线分组:今天 / 昨天 / 本周更早 / 更早(按 updatedAt 本地时区)。 */
|
||||||
|
export function groupByTimeline(tasks: Conversation[], now = Date.now()): TaskGroup[] {
|
||||||
|
const startOfDay = (ts: number) => { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime() }
|
||||||
|
const today0 = startOfDay(now)
|
||||||
|
const dayMs = 86_400_000
|
||||||
|
const buckets: TaskGroup[] = [
|
||||||
|
{ key: 'today', label: '今天', tasks: [] },
|
||||||
|
{ key: 'yesterday', label: '昨天', tasks: [] },
|
||||||
|
{ key: 'week', label: '本周更早', tasks: [] },
|
||||||
|
{ key: 'older', label: '更早', tasks: [] },
|
||||||
|
]
|
||||||
|
const weekday = (new Date(now).getDay() + 6) % 7 // 周一=0
|
||||||
|
const weekStart = today0 - weekday * dayMs
|
||||||
|
for (const t of tasks) {
|
||||||
|
const ts = Date.parse(t.updatedAt)
|
||||||
|
if (Number.isNaN(ts)) { buckets[3].tasks.push(t); continue }
|
||||||
|
if (ts >= today0) buckets[0].tasks.push(t)
|
||||||
|
else if (ts >= today0 - dayMs) buckets[1].tasks.push(t)
|
||||||
|
else if (ts >= weekStart) buckets[2].tasks.push(t)
|
||||||
|
else buckets[3].tasks.push(t)
|
||||||
|
}
|
||||||
|
return buckets.filter(g => g.tasks.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相对时间(侧栏行尾):刚刚 / N 分钟 / N 小时 / 昨天 / N 天 / 月-日。 */
|
||||||
|
export function relativeTime(iso: string, now = Date.now()): string {
|
||||||
|
const ts = Date.parse(iso)
|
||||||
|
if (Number.isNaN(ts)) return ''
|
||||||
|
const diff = now - ts
|
||||||
|
if (diff < 60_000) return '刚刚'
|
||||||
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟`
|
||||||
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时`
|
||||||
|
if (diff < 172_800_000) return '昨天'
|
||||||
|
if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天`
|
||||||
|
const d = new Date(ts)
|
||||||
|
const nowD = new Date(now)
|
||||||
|
const md = `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
return d.getFullYear() === nowD.getFullYear() ? md : `${d.getFullYear()}/${md}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标题过滤(不区分大小写;空串返回原列表)。 */
|
||||||
|
export function filterTasks(tasks: Conversation[], q: string): Conversation[] {
|
||||||
|
const needle = q.trim().toLowerCase()
|
||||||
|
if (!needle) return tasks
|
||||||
|
return tasks.filter(t => t.title.toLowerCase().includes(needle))
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
// 工具调用按类别合并成时间线的一行。新增类别必须补进 ToolKind 与两处 switch,
|
||||||
|
// 否则 never 检查会在编译期失败。
|
||||||
|
import type { Part } from './contracts'
|
||||||
|
|
||||||
|
export type ToolKind = 'read' | 'search' | 'exec' | 'write' | 'card' | 'delegate' | 'plan' | 'other'
|
||||||
|
|
||||||
|
const READ = new Set(['fs_read_file', 'fs_list_dir', 'read_conversation', 'list_conversations'])
|
||||||
|
const SEARCH = new Set(['knowledge_search', 'tavily_search', 'web_search', 'history_search'])
|
||||||
|
const EXEC = new Set(['run_shell', 'run_python'])
|
||||||
|
const WRITE = new Set(['write_artifact', 'register_artifact', 'fs_write_file'])
|
||||||
|
const CARD = new Set(['submit_card', 'card_begin', 'card_append', 'card_commit'])
|
||||||
|
const DELEGATE = new Set(['delegate_task', 'squad_orchestrate'])
|
||||||
|
const PLAN = new Set(['update_plan', 'update_todos'])
|
||||||
|
|
||||||
|
export function toolKind(name: string): ToolKind {
|
||||||
|
if (READ.has(name)) return 'read'
|
||||||
|
if (SEARCH.has(name)) return 'search'
|
||||||
|
if (EXEC.has(name)) return 'exec'
|
||||||
|
if (WRITE.has(name)) return 'write'
|
||||||
|
if (CARD.has(name)) return 'card'
|
||||||
|
if (DELEGATE.has(name)) return 'delegate'
|
||||||
|
if (PLAN.has(name)) return 'plan'
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
function argOf(raw: string | undefined, keys: string[]): string {
|
||||||
|
if (!raw) return ''
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw) as Record<string, unknown>
|
||||||
|
for (const k of keys) {
|
||||||
|
const x = v[k]
|
||||||
|
if (typeof x === 'string' && x.trim()) return x.trim()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从工具参数里取出给用户看的一行:路径、查询词或脚本首行。 */
|
||||||
|
export function toolHeadline(name: string, args?: string): string {
|
||||||
|
const kind = toolKind(name)
|
||||||
|
switch (kind) {
|
||||||
|
case 'read': return argOf(args, ['path', 'file', 'conversationId']) || name
|
||||||
|
case 'search': return argOf(args, ['query', 'q']) || name
|
||||||
|
case 'exec': return firstLine(argOf(args, ['script', 'command', 'cmd'])) || name
|
||||||
|
case 'write': return argOf(args, ['path', 'name', 'filename']) || name
|
||||||
|
case 'card': return argOf(args, ['title']) || name
|
||||||
|
case 'delegate': return argOf(args, ['task', 'goal', 'name']) || name
|
||||||
|
case 'plan': return argOf(args, ['title']) || name
|
||||||
|
case 'other': return name
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = kind
|
||||||
|
return exhaustive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toolGroupLabel(kind: ToolKind, count: number, running: boolean): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'read': return `查阅 · ${count} 个文件`
|
||||||
|
case 'search': return `搜索 · ${count} 次`
|
||||||
|
case 'exec': return running ? `运行命令 · ${count} 条` : `已运行命令 · ${count} 条`
|
||||||
|
case 'write': return `写入文件 · ${count} 个`
|
||||||
|
case 'card': return `卡片 · ${count} 张`
|
||||||
|
case 'delegate': return `委派 · ${count} 个任务`
|
||||||
|
case 'plan': return `更新计划 · ${count} 次`
|
||||||
|
case 'other': return `工具 · ${count} 次`
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = kind
|
||||||
|
return exhaustive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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[] = []
|
||||||
|
let i = 0
|
||||||
|
while (i < parts.length) {
|
||||||
|
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++ }
|
||||||
|
items.push(toolItem(call, res, live))
|
||||||
|
}
|
||||||
|
out.push({ kind: 'tools', toolKind: kind, items })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (p.type === 'tool_result') { i++; continue }
|
||||||
|
if (p.type === 'thinking') {
|
||||||
|
out.push({ kind: 'thinking', text: p.text ?? '', durationMs: p.durationMs, active: false })
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (p.type === 'text') {
|
||||||
|
out.push({ kind: 'text', text: p.text ?? '' })
|
||||||
|
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++
|
||||||
|
}
|
||||||
|
if (live) {
|
||||||
|
const idx = out.reduce((acc, e, n) => (e.kind === 'thinking' ? n : acc), -1)
|
||||||
|
const entry = idx >= 0 ? out[idx] : undefined
|
||||||
|
if (entry && entry.kind === 'thinking' && out.slice(idx + 1).every(e => e.kind === 'thinking')) {
|
||||||
|
entry.active = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// 体系工具排位:服务端声明的工作区对所有用户可见,按 shortcut 数字占位;
|
||||||
|
// 内置「代理」补进空位。文件浏览在第三栏「文件」入口(方案:入口收敛),不再占体系工具位。
|
||||||
|
// 插件管理面板不进这条清单。
|
||||||
|
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
|
||||||
|
// 没有任何 rdm.inspect.* 命令,入口可达而进入即 400。仅当命令面探测通过
|
||||||
|
//(rdmAuditReady / store.rdmAuditAvailable)才纳入;代理仍无条件以 ⌘1 补位。
|
||||||
|
|
||||||
|
export interface WorkspaceEntry {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
icon: string
|
||||||
|
shortcut: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceSource {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
icon?: string
|
||||||
|
shortcut?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审计工作区消费的全部 rdm.inspect.* 命令(capabilities 入口 + 四页签取数)。 */
|
||||||
|
export const RDM_AUDIT_COMMANDS = [
|
||||||
|
'rdm.inspect.capabilities',
|
||||||
|
'rdm.inspect.summary',
|
||||||
|
'rdm.inspect.tree',
|
||||||
|
'rdm.inspect.object',
|
||||||
|
'rdm.inspect.relations',
|
||||||
|
'rdm.inspect.events',
|
||||||
|
'rdm.inspect.history',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** GET /api/commands 清单是否足以支撑审计工作区:七命令齐备且 enabled。清单不可用(未登录/请求失败)视为未就绪。 */
|
||||||
|
export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
|
||||||
|
if (!Array.isArray(cmds)) return false
|
||||||
|
return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
|
||||||
|
{ key: 'agents', title: '代理', icon: '🤖' },
|
||||||
|
{ key: 'rdm-audit', title: '追溯审计', icon: '🔍' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function shortcutNum(raw?: string): number | null {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildWorkspaceOptions {
|
||||||
|
/** rdm.inspect.* 命令面探测通过(store.rdmAuditAvailable)。默认 false:能力未知/缺失不展示审计入口。 */
|
||||||
|
rdmAudit?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWorkspaceOptions = {}): WorkspaceEntry[] {
|
||||||
|
const used = new Set<number>()
|
||||||
|
const ranked: { rank: number; entry: Omit<WorkspaceEntry, 'shortcut'> }[] = []
|
||||||
|
const unranked: Omit<WorkspaceEntry, 'shortcut'>[] = []
|
||||||
|
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] })
|
||||||
|
if (opts.rdmAudit === true) ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
|
||||||
|
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
|
||||||
|
return ranked
|
||||||
|
.filter(item => item.rank <= 9)
|
||||||
|
.sort((a, b) => a.rank - b.rank)
|
||||||
|
.slice(0, 9)
|
||||||
|
.map((item, i) => ({ ...item.entry, shortcut: `⌘${i + 1}` }))
|
||||||
|
}
|
||||||
@@ -40,4 +40,15 @@ describe('auth 凭证模块', () => {
|
|||||||
expect(auth.apiKey()).toBe('')
|
expect(auth.apiKey()).toBe('')
|
||||||
expect(auth.withKey('/x')).toBe('/x')
|
expect(auth.withKey('/x')).toBe('/x')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('beginSession:密码会话生效后清残留 Bearer,fetch 不再带头', async () => {
|
||||||
|
auth.setKey('ask_stale')
|
||||||
|
auth.beginSession()
|
||||||
|
expect(auth.apiKey()).toBe('')
|
||||||
|
const spy = vi.fn().mockResolvedValue(new Response('{}'))
|
||||||
|
vi.stubGlobal('fetch', spy)
|
||||||
|
await auth.fetch('/api/me')
|
||||||
|
const [, init] = spy.mock.calls[0] as [string, RequestInit | undefined]
|
||||||
|
expect(new Headers(init?.headers ?? {}).get('Authorization')).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// 设计系统守护测试(父仓 DESIGN.md §11 禁止清单 · 宿主侧,2026-09-30)。
|
||||||
|
// 棘轮基线:现存豁免登记在下方;新增同类违规直接失败。收紧 = 修掉违规后删基线条目。
|
||||||
|
// 范围:src/styles.css 与 src/**/*.tsx。@ui 组件库侧由 Agent-System-UI 的守护测试覆盖。
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
|
||||||
|
import { join, relative, dirname } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url)) // test/
|
||||||
|
const FE_ROOT = join(HERE, '..')
|
||||||
|
const SRC = join(FE_ROOT, 'src')
|
||||||
|
const DEV_ROOT = join(FE_ROOT, '..', '..') // Dev 父仓根(DESIGN.md;单独检出本仓时不存在)
|
||||||
|
|
||||||
|
function walk(dir: string, out: string[] = []): string[] {
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
const p = join(dir, name)
|
||||||
|
if (statSync(p).isDirectory()) walk(p, out)
|
||||||
|
else out.push(p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
const read = (p: string) => readFileSync(p, 'utf8')
|
||||||
|
const stripCssComments = (t: string) => t.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
|
const collectHex = (t: string) => [...t.matchAll(/#[0-9a-fA-F]{3,8}\b/g)].map(m => m[0].toLowerCase())
|
||||||
|
|
||||||
|
// ---- 1. 宪法在位(组合检出时) ----
|
||||||
|
describe('DESIGN.md 宪法在位', () => {
|
||||||
|
it('DESIGN.md 存在且 AGENTS.md 带强制阅读门禁', () => {
|
||||||
|
const designPath = join(DEV_ROOT, 'DESIGN.md')
|
||||||
|
if (!existsSync(designPath)) { console.warn('(单独检出 Frontend 仓:跳过宪法检查)'); return }
|
||||||
|
expect(read(designPath)).toContain('Agent-System-Dev 设计系统')
|
||||||
|
const agents = read(join(DEV_ROOT, 'AGENTS.md'))
|
||||||
|
expect(agents).toContain('DESIGN.md')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 2. 动效唯一所有者 ----
|
||||||
|
describe('动效唯一所有者', () => {
|
||||||
|
it('styles.css 无 @keyframes(全部走 @ui/motion.css 的 mk-* 类)', () => {
|
||||||
|
expect(stripCssComments(read(join(SRC, 'styles.css')))).not.toContain('@keyframes')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 3. 裸色值棘轮 ----
|
||||||
|
const HEX_BASELINE_STYLES = [
|
||||||
|
'#0e1116', '#101826', '#0b1f38', // 登录品牌面板固定深底渐变(面板自带深底,主题无关)
|
||||||
|
'#dfe4f2', // 同上面板的固定浅字
|
||||||
|
] as const
|
||||||
|
describe('裸色值棘轮', () => {
|
||||||
|
it('styles.css 不引入基线外裸色值', () => {
|
||||||
|
const found = collectHex(read(join(SRC, 'styles.css')))
|
||||||
|
const allow = new Set<string>(HEX_BASELINE_STYLES)
|
||||||
|
const extra = found.filter(h => !allow.has(h))
|
||||||
|
expect(extra, `新增裸色值 ${[...new Set(extra)].join(', ')}——改用 tokens 令牌;确需豁免更新 HEX_BASELINE_STYLES 并写明理由`).toEqual([])
|
||||||
|
})
|
||||||
|
it('组件 TSX 无裸色值', () => {
|
||||||
|
const extra: string[] = []
|
||||||
|
for (const p of walk(SRC).filter(p => /\.(tsx|ts)$/.test(p) && !p.endsWith('.d.ts'))) {
|
||||||
|
for (const h of collectHex(read(p))) extra.push(`${relative(SRC, p)}: ${h}`)
|
||||||
|
}
|
||||||
|
expect(extra, '组件里出现裸色值——走 tokens 令牌').toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 4. 行内字号棘轮(--fs-ui-* 标尺) ----
|
||||||
|
const FONTSIZE_LITERAL_BASELINE = ['40'] as const // Conversation 空态装饰字形(⌘ 展示字号,非 UI 层级)
|
||||||
|
describe('行内字号棘轮', () => {
|
||||||
|
it('组件 TSX 无基线外 fontSize 数值字面量', () => {
|
||||||
|
const allow = new Set<string>(FONTSIZE_LITERAL_BASELINE)
|
||||||
|
const extra: string[] = []
|
||||||
|
for (const p of walk(SRC).filter(p => p.endsWith('.tsx'))) {
|
||||||
|
const lines = read(p).split('\n')
|
||||||
|
lines.forEach((l: string, i: number) => {
|
||||||
|
const m = l.match(/fontSize:\s*['"`]?([\d.]+)/)
|
||||||
|
if (m && !allow.has(m[1])) extra.push(`${relative(SRC, p)}:${i + 1} (${m[1]})`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
expect(extra, '行内 fontSize 数值——改用 var(--fs-ui-*);确需豁免更新基线并写明理由').toEqual([])
|
||||||
|
})
|
||||||
|
it('组件 TSX 无 <details> 折叠(走 @ui Collapsible,DESIGN.md §11.7)', () => {
|
||||||
|
const extra: string[] = []
|
||||||
|
for (const p of walk(SRC).filter(p => p.endsWith('.tsx'))) {
|
||||||
|
const code = read(p).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
||||||
|
if (/<details[\s>]/.test(code)) extra.push(relative(SRC, p))
|
||||||
|
}
|
||||||
|
expect(extra, '<details> 折叠——改用 @ui Collapsible').toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// live 流式投影纯逻辑测试(中栏过程/回复分离):到达序并段、process 标记时机、
|
||||||
|
// 轮次穿插同构、过程区折叠谓词。
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated,
|
||||||
|
applyCheck, applyFinalizing,
|
||||||
|
hasSettledReply, liveToMessage, livePhaseLabel, showProcessInline, type LiveItem,
|
||||||
|
} from '../src/live'
|
||||||
|
import type { LiveState } from '../src/store'
|
||||||
|
|
||||||
|
function live(over: Partial<LiveState> = {}): LiveState {
|
||||||
|
return { runId: 'r1', phase: 'thinking', items: [], agentNotes: {}, startedAt: 0, ...over }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两轮流式事件回放:思考→解说文本→工具→思考→最终回复(与后端事件序一致)。
|
||||||
|
function twoTurnItems(): LiveItem[] {
|
||||||
|
let items: LiveItem[] = []
|
||||||
|
items = applyThinkingDelta(items, '先想想', 0)
|
||||||
|
items = applyThinkingDelta(items, '一下', 0)
|
||||||
|
items = applyTextDelta(items, 0, '我先更新待办。')
|
||||||
|
items = applyToolCall(items, 'update_todos', 'c1')
|
||||||
|
items = applyToolResult(items, 'c1', 'ok', '已更新')
|
||||||
|
items = applyThinkingDelta(items, '再想想', 0)
|
||||||
|
items = applyTextDelta(items, 1, '最终')
|
||||||
|
items = applyTextDelta(items, 1, '回复')
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('applyThinkingDelta / applyTextDelta', () => {
|
||||||
|
it('merges thinking deltas into the last thinking segment', () => {
|
||||||
|
let items: LiveItem[] = []
|
||||||
|
items = applyThinkingDelta(items, '先', 0)
|
||||||
|
items = applyThinkingDelta(items, '想想', 0)
|
||||||
|
expect(items).toEqual([{ kind: 'thinking', text: '先想想', startedAt: 0, endedAt: 0 }])
|
||||||
|
})
|
||||||
|
it('opens a new thinking segment after text/tool (turn boundary)', () => {
|
||||||
|
let items: LiveItem[] = applyThinkingDelta([], '第一轮思考', 0)
|
||||||
|
items = applyTextDelta(items, 0, '解说')
|
||||||
|
items = applyThinkingDelta(items, '第二轮思考', 0)
|
||||||
|
expect(items).toEqual([
|
||||||
|
{ kind: 'thinking', text: '第一轮思考', startedAt: 0, endedAt: 0 },
|
||||||
|
{ kind: 'text', index: 0, text: '解说' },
|
||||||
|
{ kind: 'thinking', text: '第二轮思考', startedAt: 0, endedAt: 0 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
it('appends text to the last segment when index matches', () => {
|
||||||
|
let items: LiveItem[] = []
|
||||||
|
items = applyTextDelta(items, 0, '先查')
|
||||||
|
items = applyTextDelta(items, 0, '一下')
|
||||||
|
expect(items).toEqual([{ kind: 'text', index: 0, text: '先查一下' }])
|
||||||
|
})
|
||||||
|
it('opens a new text segment when index advances (new turn)', () => {
|
||||||
|
let items: LiveItem[] = applyTextDelta([], 0, '解说')
|
||||||
|
items = applyTextDelta(items, 1, '最终')
|
||||||
|
items = applyTextDelta(items, 1, '回复')
|
||||||
|
expect(items).toEqual([
|
||||||
|
{ kind: 'text', index: 0, text: '解说' },
|
||||||
|
{ kind: 'text', index: 1, text: '最终回复' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
it('fills the most recent pending tool call (callId match, tail fallback)', () => {
|
||||||
|
let items: LiveItem[] = []
|
||||||
|
items = applyToolCall(items, 'a', 'ca')
|
||||||
|
items = applyToolCall(items, 'b', 'cb')
|
||||||
|
items = applyToolResult(items, 'cb', 'ok', '结果')
|
||||||
|
expect(items).toEqual([
|
||||||
|
{ kind: 'tool', callId: 'ca', name: 'a', 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: '结果' },
|
||||||
|
])
|
||||||
|
items = applyToolResult(items, undefined, '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' } })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('liveToMessage', () => {
|
||||||
|
it('interleaves parts per turn in arrival order, matching persisted parts', () => {
|
||||||
|
const msg = liveToMessage(live({ items: twoTurnItems() }))
|
||||||
|
expect(msg.parts).toEqual([
|
||||||
|
{ type: 'thinking', text: '先想想一下', process: true },
|
||||||
|
{ type: 'text', text: '我先更新待办。', process: true },
|
||||||
|
{ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'update_todos' }, toolPhase: 'done' },
|
||||||
|
{ type: 'tool_result', process: true, toolCall: { id: 'c1', name: 'update_todos' },
|
||||||
|
toolResult: { callId: 'c1', status: 'ok', summary: '已更新' } },
|
||||||
|
{ type: 'thinking', text: '再想想', process: true },
|
||||||
|
{ type: 'text', text: '最终回复' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
it('marks the in-flight segment as process the moment its tool call arrives', () => {
|
||||||
|
let items: LiveItem[] = []
|
||||||
|
items = applyTextDelta(items, 0, '我先查一下')
|
||||||
|
const before = liveToMessage(live({ items }))
|
||||||
|
expect(before.parts.map(p => p.process)).toEqual([undefined]) // 尚无工具:仍在回复位
|
||||||
|
items = applyToolCall(items, 'web_search')
|
||||||
|
const after = liveToMessage(live({ items }))
|
||||||
|
expect(after.parts.map(p => p.process)).toEqual([true, true]) // tool.call 一到即改标
|
||||||
|
})
|
||||||
|
it('single final segment without tools carries no process flag', () => {
|
||||||
|
let items: LiveItem[] = []
|
||||||
|
items = applyTextDelta(items, 0, '直接回答')
|
||||||
|
const msg = liveToMessage(live({ items }))
|
||||||
|
expect(msg.parts.map(p => p.process)).toEqual([undefined])
|
||||||
|
})
|
||||||
|
it('tools between segments keep every non-final segment in the process area', () => {
|
||||||
|
let items: LiveItem[] = []
|
||||||
|
items = applyTextDelta(items, 0, '第一轮')
|
||||||
|
items = applyToolCall(items, 'a')
|
||||||
|
items = applyToolResult(items, undefined, 'ok')
|
||||||
|
items = applyTextDelta(items, 1, '第二轮')
|
||||||
|
items = applyToolCall(items, 'b')
|
||||||
|
items = applyTextDelta(items, 2, '最终回复')
|
||||||
|
const msg = liveToMessage(live({ items }))
|
||||||
|
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, true, undefined])
|
||||||
|
// 顺序必须与落库穿插序一致:text → tool → text → tool → text。
|
||||||
|
expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'tool_call', 'text'])
|
||||||
|
})
|
||||||
|
it('thinking and tool parts are always process', () => {
|
||||||
|
let items: LiveItem[] = []
|
||||||
|
items = applyThinkingDelta(items, '想', 0)
|
||||||
|
items = applyTextDelta(items, 0, '答')
|
||||||
|
items = applyToolCall(items, 'web_search')
|
||||||
|
items = applyToolResult(items, undefined, 'ok', '命中')
|
||||||
|
const msg = liveToMessage(live({ items }))
|
||||||
|
for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true)
|
||||||
|
})
|
||||||
|
it('does not project deliverable cards while the run is live', () => {
|
||||||
|
let items: LiveItem[] = []
|
||||||
|
items = applyTextDelta(items, 0, '生成报表。')
|
||||||
|
items = applyToolCall(items, 'make_report', 'c1')
|
||||||
|
items = applyToolResult(items, 'c1', 'ok')
|
||||||
|
items = applyArtifactCreated(items, { artifactId: 'a1', name: 'report.csv', kind: 'csv' })
|
||||||
|
items = applyTextDelta(items, 1, '完成。')
|
||||||
|
const msg = liveToMessage(live({ items }))
|
||||||
|
expect(msg.parts.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', () => {
|
||||||
|
it('stays expanded for the whole live run, including the responding phase', () => {
|
||||||
|
expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(true)
|
||||||
|
expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(true)
|
||||||
|
expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(true)
|
||||||
|
})
|
||||||
|
it('records thinking duration from the first delta to the last', () => {
|
||||||
|
let items: LiveItem[] = applyThinkingDelta([], '先', 1000)
|
||||||
|
items = applyThinkingDelta(items, '想想', 2500)
|
||||||
|
const msg = liveToMessage(live({ items }))
|
||||||
|
expect(msg.parts[0]).toMatchObject({ type: 'thinking', text: '先想想', durationMs: 1500 })
|
||||||
|
})
|
||||||
|
it('falls back to the auto-collapse preference when no live run', () => {
|
||||||
|
expect(showProcessInline(null, true)).toBe(false)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
|
||||||
|
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { humanChangePasswordError, humanLoginError } from '../src/components/LoginPage'
|
||||||
|
|
||||||
|
describe('humanLoginError(登录)', () => {
|
||||||
|
it('401 统一为用户名或密码不正确', () => {
|
||||||
|
expect(humanLoginError('401: invalid owner or password')).toBe('用户名或密码不正确')
|
||||||
|
})
|
||||||
|
it('429/412 各有文案,其余剥状态码前缀', () => {
|
||||||
|
expect(humanLoginError('429: too many failed attempts; retry later')).toBe('失败次数过多,请稍后再试')
|
||||||
|
expect(humanLoginError('412: auth disabled (embedded single-tenant mode)')).toBe('本服务未开启登录(嵌入式单租户档)')
|
||||||
|
expect(humanLoginError('400: bad')).toBe('bad')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('humanChangePasswordError(改密门)', () => {
|
||||||
|
it('当前密码错误不再伪装成登录失败', () => {
|
||||||
|
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
|
||||||
|
})
|
||||||
|
it('无会话(API key 凭证)给出可行动指引', () => {
|
||||||
|
expect(humanChangePasswordError('401: session login required (API keys do not own passwords)')).toBe('请先用账号密码登录再改密(API 密钥不能修改密码)')
|
||||||
|
})
|
||||||
|
it('会话过期与失效 key 各有文案', () => {
|
||||||
|
expect(humanChangePasswordError('401: session expired')).toBe('会话已过期,请重新登录')
|
||||||
|
expect(humanChangePasswordError('401: unauthorized: invalid or revoked API key')).toBe('本地残留的 API 密钥已失效,请退出登录后重试')
|
||||||
|
})
|
||||||
|
it('未知 401 回落通用文案,其余错误剥前缀透传', () => {
|
||||||
|
expect(humanChangePasswordError('401: whatever')).toBe('认证失败,请重新登录')
|
||||||
|
expect(humanChangePasswordError('400: password must be 8-256 characters')).toBe('password must be 8-256 characters')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
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 {
|
||||||
|
return partial as Part
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('splitTimeline', () => {
|
||||||
|
it('keeps everything up to the last process part in the timeline', () => {
|
||||||
|
const parts: Part[] = [
|
||||||
|
p({ type: 'text', text: '先找资料', process: true }),
|
||||||
|
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
|
||||||
|
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }),
|
||||||
|
p({ type: 'text', text: '文档已生成,请查收' }),
|
||||||
|
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
|
||||||
|
]
|
||||||
|
const split = splitTimeline(parts)
|
||||||
|
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_result'])
|
||||||
|
expect(split.final.map(x => x.type)).toEqual(['text', 'artifact_ref'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a card sandwiched between process parts inside the timeline', () => {
|
||||||
|
const parts: Part[] = [
|
||||||
|
p({ type: 'thinking', process: true, text: '想' }),
|
||||||
|
p({ type: 'card', card: { kind: 'result', data: [] } }),
|
||||||
|
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'a' } }),
|
||||||
|
p({ type: 'text', text: '最终' }),
|
||||||
|
]
|
||||||
|
const split = splitTimeline(parts)
|
||||||
|
expect(split.timeline.map(x => x.type)).toEqual(['thinking', 'card', 'tool_call'])
|
||||||
|
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([])
|
||||||
|
expect(splitTimeline(parts).final).toEqual(parts)
|
||||||
|
expect(splitTimeline([])).toEqual({ timeline: [], final: [] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('artifact ids in reply text', () => {
|
||||||
|
it('extracts unique ids in order', () => {
|
||||||
|
expect(artifactIdsIn('见 art_0123456789ab 和 `art_abcdefabcdef`,再提 art_0123456789ab'))
|
||||||
|
.toEqual(['art_0123456789ab', 'art_abcdefabcdef'])
|
||||||
|
})
|
||||||
|
it('marks only ids the project does not have', () => {
|
||||||
|
const text = '文件 art_0123456789ab 与 art_abcdefabcdef'
|
||||||
|
const out = markMissingArtifacts(text, id => id === 'art_0123456789ab')
|
||||||
|
expect(out).toContain('art_0123456789ab')
|
||||||
|
expect(out).not.toContain('art_abcdefabcdef')
|
||||||
|
expect(out).toContain('未找到该文件')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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 秒')
|
||||||
|
expect(workedLabel(undefined, undefined)).toBe('已工作')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('humanSize / artifactIcon', () => {
|
||||||
|
it('formats sizes compactly', () => {
|
||||||
|
expect(humanSize(undefined)).toBe('')
|
||||||
|
expect(humanSize(0)).toBe('')
|
||||||
|
expect(humanSize(512)).toBe('512 B')
|
||||||
|
expect(humanSize(2048)).toBe('2 KB')
|
||||||
|
expect(humanSize(1536 * 1024)).toBe('1.5 MB')
|
||||||
|
})
|
||||||
|
it('picks icons by extension then kind', () => {
|
||||||
|
expect(artifactIcon('会议纪要.docx')).toBe('file')
|
||||||
|
expect(artifactIcon('截图.png')).toBe('image')
|
||||||
|
expect(artifactIcon('blob', 'image')).toBe('image')
|
||||||
|
expect(artifactIcon('打包.zip')).toBe('package')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage,
|
||||||
|
removeQueuedMessage, replaceQueuedText,
|
||||||
|
} from '../src/outbox'
|
||||||
|
|
||||||
|
describe('outbox', () => {
|
||||||
|
it('appends, edits, promotes, and removes', () => {
|
||||||
|
const a = newQueuedMessage('a', [], [])
|
||||||
|
const b = newQueuedMessage('b', ['f1'], ['note.md'])
|
||||||
|
let q = enqueueMessage(enqueueMessage([], a), b)
|
||||||
|
expect(q.map(x => x.text)).toEqual(['a', 'b'])
|
||||||
|
q = replaceQueuedText(q, a.id, 'a2')
|
||||||
|
expect(q[0].text).toBe('a2')
|
||||||
|
q = promoteQueuedMessage(q, b.id)
|
||||||
|
expect(q.map(x => x.text)).toEqual(['b', 'a2'])
|
||||||
|
q = removeQueuedMessage(q, b.id)
|
||||||
|
expect(q.map(x => x.text)).toEqual(['a2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts a busy conflict back at the front without duplicating', () => {
|
||||||
|
const a = newQueuedMessage('a', [], [])
|
||||||
|
const b = newQueuedMessage('b', [], [])
|
||||||
|
const q = enqueueMessageFront(enqueueMessage([], a), b)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// 空态点工件无反应(2026-10-10 实测复现):requestPreview 写 '_' 键、渲染判
|
||||||
|
// currentConvId 非空——键口径不一致导致空态点击只剩弹层关闭。这里测纯函数口径。
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { mergeOrphanPreviewTabs, previewTabsKey } from '../src/previewtabs'
|
||||||
|
|
||||||
|
describe('previewTabsKey', () => {
|
||||||
|
it('falls back to the empty-state key when no conversation is selected', () => {
|
||||||
|
expect(previewTabsKey(null)).toBe('_')
|
||||||
|
expect(previewTabsKey(undefined)).toBe('_')
|
||||||
|
expect(previewTabsKey('cnv_1')).toBe('cnv_1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mergeOrphanPreviewTabs', () => {
|
||||||
|
it('merges empty-state tabs into the conversation key and clears the orphan key', () => {
|
||||||
|
const got = mergeOrphanPreviewTabs({
|
||||||
|
_: [{ id: 'a', name: 'A.docx' }, { id: 'b', name: 'B.md' }],
|
||||||
|
cnv_1: [{ id: 'b', name: 'B.md' }],
|
||||||
|
}, 'cnv_1')
|
||||||
|
expect(got._).toEqual([])
|
||||||
|
expect(got.cnv_1.map(t => t.id)).toEqual(['b', 'a'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the input untouched when there is no orphan or the target is the orphan key', () => {
|
||||||
|
const none = { cnv_1: [{ id: 'a', name: 'A' }] }
|
||||||
|
expect(mergeOrphanPreviewTabs(none, 'cnv_1')).toBe(none)
|
||||||
|
const self = { _: [{ id: 'a', name: 'A' }] }
|
||||||
|
expect(mergeOrphanPreviewTabs(self, '_')).toBe(self)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { afterEach, describe, it, expect } from 'vitest'
|
||||||
|
import { computeFitScale, fitPagesToWidth } from '../src/previewfit'
|
||||||
|
|
||||||
|
describe('computeFitScale', () => {
|
||||||
|
it('keeps the page at 100% when the column is wider than the page', () => {
|
||||||
|
expect(computeFitScale(1100, 794, 24)).toBe(1)
|
||||||
|
expect(computeFitScale(818, 794, 24)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scales the page down to the column width after subtracting the gutter', () => {
|
||||||
|
expect(computeFitScale(420, 794, 24)).toBeCloseTo((420 - 24) / 794)
|
||||||
|
expect(computeFitScale(794, 794, 24)).toBeCloseTo((794 - 24) / 794)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not invert or zoom when the width is unusable', () => {
|
||||||
|
expect(computeFitScale(0, 794, 24)).toBe(1)
|
||||||
|
expect(computeFitScale(20, 794, 24)).toBe(1)
|
||||||
|
expect(computeFitScale(420, 0, 24)).toBe(1)
|
||||||
|
expect(computeFitScale(Number.NaN, 794, 24)).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// node 测试环境没有 DOM。这里只实现 fitPagesToWidth 会碰到的查询与尺寸。
|
||||||
|
class FakeEl {
|
||||||
|
style: { height: string; overflow: string; minHeight: string; transform: string; transformOrigin: string } = {
|
||||||
|
height: '', overflow: '', minHeight: '', transform: '', transformOrigin: '',
|
||||||
|
}
|
||||||
|
clientWidth = 0
|
||||||
|
offsetWidth = 0
|
||||||
|
offsetHeight = 0
|
||||||
|
className = ''
|
||||||
|
tag = 'div'
|
||||||
|
children: FakeEl[] = []
|
||||||
|
querySelector(sel: string): FakeEl | null {
|
||||||
|
const walk = (nodes: FakeEl[]): FakeEl | null => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (matches(node, sel)) return node
|
||||||
|
const hit = walk(node.children)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return walk(this.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(el: FakeEl, sel: string): boolean {
|
||||||
|
const classes = el.className.split(/\s+/).filter(Boolean)
|
||||||
|
if (sel.startsWith('.')) return classes.includes(sel.slice(1))
|
||||||
|
const [tag, cls] = sel.split('.')
|
||||||
|
return el.tag === tag && classes.includes(cls)
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeResizeObserver {
|
||||||
|
static observed: FakeEl[] = []
|
||||||
|
static instances: FakeResizeObserver[] = []
|
||||||
|
constructor(private callback: () => void) {
|
||||||
|
FakeResizeObserver.instances.push(this)
|
||||||
|
}
|
||||||
|
observe(el: FakeEl) { FakeResizeObserver.observed.push(el) }
|
||||||
|
disconnect() { /* dispose 只要求卸下监听 */ }
|
||||||
|
static fire() {
|
||||||
|
for (const instance of FakeResizeObserver.instances) instance.callback()
|
||||||
|
}
|
||||||
|
static reset() {
|
||||||
|
FakeResizeObserver.observed = []
|
||||||
|
FakeResizeObserver.instances = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('fitPagesToWidth', () => {
|
||||||
|
const previous = globalThis.ResizeObserver
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
FakeResizeObserver.reset()
|
||||||
|
globalThis.ResizeObserver = previous
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recomputes the host height when the page wrapper grows and clears inline styles on dispose', () => {
|
||||||
|
globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver
|
||||||
|
const host = new FakeEl()
|
||||||
|
host.clientWidth = 420
|
||||||
|
const wrapper = new FakeEl()
|
||||||
|
wrapper.className = 'docx-wrapper'
|
||||||
|
wrapper.offsetHeight = 1000
|
||||||
|
const page = new FakeEl()
|
||||||
|
page.tag = 'section'
|
||||||
|
page.className = 'docx'
|
||||||
|
page.offsetWidth = 794
|
||||||
|
wrapper.children.push(page)
|
||||||
|
host.children.push(wrapper)
|
||||||
|
|
||||||
|
const dispose = fitPagesToWidth(host as unknown as HTMLElement, host as unknown as HTMLElement)
|
||||||
|
const scale = (420 - 24) / 794
|
||||||
|
expect(host.style.height).toBe(`${Math.round(1000 * scale)}px`)
|
||||||
|
expect(host.style.overflow).toBe('hidden')
|
||||||
|
expect(FakeResizeObserver.observed).toContain(wrapper)
|
||||||
|
|
||||||
|
wrapper.offsetHeight = 1500
|
||||||
|
FakeResizeObserver.fire()
|
||||||
|
expect(host.style.height).toBe(`${Math.round(1500 * scale)}px`)
|
||||||
|
|
||||||
|
dispose()
|
||||||
|
expect(host.style.height).toBe('')
|
||||||
|
expect(host.style.overflow).toBe('')
|
||||||
|
expect(host.style.minHeight).toBe('')
|
||||||
|
expect(wrapper.style.transform).toBe('')
|
||||||
|
expect(wrapper.style.transformOrigin).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { openPreviewTab, closePreviewTab } from '../src/previewtabs'
|
||||||
|
|
||||||
|
describe('preview tabs', () => {
|
||||||
|
it('opens a new tab and re-activates an existing one without duplicating it', () => {
|
||||||
|
const first = openPreviewTab([], { id: 'a', name: '甲.docx' })
|
||||||
|
const second = openPreviewTab(first.tabs, { id: 'b', name: '乙.pptx' })
|
||||||
|
expect(second.tabs.map(t => t.id)).toEqual(['a', 'b'])
|
||||||
|
expect(second.activeId).toBe('b')
|
||||||
|
const again = openPreviewTab(second.tabs, { id: 'a', name: '甲.docx' })
|
||||||
|
expect(again.tabs).toHaveLength(2)
|
||||||
|
expect(again.activeId).toBe('a')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closing the active tab selects the neighbor, and closing another keeps the active tab', () => {
|
||||||
|
const tabs = [{ id: 'a', name: '甲' }, { id: 'b', name: '乙' }, { id: 'c', name: '丙' }]
|
||||||
|
const closedB = closePreviewTab(tabs, 'b', 'b')
|
||||||
|
expect(closedB.tabs.map(t => t.id)).toEqual(['a', 'c'])
|
||||||
|
expect(closedB.activeId).toBe('c')
|
||||||
|
const closedA = closePreviewTab(closedB.tabs, 'c', 'a')
|
||||||
|
expect(closedA.activeId).toBe('c')
|
||||||
|
expect(closePreviewTab([{ id: 'a', name: '甲' }], 'a', 'a')).toEqual({ tabs: [], activeId: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { classifyRdmError } from '../src/rdmErrors'
|
||||||
|
|
||||||
|
// 2026-10-12 增量 2(审计错误四分类):审计页不再把原始报错(400 回执 JSON)直出,
|
||||||
|
// 改为「能力缺失 / 登录已失效 / 查询失败」文案并保留原始回执供诊断;
|
||||||
|
// 「无可审计项目」由组件空态承担,不走本分类。
|
||||||
|
// 分类依据:api.ts json() 抛 `${status}: ${body}`;后端 unknown/disabled 命令
|
||||||
|
// 回 400 + "unknown or disabled command"(cmdbus.go Dispatch),总线未挂载回
|
||||||
|
// 503 "commands disabled"(server.go execCommand)。
|
||||||
|
describe('classifyRdmError', () => {
|
||||||
|
it('400 unknown or disabled command → 能力缺失', () => {
|
||||||
|
const raw = '400: {"receipt":{"state":"rejected","error":"unknown or disabled command: rdm.inspect.capabilities"}}'
|
||||||
|
const e = classifyRdmError(new Error(raw))
|
||||||
|
expect(e.kind).toBe('capability')
|
||||||
|
expect(e.message).toContain('当前服务未提供审计能力')
|
||||||
|
expect(e.raw).toContain('unknown or disabled command')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('503 commands disabled → 能力缺失', () => {
|
||||||
|
expect(classifyRdmError('503: commands disabled').kind).toBe('capability')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('401/403 → 登录已失效', () => {
|
||||||
|
expect(classifyRdmError('401: session expired').kind).toBe('auth')
|
||||||
|
expect(classifyRdmError(new Error('403: forbidden')).kind).toBe('auth')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('其余错误 → 查询失败,保留原始串', () => {
|
||||||
|
const e = classifyRdmError('500: boom')
|
||||||
|
expect(e.kind).toBe('query')
|
||||||
|
expect(e.message).toContain('查询失败')
|
||||||
|
expect(e.raw).toBe('500: boom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非 Error 输入可容忍', () => {
|
||||||
|
expect(classifyRdmError(undefined).kind).toBe('query')
|
||||||
|
expect(classifyRdmError({ weird: true }).kind).toBe('query')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// 任务列表组织纯函数测试(方案 A R1)。
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { groupByProject, groupByTimeline, relativeTime, filterTasks } from '../src/tasklist'
|
||||||
|
import type { Conversation, Project } from '../src/contracts'
|
||||||
|
|
||||||
|
const NOW = new Date('2026-09-30T15:00:00').getTime()
|
||||||
|
const H = 3_600_000
|
||||||
|
|
||||||
|
function conv(id: string, projectId: string, updatedAt: string, extra: Partial<Conversation> = {}): Conversation {
|
||||||
|
return { id, projectId, title: id, createdAt: updatedAt, updatedAt, ...extra }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('groupByProject', () => {
|
||||||
|
it('groups tasks under projects in project order and never drops orphans', () => {
|
||||||
|
const projects: Project[] = [
|
||||||
|
{ id: 'p2', name: '乙', createdAt: '', updatedAt: '' },
|
||||||
|
{ id: 'p1', name: '甲', createdAt: '', updatedAt: '' },
|
||||||
|
]
|
||||||
|
const tasks = [conv('a', 'p1', '2026-09-30T10:00:00Z'), conv('b', 'p2', '2026-09-29T10:00:00Z'), conv('c', 'pX', '2026-09-28T10:00:00Z')]
|
||||||
|
const groups = groupByProject(tasks, projects)
|
||||||
|
expect(groups.map(g => g.label)).toEqual(['乙', '甲', '未分组'])
|
||||||
|
expect(groups[0].projectId).toBe('p2')
|
||||||
|
expect(groups[2].tasks.map(t => t.id)).toEqual(['c'])
|
||||||
|
})
|
||||||
|
it('skips projects with zero visible tasks', () => {
|
||||||
|
const projects: Project[] = [{ id: 'p1', name: '甲', createdAt: '', updatedAt: '' }]
|
||||||
|
expect(groupByProject([], projects)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('groupByTimeline', () => {
|
||||||
|
it('buckets by local day and week boundaries', () => {
|
||||||
|
// NOW=周三 15:00(本地);构造今天/昨天/本周更早(周一)/更早 各一条。
|
||||||
|
const tasks = [
|
||||||
|
conv('today', 'p', new Date(NOW - H).toISOString()),
|
||||||
|
conv('yesterday', 'p', new Date(NOW - 26 * H).toISOString()),
|
||||||
|
conv('week', 'p', new Date(NOW - 50 * H).toISOString()),
|
||||||
|
conv('older', 'p', new Date(NOW - 10 * 24 * H).toISOString()),
|
||||||
|
]
|
||||||
|
const groups = groupByTimeline(tasks, NOW)
|
||||||
|
expect(groups.map(g => g.label)).toEqual(['今天', '昨天', '本周更早', '更早'])
|
||||||
|
expect(groups[0].tasks[0].id).toBe('today')
|
||||||
|
})
|
||||||
|
it('hides empty buckets and tolerates bad timestamps', () => {
|
||||||
|
const groups = groupByTimeline([conv('x', 'p', 'not-a-date')], NOW)
|
||||||
|
expect(groups).toHaveLength(1)
|
||||||
|
expect(groups[0].label).toBe('更早')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('relativeTime', () => {
|
||||||
|
it('formats gradients and year rollover', () => {
|
||||||
|
expect(relativeTime(new Date(NOW - 30_000).toISOString(), NOW)).toBe('刚刚')
|
||||||
|
expect(relativeTime(new Date(NOW - 5 * 60_000).toISOString(), NOW)).toBe('5 分钟')
|
||||||
|
expect(relativeTime(new Date(NOW - 3 * H).toISOString(), NOW)).toBe('3 小时')
|
||||||
|
expect(relativeTime(new Date(NOW - 30 * H).toISOString(), NOW)).toBe('昨天')
|
||||||
|
expect(relativeTime(new Date(NOW - 3 * 24 * H).toISOString(), NOW)).toBe('3 天')
|
||||||
|
expect(relativeTime('2026-05-01T00:00:00', NOW)).toBe('5/1')
|
||||||
|
expect(relativeTime('2025-12-31T00:00:00', NOW)).toBe('2025/12/31')
|
||||||
|
expect(relativeTime('bad')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('filterTasks', () => {
|
||||||
|
it('matches title case-insensitively and passes through on empty query', () => {
|
||||||
|
const tasks = [conv('a', 'p', '', { title: '周报 Draft' }), conv('b', 'p', '', { title: '接口评审' })]
|
||||||
|
expect(filterTasks(tasks, '周报')).toEqual([tasks[0]])
|
||||||
|
expect(filterTasks(tasks, ' ')).toEqual(tasks)
|
||||||
|
expect(filterTasks(tasks, 'DRAFT')).toEqual([tasks[0]])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { toolKind, toolHeadline, toolGroupLabel, timelineEntries } from '../src/toolkind'
|
||||||
|
import type { Part } from '../src/contracts'
|
||||||
|
|
||||||
|
function p(partial: Partial<Part> & { type: Part['type'] }): Part {
|
||||||
|
return partial as Part
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('toolKind', () => {
|
||||||
|
it('classifies known tools and falls back to other', () => {
|
||||||
|
expect(toolKind('fs_read_file')).toBe('read')
|
||||||
|
expect(toolKind('web_search')).toBe('search')
|
||||||
|
expect(toolKind('run_python')).toBe('exec')
|
||||||
|
expect(toolKind('register_artifact')).toBe('write')
|
||||||
|
expect(toolKind('submit_card')).toBe('card')
|
||||||
|
expect(toolKind('delegate_task')).toBe('delegate')
|
||||||
|
expect(toolKind('update_todos')).toBe('plan')
|
||||||
|
expect(toolKind('time_now')).toBe('other')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pulls a path, a query, or the first script line out of arguments', () => {
|
||||||
|
expect(toolHeadline('fs_read_file', '{"path":"notes/a.md"}')).toBe('notes/a.md')
|
||||||
|
expect(toolHeadline('web_search', '{"query":"会议冲突"}')).toBe('会议冲突')
|
||||||
|
expect(toolHeadline('run_python', '{"script":"print(1)\\nprint(2)"}')).toBe('print(1)')
|
||||||
|
expect(toolGroupLabel('read', 2, false)).toBe('查阅 · 2 个文件')
|
||||||
|
expect(toolGroupLabel('exec', 1, true)).toBe('运行命令 · 1 条')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('timelineEntries', () => {
|
||||||
|
it('merges consecutive tools of the same kind and leaves narration in place', () => {
|
||||||
|
const parts: Part[] = [
|
||||||
|
p({ type: 'thinking', process: true, text: '先看文件', durationMs: 1500 }),
|
||||||
|
p({ type: 'tool_call', process: true, toolCall: { id: 'a', name: 'fs_read_file', arguments: '{"path":"a.md"}' } }),
|
||||||
|
p({ type: 'tool_result', process: true, toolResult: { callId: 'a', status: 'ok', summary: '12 行' } }),
|
||||||
|
p({ type: 'tool_call', process: true, toolCall: { id: 'b', name: 'fs_read_file', arguments: '{"path":"b.md"}' } }),
|
||||||
|
p({ type: 'tool_result', process: true, toolResult: { callId: 'b', status: 'ok' } }),
|
||||||
|
p({ type: 'text', process: true, text: '两份都读完了' }),
|
||||||
|
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', 'toolblock'])
|
||||||
|
const reads = rows[1]
|
||||||
|
if (reads.kind !== 'tools') throw new Error('expected tool group')
|
||||||
|
expect(reads.toolKind).toBe('read')
|
||||||
|
expect(reads.items.map(it => it.headline)).toEqual(['a.md', 'b.md'])
|
||||||
|
expect(reads.items[0].summary).toBe('12 行')
|
||||||
|
const thinking = rows[0]
|
||||||
|
if (thinking.kind !== 'thinking') throw new Error('expected thinking')
|
||||||
|
expect(thinking.active).toBe(false)
|
||||||
|
const exec = rows[3]
|
||||||
|
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', () => {
|
||||||
|
const rows = timelineEntries([p({ type: 'thinking', process: true, text: '还在想' })], true)
|
||||||
|
expect(rows[0]).toMatchObject({ kind: 'thinking', active: true })
|
||||||
|
expect(timelineEntries([p({ type: 'thinking', process: true, text: '想完了' })], false)[0])
|
||||||
|
.toMatchObject({ kind: 'thinking', active: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildWorkspaceEntries, rdmAuditReady, RDM_AUDIT_COMMANDS } from '../src/workspaces'
|
||||||
|
|
||||||
|
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置「代理」。
|
||||||
|
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
|
||||||
|
// 没有任何 rdm.inspect.* 命令,入口展示后进入即 400。新契约:仅当命令面探测
|
||||||
|
// 通过(opts.rdmAudit)才纳入 rdm-audit;代理仍无条件以 ⌘1 补位。
|
||||||
|
describe('buildWorkspaceEntries', () => {
|
||||||
|
it('没有领域插件且无 RDM 能力:只有代理占 ⌘1', () => {
|
||||||
|
const entries = buildWorkspaceEntries([])
|
||||||
|
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有领域插件且 RDM 能力通过:代理 ⌘1、追溯审计 ⌘2', () => {
|
||||||
|
const entries = buildWorkspaceEntries([], { rdmAudit: true })
|
||||||
|
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理', '⌘2 追溯审计'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('声明了 shortcut 的工作区占住该位,内置工具让位(能力开)', () => {
|
||||||
|
const entries = buildWorkspaceEntries([
|
||||||
|
{ id: 'trace', title: '追溯网', shortcut: '1' },
|
||||||
|
], { rdmAudit: true })
|
||||||
|
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理', '追溯审计'])
|
||||||
|
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3'])
|
||||||
|
expect(entries[0].key).toBe('ws:trace')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无 RDM 能力:让位后只有代理,不出现追溯审计', () => {
|
||||||
|
const entries = buildWorkspaceEntries([
|
||||||
|
{ id: 'trace', title: '追溯网', shortcut: '1' },
|
||||||
|
])
|
||||||
|
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未编号的工作区排在内置工具后面', () => {
|
||||||
|
const entries = buildWorkspaceEntries([
|
||||||
|
{ id: 'mem', title: '记忆库' },
|
||||||
|
], { rdmAudit: true })
|
||||||
|
expect(entries.map(e => e.title)).toEqual(['代理', '追溯审计', '记忆库'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('九位占满时低序内置保留,溢出项截断', () => {
|
||||||
|
const server = Array.from({ length: 8 }, (_, i) => ({ id: `w${i}`, title: `w${i}`, shortcut: String(i + 1) }))
|
||||||
|
const entries = buildWorkspaceEntries(server, { rdmAudit: true })
|
||||||
|
// ⌘1–8 被声明占住;代理取 ⌘9;追溯审计溢出截断(slice 0,9)。
|
||||||
|
expect(entries.filter(e => e.key.startsWith('ws:'))).toHaveLength(8)
|
||||||
|
expect(entries.map(e => e.title)).toContain('代理')
|
||||||
|
expect(entries.map(e => e.title)).not.toContain('追溯审计')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 能力探测依据后端 GET /api/commands(server.go listCommands:{ id, title, …, enabled })。
|
||||||
|
// 审计工作区消费七个 rdm.inspect.* 命令;任一缺失或 enabled=false 都视为能力未就绪。
|
||||||
|
describe('rdmAuditReady', () => {
|
||||||
|
const all = RDM_AUDIT_COMMANDS.map(id => ({ id, enabled: true }))
|
||||||
|
|
||||||
|
it('七命令齐备且启用 → true', () => {
|
||||||
|
expect(rdmAuditReady(all)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('普通基座空清单 / 缺任一命令 → false', () => {
|
||||||
|
expect(rdmAuditReady([])).toBe(false)
|
||||||
|
expect(rdmAuditReady(all.filter(c => c.id !== 'rdm.inspect.history'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('存在但 enabled=false → false', () => {
|
||||||
|
const disabled = all.map(c => (c.id === 'rdm.inspect.capabilities' ? { ...c, enabled: false } : c))
|
||||||
|
expect(rdmAuditReady(disabled)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('清单不可用(未登录/请求失败)→ false,不展示入口', () => {
|
||||||
|
expect(rdmAuditReady(null)).toBe(false)
|
||||||
|
expect(rdmAuditReady(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
+5
-2
@@ -12,11 +12,14 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client", "node"],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@ui": ["../ui/src/index.ts"],
|
"@ui": ["../ui/src/index.ts"],
|
||||||
"@ui/*": ["../ui/src/*"]
|
"@ui/*": ["../ui/src/*"],
|
||||||
|
"react": ["./node_modules/@types/react"],
|
||||||
|
"react-dom": ["./node_modules/@types/react-dom"],
|
||||||
|
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "test"]
|
"include": ["src", "test"]
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/store.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/ProjectNav.tsx","./src/components/SettingsModal.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts"],"version":"5.9.3"}
|
|
||||||
+9
-1
@@ -10,7 +10,7 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: [{ find: '@ui', replacement: uiSrc }],
|
alias: [{ find: '@ui', replacement: uiSrc }],
|
||||||
// 源码组合下 ui 仓有自己的 node_modules;dedupe 强制最终 bundle 用 frontend 的单副本。
|
// 源码组合下 ui 仓有自己的 node_modules;dedupe 强制最终 bundle 用 frontend 的单副本。
|
||||||
dedupe: ['react', 'react-dom', 'marked'],
|
dedupe: ['react', 'react-dom', 'marked', 'dompurify'],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
@@ -18,5 +18,13 @@ export default defineConfig({
|
|||||||
'/api': { target: 'http://127.0.0.1:8787', changeOrigin: false },
|
'/api': { target: 'http://127.0.0.1:8787', changeOrigin: false },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// E2E(U4 运行验收):preview 服务 dist 并把 /api 代理到被测后端(e2e.sh 注入
|
||||||
|
// PLAYWRIGHT_API_TARGET;缺省与 dev 代理同值,本地 npm run preview 也保持可用)。
|
||||||
|
preview: {
|
||||||
|
port: Number(process.env.PLAYWRIGHT_PREVIEW_PORT || 4173),
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: process.env.PLAYWRIGHT_API_TARGET || 'http://127.0.0.1:8787', changeOrigin: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
test: { environment: 'node' },
|
test: { environment: 'node' },
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user