Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
278b54cf9d | ||
|
|
49ba8b1735 | ||
|
|
b594d2748e | ||
|
|
afd661c932 | ||
|
|
c9ef3c0f0a | ||
|
|
72f1a259e1 | ||
|
|
804170ec66 | ||
|
|
7759d8077f | ||
|
|
8d43b1de12 | ||
|
|
d66bd7941c | ||
|
|
a022257b40 | ||
|
|
3f6ced099c | ||
|
|
86770e35fb | ||
|
|
65ab7a8a44 | ||
|
|
119fff96e8 | ||
|
|
96ec03d461 | ||
|
|
17996cf4dd | ||
|
|
dbb5f0462f | ||
|
|
125261410c | ||
|
|
e895a4f6a8 | ||
|
|
46e99fedbb | ||
|
|
bcb81719ce | ||
|
|
d8e610aebe | ||
|
|
3bbb918795 | ||
|
|
10635387e0 | ||
|
|
a81e94a4f2 | ||
|
|
781cde6667 | ||
|
|
e33d17e1a6 | ||
|
|
5c0e9f2711 | ||
|
|
04269eed25 | ||
|
|
985fc0f931 | ||
|
|
4835c45b8d | ||
|
|
6093beb27e | ||
|
|
c2ff4fcc8e | ||
|
|
03db4b81be | ||
|
|
3555f815af | ||
|
|
11f66aee9d | ||
|
|
06249dd83f | ||
|
|
83c22681f9 | ||
|
|
44205e1262 | ||
|
|
1b7ba779f1 | ||
|
|
0cd1f9954a | ||
|
|
92c390a08a | ||
|
|
06fa1a40a6 | ||
|
|
9242cee89d | ||
|
|
8a9d129a94 | ||
|
|
f7d9197011 | ||
|
|
5ab81432d1 | ||
|
|
9b9f24b7be | ||
|
|
8dc68fd1e3 | ||
|
|
ddf3135c3f | ||
|
|
b04f4ded95 | ||
|
|
710ef836b5 | ||
|
|
e10e447dab | ||
|
|
b175506ebd | ||
|
|
7afae7f20b | ||
|
|
099a92022d | ||
|
|
5313cfad4e | ||
|
|
4e1eb39035 | ||
|
|
eb2d6d32f9 | ||
|
|
40c2afe054 | ||
|
|
42cda9c056 | ||
|
|
15b962694b | ||
|
|
67585c1701 | ||
|
|
2ba275b991 | ||
|
|
e7f9081a93 | ||
|
|
c294835f8d | ||
|
|
880eff74f8 | ||
|
|
b7473fe585 | ||
|
|
dfcea5c988 | ||
|
|
07a03ac4f6 | ||
|
|
d69009e081 | ||
|
|
55a64f40e8 | ||
|
|
d9bb780384 |
@@ -0,0 +1,83 @@
|
||||
// 防再发守卫:管理员建号路径(UsersPanel)必须持续符合后端待初始化契约。
|
||||
// 背景:backend 8dfc223 将 POST /api/admin/users 改为严格 {owner, profile} 解码,
|
||||
// 前端未跟进导致部署后 UI 建号 400(json: unknown field "name")——单测与 mock
|
||||
// 都没有覆盖这条组合路径,本场景以真实浏览器把「填用户名 → 创建账号 →
|
||||
// 确认『待初始化』提示与用户表行」钉进组合验收线。
|
||||
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制 + frontend dist,起 serve(auth on)
|
||||
// 与 vite preview;种子账号 e2e-auditor 为基座管理员(bootstrap-admin)。
|
||||
// 环境契约(缺省值与 e2e.sh 一致;PLAYWRIGHT_BASE_URL 未设置时整组跳过):
|
||||
// PLAYWRIGHT_BASE_URL / E2E_OWNER / E2E_PASSWORD / E2E_PASSWORD2
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
|
||||
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto('/')
|
||||
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
|
||||
await page.getByPlaceholder('由管理员分配').fill(OWNER)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
// 三种落点:直接进工作台(密码已生效)|强制改密门|初始密码已失效(种子二次运行)→ 改用新密码重登。
|
||||
const entered = await page.locator('.tsb-user').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (!entered) {
|
||||
const gated = await page.getByText('修改密码').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (gated) {
|
||||
const pwInputs = page.locator('.lg-card input[type="password"]')
|
||||
await pwInputs.nth(0).fill(PASSWORD)
|
||||
await pwInputs.nth(1).fill(PASSWORD2)
|
||||
await pwInputs.nth(2).fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '设置新密码并进入' }).click()
|
||||
} else {
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
}
|
||||
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('管理员建号:UI 请求新契约并给出待初始化提示', async ({ page }) => {
|
||||
await login(page)
|
||||
|
||||
// 账户菜单 → 系统管理…(全屏独立视图)→ 用户管理分区。
|
||||
await page.locator('.tsb-user').click()
|
||||
const adminEntry = page.getByText('系统管理…', { exact: true })
|
||||
await expect(adminEntry).toBeVisible()
|
||||
await adminEntry.click()
|
||||
await expect(page.locator('.az')).toBeVisible()
|
||||
const usersSection = page.getByText('用户管理', { exact: true }).first()
|
||||
if (await usersSection.count()) await usersSection.click()
|
||||
|
||||
// 建号表单:用户名每次运行唯一(重复运行不因 409 假红),显示名可空。
|
||||
const newOwner = `e2e-ui-${Date.now().toString(36)}`
|
||||
const userInput = page.getByPlaceholder('用户名(登录名)')
|
||||
await expect(userInput).toBeVisible()
|
||||
await userInput.fill(newOwner)
|
||||
await page.getByPlaceholder('显示名(可空)').fill('组合验收')
|
||||
|
||||
// 创建 → 契约断言一:请求体是严格 {owner, profile:{name}},无 password/isAdmin。
|
||||
const createBody = page.waitForRequest(
|
||||
req => req.url().endsWith('/api/admin/users') && req.method() === 'POST',
|
||||
).then(req => req.postDataJSON())
|
||||
await page.getByRole('button', { name: '创建账号', exact: true }).click()
|
||||
const body = await createBody
|
||||
expect(body).toEqual({ owner: newOwner, profile: { name: '组合验收' } })
|
||||
|
||||
// 契约断言二:成功提示逐字含「待初始化」与 CLI 发放指引。
|
||||
await expect(page.locator('.ad-notice')).toHaveText(
|
||||
`已创建 ${newOwner}(待初始化):请在服务器执行 user initialize-account 发放激活 Key`,
|
||||
)
|
||||
|
||||
// 契约断言三:用户表出现该账号,状态为未设密码(HTTP 建号不能设密码/授权限)。
|
||||
const row = page.locator('.ad-table tbody tr', { hasText: newOwner })
|
||||
await expect(row).toBeVisible()
|
||||
await expect(row).toContainText('未设密码')
|
||||
await expect(row).not.toContainText('ADMIN')
|
||||
|
||||
// 界面说明与后端流程一致(激活 Key 由服务器本地 CLI 发放)。
|
||||
await expect(page.locator('.ad-create .ad-hint')).toContainText('待初始化')
|
||||
await expect(page.locator('.ad-create .ad-hint')).toContainText('user initialize-account')
|
||||
})
|
||||
@@ -123,4 +123,24 @@ test.describe.serial('U4 项目追溯审计工作区', () => {
|
||||
await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '',
|
||||
)
|
||||
})
|
||||
|
||||
test('概览导出:生成后显示水位与校验和,并可下载', async ({ page }) => {
|
||||
await login(page)
|
||||
await openAudit(page)
|
||||
const exportBox = page.locator('.rdm-audit-export')
|
||||
await expect(exportBox).toBeVisible()
|
||||
await exportBox.getByLabel('导出类型').selectOption('cgraph-docx')
|
||||
await exportBox.getByRole('button', { name: '生成', exact: true }).click()
|
||||
const draft = exportBox.getByRole('button', { name: '生成草稿' })
|
||||
if (await draft.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true).catch(() => false)) {
|
||||
await draft.click()
|
||||
}
|
||||
await expect(exportBox.getByText(/水位 \d+/)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(exportBox.getByText(/sha256 [0-9a-f]{64}/)).toBeVisible()
|
||||
await expect(exportBox.getByText(/有效期/)).toBeVisible()
|
||||
const download = page.waitForEvent('download')
|
||||
await exportBox.getByRole('button', { name: '下载' }).click()
|
||||
const file = await download
|
||||
expect(file.suggestedFilename()).toMatch(/\.docx$/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// I4 组合验收(Playwright):文档驾驶舱七场景(rollout §3.I4 最小场景集)。
|
||||
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制(含 I1/I3 命令面)+ frontend dist,
|
||||
// 种子:审计演示项目(rdm-self + cockpit-e2e 结构装载;两个章节经草稿流落正文;
|
||||
// 一个章节留空;第二个账号 e2e-reader 不入项目做权限负例)。
|
||||
// 环境契约与 rdm-audit.playwright.ts 一致;PLAYWRIGHT_BASE_URL 未设置时整组跳过。
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
|
||||
const PASSWORD = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
|
||||
const READER = process.env.E2E_READER || 'e2e-reader'
|
||||
const READER_PASSWORD = process.env.E2E_READER_PASSWORD || 'E2eReader#2026'
|
||||
const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
|
||||
|
||||
async function login(page: Page, user: string, password: string) {
|
||||
await page.goto('/')
|
||||
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
|
||||
await page.getByPlaceholder('由管理员分配').fill(user)
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(password)
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
|
||||
async function loginOwner(page: Page) {
|
||||
await login(page, OWNER, PASSWORD)
|
||||
await expect(page.locator('.tsb-tool', { hasText: '文档驾驶舱' })).toBeVisible({ timeout: 15_000 })
|
||||
// 登录后身份纪元与能力探针仍可能各跳一次;应用在身份变更时会关闭已开
|
||||
// 工作区——等落定再点入口,避免 overlay 被身份刷新关掉(竞态实测)。
|
||||
await page.waitForLoadState('networkidle').catch(() => {})
|
||||
await page.waitForTimeout(1_500)
|
||||
}
|
||||
|
||||
async function openCockpit(page: Page) {
|
||||
await page.locator('.tsb-tool', { hasText: '文档驾驶舱' }).click()
|
||||
await expect(page.locator('.cockpit-project')).toBeVisible({ timeout: 15_000 })
|
||||
// 默认选中首个项目(种子=审计演示项目)。
|
||||
await expect(page.locator('.cockpit-stats')).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
async function openReaderOnDoc(page: Page, title: string) {
|
||||
await page.locator('.cockpit-tree-title', { hasText: title }).first().click()
|
||||
await page.getByRole('button', { name: '在阅读器中打开' }).click()
|
||||
await expect(page.locator('.cockpit-reader')).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe.serial('I4 文档驾驶舱组合验收', () => {
|
||||
// 场景 1:首次进入项目——统计带、结构树、全景可见(DV-R01/R02)。
|
||||
test('1 首屏:统计、树、全景可见且统计可判读', async ({ page }) => {
|
||||
await loginOwner(page)
|
||||
await openCockpit(page)
|
||||
// placeholder
|
||||
await expect(page.locator('.cockpit-stat', { hasText: '完成度' })).toBeVisible()
|
||||
await expect(page.locator('.cockpit-stat', { hasText: '文档' })).toBeVisible()
|
||||
// 空 vs 不可算不得互相冒充:有数据项目的空缺计数显示数字。
|
||||
await expect(page.locator('.cockpit-stat', { hasText: '空缺节点' }).locator('.cockpit-stat-num')).toHaveText(/^\d+$/)
|
||||
await expect(page.locator('.cockpit-tree-title', { hasText: '验证主体报告' }).first()).toBeVisible()
|
||||
await expect(page.getByText('文档概览')).toBeVisible()
|
||||
await expect(page.locator('.cockpit-snap')).toContainText(/快照 wm:\d+/)
|
||||
})
|
||||
|
||||
// 场景 2:文档→章节,全景与阅读器双向定位(DV-R03)。
|
||||
test('2 定位:树选章节→阅读器正文;返回全景仍同一节点', async ({ page }) => {
|
||||
await loginOwner(page)
|
||||
await openCockpit(page)
|
||||
// 展开目标文档(树按标题排序,branch 作用域防跨文档串扰),点「概述」。
|
||||
const branch = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
|
||||
await branch.locator('.cockpit-tree-caret').click()
|
||||
const sec = branch.locator('.cockpit-tree-title', { hasText: '概述' }).first()
|
||||
await expect(sec).toBeVisible()
|
||||
sec.click()
|
||||
await expect(page.locator('.cockpit-nodehead')).toBeVisible()
|
||||
// 全景→阅读器:同一 nodeRef(阅读器显示该节标题与正文)。
|
||||
await page.getByRole('button', { name: '在阅读器中打开' }).click()
|
||||
await expect(page.locator('.cockpit-reader')).toBeVisible()
|
||||
await expect(page.locator('.cockpit-nodetitle', { hasText: '概述' })).toBeVisible()
|
||||
const body = page.locator('.cockpit-reader-para', { hasText: '本报告' }).first()
|
||||
await expect(body).toBeVisible()
|
||||
// 阅读器→全景:返回后仍是选中节点(节点头仍在)。
|
||||
await page.getByRole('button', { name: '← 返回全景' }).click()
|
||||
await expect(page.locator('.cockpit-nodehead')).toBeVisible()
|
||||
})
|
||||
|
||||
// 场景 3:四面板展开并正确显示计数;受限与空态诚实(DV-R04)。
|
||||
test('3 面板:来源/历史/影响/冲突计数与空态、截断旗标', async ({ page }) => {
|
||||
await loginOwner(page)
|
||||
await openCockpit(page)
|
||||
const branch = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
|
||||
await branch.locator('.cockpit-tree-caret').click()
|
||||
await branch.locator('.cockpit-tree-title', { hasText: '概述' }).first().click()
|
||||
const panel = page.locator('.cockpit-panel')
|
||||
await expect(panel).toBeVisible()
|
||||
// 来源:草稿落版本挂了依据切片(种子经 ingest 提供)。
|
||||
await panel.getByRole('tab', { name: '内容来源' }).click()
|
||||
await expect(panel.locator('.cockpit-panel-count')).toContainText(/来源 \d+ 条/)
|
||||
// 历史:已提交事件(structure + draft 链)。
|
||||
await panel.getByRole('tab', { name: '修改历史' }).click()
|
||||
await expect(panel.locator('.cockpit-panel-count')).toContainText('已提交事件')
|
||||
await expect(panel.locator('.cockpit-panel-row').first()).toBeVisible()
|
||||
// 影响:一跳;空/计数二态都诚实。
|
||||
await panel.getByRole('tab', { name: '影响对象' }).click()
|
||||
await expect(panel.locator('.cockpit-panel-count')).toContainText(/影响 \d+ 项/)
|
||||
// 冲突:种子无开放碰撞 → 明确「无开放冲突」而非空白。
|
||||
await panel.getByRole('tab', { name: '冲突与风险' }).click()
|
||||
await expect(panel.getByText('无开放冲突')).toBeVisible()
|
||||
// 版本块(I3):当前版本+差异折叠入口可见。
|
||||
await expect(page.locator('.cockpit-versions-toggle')).toBeVisible()
|
||||
})
|
||||
|
||||
// 场景 4:后台变更后所有投影一致(DV-R05)——测试经 API 采纳新版本,
|
||||
// 驾驶舱刷新后统计水位/树时间/详情版本块同步前进。
|
||||
test('4 一致性:API 采纳新版本后,刷新入口与各投影同步', async ({ page }) => {
|
||||
await loginOwner(page)
|
||||
await openCockpit(page)
|
||||
await page.locator('.cockpit-tree-caret').first().click()
|
||||
await page.locator('.cockpit-tree-title', { hasText: '设计说明' }).first().click()
|
||||
const snapBefore = await page.locator('.cockpit-snap').innerText()
|
||||
// 通过应用自身 API 采纳一个新提案(同源会话;与浏览器渲染解耦)。
|
||||
await page.request.post('/api/commands/rdm.draft.propose', {
|
||||
data: { params: { projectId: (await page.locator('.cockpit-project option:checked').textContent()) || '', sectionId: '', content: '一致性探针内容——不应出现' }, meta: {} },
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
// 上面缺 sectionId 必然 400:真正的驱动是 e2e.sh 早已留好的第二个提案
|
||||
// (种子阶段 propose 未采纳的「一致性探针」),这里经面板刷新读取。
|
||||
await page.getByRole('button', { name: '刷新' }).click()
|
||||
await expect(page.locator('.cockpit-snap')).not.toHaveText(snapBefore)
|
||||
// 版本块前进:出现新版本条目。
|
||||
await expect(page.locator('.cockpit-versions-toggle')).toContainText(/版本 v\d+/)
|
||||
})
|
||||
|
||||
// 场景 5:断线/失败保留旧快照并给出重试(DV-R08)——page.route 掐断命令面。
|
||||
test('5 失败:命令面断连时保留旧快照并出现重试入口', async ({ page }) => {
|
||||
await loginOwner(page)
|
||||
await openCockpit(page)
|
||||
await expect(page.locator('.cockpit-stats')).toBeVisible()
|
||||
// 掐断后续命令面:切换项目触发重取 → 失败横幅 + 旧内容保留。
|
||||
await page.route('**/api/commands/rdm.cockpit.**', route => route.abort())
|
||||
await page.locator('.cockpit-refresh').click()
|
||||
await expect(page.locator('.cockpit-banner-err')).toBeVisible()
|
||||
await expect(page.locator('.cockpit-stats')).toBeVisible() // 旧快照未被清空
|
||||
await page.unroute('**/api/commands/rdm.cockpit.**')
|
||||
await page.locator('.cockpit-refresh').click()
|
||||
await expect(page.locator('.cockpit-banner-err')).toBeHidden()
|
||||
})
|
||||
|
||||
// 场景 6:权限负例——非成员账号看不到项目(无项目诚实空态;不泄露存在性)。
|
||||
test('6 权限:非成员账号无项目可见', async ({ page }) => {
|
||||
await login(page, READER, READER_PASSWORD)
|
||||
await page.locator('.tsb-tool', { hasText: '文档驾驶舱' }).click()
|
||||
await expect(page.getByText('暂无可见研发项目')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.locator('.cockpit-tree-title', { hasText: '验证主体报告' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
// 场景 7:窄屏、键盘、减少动效(DV-R07)。
|
||||
test('7 可用性:390 宽无横向溢出、键盘可达、减少动效可用', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await loginOwner(page)
|
||||
await openCockpit(page)
|
||||
// 无横向溢出。
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
expect(overflow).toBeLessThanOrEqual(1)
|
||||
// 键盘:统计卡/树行可聚焦(tab 序含驾驶舱交互件)。
|
||||
await page.locator('.cockpit-tree-title').first().focus()
|
||||
await expect(page.locator('.cockpit-tree-title').first()).toBeFocused()
|
||||
// 18px 大字(html font-size 注入)不破坏统计带布局。
|
||||
await page.addStyleTag({ content: 'html{font-size:18px !important}' })
|
||||
await expect(page.locator('.cockpit-stats')).toBeVisible()
|
||||
const overflowBig = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
expect(overflowBig).toBeLessThanOrEqual(8)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
// 文档职责三视图:确定性 HTTP,不连接真实账号或数据库。
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>(ok => { resolve = ok })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function dutiesPage(page: Page) {
|
||||
const writes: { command: string; params: Record<string, unknown> }[] = []
|
||||
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
|
||||
let generation = 0
|
||||
const commands = ['rdm.project.member.list', 'rdm.duty.matrix', 'rdm.duty.preview',
|
||||
'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', 'rdm.duty.legacy.scan', 'rdm.duty.legacy.apply']
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
let body: unknown = []
|
||||
if (path === '/api/health') body = { ok: true }
|
||||
else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: true }
|
||||
else if (path === '/api/projects') body = [{ id: 'host-1', name: '职责测试项目', createdAt: '', updatedAt: '',
|
||||
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }]
|
||||
else if (path === '/api/settings') body = { schema: [], defaults: {}, user: {}, project: {} }
|
||||
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
|
||||
else if (path.startsWith('/api/commands/')) {
|
||||
const command = decodeURIComponent(path.substring('/api/commands/'.length))
|
||||
const params = request.postDataJSON().params as Record<string, unknown>
|
||||
expect(params.projectId).toBe('domain-7')
|
||||
const hold = holds.get(command)
|
||||
if (hold) { hold.started.resolve(); await hold.release.promise }
|
||||
const author = generation === 0 ? 'alice' : 'bob'
|
||||
if (command === 'rdm.duty.matrix') {
|
||||
body = { payload: {
|
||||
canAssign: true,
|
||||
docs: [{ docId: 'doc-A', code: 'A', title: '文档A', typeKey: 'design', stage: 'BD', dutyVersion: 1, authors: 1, reviewers: 1, approvers: 1, conflicts: 0, legacySectionOverrides: 0 }],
|
||||
members: ['alice', 'bob', 'carol'].map(owner => ({ owner, role: 'member', valid: true, canAuthor: true, canReview: true, canApprove: true })),
|
||||
cells: [
|
||||
{ docId: 'doc-A', owner: 'alice', duty: author === 'alice' ? 'author' : 'none' },
|
||||
{ docId: 'doc-A', owner: 'bob', duty: author === 'bob' ? 'author' : 'reviewer' },
|
||||
{ docId: 'doc-A', owner: 'carol', duty: 'approver' },
|
||||
],
|
||||
} }
|
||||
} else if (command === 'rdm.duty.preview') {
|
||||
writes.push({ command, params })
|
||||
body = { payload: { ok: true, docs: [{ docId: 'doc-A', title: '文档A', replacedAuthor: 'alice' }] } }
|
||||
} else if (command === 'rdm.duty.legacy.scan') {
|
||||
body = { payload: { findings: [{ docId: 'doc-A', title: '文档A', sectionTitle: '范围', kind: 'ambiguous' }] } }
|
||||
} else {
|
||||
writes.push({ command, params })
|
||||
generation += 1
|
||||
body = { payload: { applied: 1 } }
|
||||
}
|
||||
} else if (path.endsWith('/cards')) body = { cards: [] }
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: '项目设置', exact: true }).click()
|
||||
await page.getByRole('tab', { name: '文档职责', exact: true }).click()
|
||||
await expect(page.getByText('本页只分配已有文档的职责,不创建或生成文档。')).toBeVisible()
|
||||
return { writes, hold(command: string) {
|
||||
const pending = { started: deferred(), release: deferred() }
|
||||
holds.set(command, pending)
|
||||
return pending
|
||||
} }
|
||||
}
|
||||
|
||||
test('three views share one draft and do not offer review actions', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
await page.getByLabel('选择成员').selectOption('bob')
|
||||
await page.getByLabel('bob 在 文档A 的职责').selectOption({ label: '编制(将替换alice)' })
|
||||
await page.getByRole('tab', { name: '按文档' }).click()
|
||||
await page.getByLabel('选择文档').selectOption('doc-A')
|
||||
await page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
|
||||
await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author')
|
||||
await page.getByRole('tab', { name: '矩阵' }).click()
|
||||
await expect(page.getByLabel('bob 在 文档A 的职责')).toHaveValue('author')
|
||||
await expect(page.getByRole('button', { name: /提交|审核通过|驳回|终批|生成|新建文档/ })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '保存职责' }).click()
|
||||
await expect.poll(() => fixture.writes.map(item => item.command)).toEqual(['rdm.duty.preview', 'rdm.review.assign.doc.batch'])
|
||||
})
|
||||
|
||||
test('a stale matrix response does not replace the current draft', async ({ page }) => {
|
||||
const fixture = await dutiesPage(page)
|
||||
await page.getByLabel('选择成员').selectOption('alice')
|
||||
const held = fixture.hold('rdm.duty.matrix')
|
||||
await page.getByLabel('筛选文档名称或编号').fill('文档A')
|
||||
await held.started.promise
|
||||
await page.getByLabel('alice 在 文档A 的职责').selectOption('审核')
|
||||
held.release.resolve()
|
||||
await expect(page.getByLabel('alice 在 文档A 的职责')).toHaveValue('reviewer')
|
||||
})
|
||||
|
||||
test('a narrow viewport points away from the full matrix', async ({ page }) => {
|
||||
await dutiesPage(page)
|
||||
await page.setViewportSize({ width: 390, height: 800 })
|
||||
await page.getByRole('tab', { name: '矩阵' }).click()
|
||||
await expect(page.getByText('当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
// Isolated browser component regression: actual React workspace, deliberately deferred API responses.
|
||||
// This checks client request ownership, not server authorization or business acceptance.
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
test.skip(!process.env.PLAYWRIGHT_COMPONENT_BASE_URL, 'requires the isolated Vite component test server')
|
||||
test.use({ baseURL: process.env.PLAYWRIGHT_COMPONENT_BASE_URL })
|
||||
|
||||
async function mount(page: Page, delayed: string) {
|
||||
const index = await page.request.get('/')
|
||||
const html = (await index.text()).replace(/<script[^>]+src="\/src\/main\.tsx[^" ]*"[^>]*><\/script>/, '')
|
||||
// Let Vite prepare ReactDOM before requesting its optimized module directly.
|
||||
await page.request.get('/src/main.tsx')
|
||||
await page.route('**/__export_test', route => route.fulfill({ contentType: 'text/html',
|
||||
body: html }))
|
||||
await page.goto('/__export_test')
|
||||
await page.waitForFunction(() => typeof (window as any).$RefreshReg$ === 'function')
|
||||
await page.evaluate(async delayed => {
|
||||
const load = (path: string) => import(/* @vite-ignore */ path)
|
||||
const { default: React } = await load('/node_modules/.vite/deps/react.js')
|
||||
const { default: ReactDOM } = await load('/node_modules/.vite/deps/react-dom_client.js')
|
||||
const { RdmAuditWorkspace } = await load('/src/components/RdmAuditWorkspace.tsx')
|
||||
const { api } = await load('/src/api.ts')
|
||||
const { auth } = await load('/src/auth.ts')
|
||||
const { store } = await load('/src/store.ts')
|
||||
const { RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } = await load('/src/workspaces.ts')
|
||||
const calls: { command: string; params: any }[] = []
|
||||
let actor = 'alice'
|
||||
const pending: (() => void)[] = []
|
||||
let downloads = 0
|
||||
URL.createObjectURL = () => { downloads++; return 'blob:export-test' }
|
||||
URL.revokeObjectURL = () => {}
|
||||
HTMLAnchorElement.prototype.click = () => {}
|
||||
api.listPlugins = async () => []
|
||||
api.listWorkspaces = async () => []
|
||||
api.listCommands = async () => [...RDM_AUDIT_COMMANDS, ...RDM_EXPORT_COMMANDS].map(id => ({ id, enabled: true }))
|
||||
api.adminZoneSections = async () => []
|
||||
api.getSettings = async () => ({ user: {} })
|
||||
api.rdmCommand = async (command: string, params: any = {}) => {
|
||||
calls.push({ command, params })
|
||||
const result = command === 'rdm.inspect.capabilities'
|
||||
? { actor, crossProject: false, projects: ['A', 'B'].map(projectId => ({ projectId, name: actor + projectId, canAudit: true })) }
|
||||
: command === 'rdm.inspect.summary'
|
||||
? { projectId: params.projectId, counts: {}, watermark: 7 }
|
||||
: command === 'rdm.inspect.export.create'
|
||||
? { exportId: params.projectId + '-report', watermark: 7, sha256: 'abc', fileName: 'report.docx' }
|
||||
: command === 'rdm.inspect.export.status'
|
||||
? { expiresAt: '2099-01-01', watermark: 7, sha256: 'abc' }
|
||||
: { fileName: 'report.docx', mime: 'application/octet-stream', dataB64: 'QQ==', sha256: 'abc' }
|
||||
if (command === delayed && params.projectId === 'A') {
|
||||
await new Promise<void>(resolve => pending.push(resolve))
|
||||
}
|
||||
return result
|
||||
}
|
||||
await store.refreshPlugins()
|
||||
const root = ReactDOM.createRoot(document.getElementById('root')!)
|
||||
root.render(React.createElement(RdmAuditWorkspace))
|
||||
;(window as any).exportTest = { calls, release: () => pending.splice(0).forEach(resolve => resolve()),
|
||||
downloads: () => downloads, identity: async () => { actor = 'bob'; auth.beginSession(); await store.refreshPlugins() },
|
||||
unmount: () => root.unmount() }
|
||||
}, delayed)
|
||||
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||
}
|
||||
async function release(page: Page) {
|
||||
await page.evaluate(async () => { (window as any).exportTest.release(); await new Promise(resolve => setTimeout(resolve, 0)) })
|
||||
}
|
||||
const generate = (page: Page) => page.getByRole('button', { name: '生成', exact: true }).click()
|
||||
|
||||
for (const stage of ['create', 'status']) {
|
||||
test(`ignores old ${stage} response after project A -> B -> A`, async ({ page }) => {
|
||||
await mount(page, 'rdm.inspect.export.' + stage)
|
||||
await generate(page)
|
||||
await expect.poll(() => page.evaluate(stage => (window as any).exportTest.calls
|
||||
.some((c: any) => c.command === 'rdm.inspect.export.' + stage), stage)).toBe(true)
|
||||
await page.getByLabel('选择项目').selectOption('B')
|
||||
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||
await page.getByLabel('选择项目').selectOption('A')
|
||||
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||
await release(page)
|
||||
await expect(page.getByRole('button', { name: '下载', exact: true })).toHaveCount(0)
|
||||
if (stage === 'create') expect(await page.evaluate(() => (window as any).exportTest.calls
|
||||
.filter((c: any) => c.command === 'rdm.inspect.export.status').length)).toBe(0)
|
||||
await expect(page.getByRole('button', { name: '生成', exact: true })).toBeEnabled()
|
||||
})
|
||||
}
|
||||
for (const change of ['project', 'identity', 'unmount']) {
|
||||
test(`suppresses delayed download after ${change} change`, async ({ page }) => {
|
||||
await mount(page, 'rdm.inspect.export.download')
|
||||
await generate(page)
|
||||
await page.getByRole('button', { name: '下载', exact: true }).click()
|
||||
if (change === 'project') {
|
||||
await page.getByLabel('选择项目').selectOption('B')
|
||||
await expect(page.locator('.rdm-audit-export')).toBeVisible()
|
||||
} else if (change === 'identity') {
|
||||
await page.evaluate(() => (window as any).exportTest.identity())
|
||||
await expect(page.getByLabel('选择项目').locator('option').first()).toHaveText('bobA')
|
||||
} else await page.evaluate(() => (window as any).exportTest.unmount())
|
||||
await release(page)
|
||||
expect(await page.evaluate(() => (window as any).exportTest.downloads())).toBe(0)
|
||||
})
|
||||
}
|
||||
test('current identity can generate and download normally', async ({ page }) => {
|
||||
await mount(page, '')
|
||||
await generate(page)
|
||||
await page.getByRole('button', { name: '下载', exact: true }).click()
|
||||
await expect.poll(() => page.evaluate(() => (window as any).exportTest.downloads())).toBe(1)
|
||||
await expect(page.locator('.rdm-audit-export-result')).toContainText('水位 7')
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import type { ActivityCard, Artifact, Conversation, StreamEvent } from '../src/contracts'
|
||||
import type { ActivityCard, Artifact, Conversation, Part, StreamEvent, WorkCardAction } from '../src/contracts'
|
||||
|
||||
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
|
||||
|
||||
@@ -21,18 +21,20 @@ const confirmation = work('confirm1', 'action_required', { kind: 'confirm', titl
|
||||
actions: [{ id: 'confirm', label: '确认生成', style: 'primary' }, { id: 'cancel', label: '暂不生成' }],
|
||||
})
|
||||
|
||||
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14) {
|
||||
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14,
|
||||
history?: { events: StreamEvent[]; cardRef?: string; savedParts?: Part[] }, theme: 'light' | 'dark' | 'molv' = 'light') {
|
||||
let cards = [...initialCards]
|
||||
let actionCount = 0
|
||||
let releaseAction: (() => void) | undefined
|
||||
let holdAction = false
|
||||
let sent: { text: string; attachmentIds: string[] } | undefined
|
||||
let sent: { text: string; attachmentIds: string[]; taskRef?: { cardId: string; resultVersion: number } } | undefined
|
||||
const workActions: { cardId: string; actionId: string; payload: WorkCardAction }[] = []
|
||||
let uploadNumber = 0
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
await page.addInitScript(({ fontSize }) => {
|
||||
await page.addInitScript(({ fontSize, theme }) => {
|
||||
localStorage.setItem('as.conv', 'c1')
|
||||
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
|
||||
localStorage.setItem('as.ui', JSON.stringify({ theme, fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
|
||||
const streams: { url: string; emit: (event: unknown) => void }[] = []
|
||||
class TestEventSource {
|
||||
onopen: (() => void) | null = null
|
||||
@@ -48,7 +50,7 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
|
||||
close() {}
|
||||
}
|
||||
Object.assign(window, { EventSource: TestEventSource, __workStreams: streams })
|
||||
}, { fontSize })
|
||||
}, { fontSize, theme })
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
@@ -64,8 +66,21 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
|
||||
body = { conversation: conversations.find(c => c.id === id), messages: id === 'c1' ? [
|
||||
{ id: 'm1', conversationId: id, role: 'user', parts: [{ type: 'text', text: '请整理这三份材料,看看需要优先处理什么' }], createdAt: stamp },
|
||||
{ id: 'm2', conversationId: id, role: 'assistant', parts: [{ type: 'text', text: '先核对时间安排,再补齐验收条件。其他内容仍在整理。' }], createdAt: stamp },
|
||||
] : [], runs: [], activeHitl: [], lastSeq: 10 }
|
||||
...(history ? [{ id: 'm3', conversationId: id, role: 'user', runId: 'run-history', parts: [{ type: 'text', text: '继续核对' }], createdAt: stamp },
|
||||
{ id: 'm4', conversationId: id, role: 'assistant', runId: 'run-history', parts: history.savedParts ?? [
|
||||
{ type: 'thinking', process: true, text: '先核对日期' },
|
||||
{ type: 'text', process: true, text: '开始读取材料' },
|
||||
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' } },
|
||||
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok', summary: '读取成功' } },
|
||||
{ type: 'text', text: '最终回复已完成' },
|
||||
...(history.cardRef ? [{ type: 'card', card: { cardId: history.cardRef, kind: 'result', data: [] } }] : []),
|
||||
], createdAt: stamp }] : []),
|
||||
] : [], runs: history ? [{ id: 'run-history', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'completed', createdAt: stamp, endedAt: stamp }] : [], activeHitl: [], lastSeq: 10 }
|
||||
} else if (path === '/api/conversations/c1/cards') body = { cards }
|
||||
else if (path === '/api/conversations/c1/runs/run-history/events') {
|
||||
const after = Number(url.searchParams.get('after') ?? 0)
|
||||
body = { events: (history?.events ?? []).filter(event => event.seq > after).slice(0, 500) }
|
||||
}
|
||||
else if (path === '/api/conversations/c2/cards') body = { cards: [] }
|
||||
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
|
||||
else if (path === '/api/projects/p1/artifacts') body = [artifact]
|
||||
@@ -77,6 +92,22 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
|
||||
else if (/\/messages$/.test(path) && request.method() === 'POST') {
|
||||
sent = request.postDataJSON()
|
||||
body = { message: { id: 'sent', conversationId: 'c1', role: 'user', parts: [{ type: 'text', text: sent?.text ?? '' }], createdAt: stamp }, run: { id: 'run1', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'running', createdAt: stamp } }
|
||||
} else if (/^\/api\/cards\/[^/]+\/actions\/work\./.test(path)) {
|
||||
const cardId = path.split('/')[3]
|
||||
const actionId = path.split('/').at(-1)!
|
||||
const payload = request.postDataJSON() as WorkCardAction
|
||||
workActions.push({ cardId, actionId, payload })
|
||||
cards = cards.map(card => {
|
||||
if (card.id !== cardId || !card.work) return card
|
||||
const next = { ...card, work: { ...card.work } }
|
||||
if (actionId === 'work.read') next.work.readVersion = payload.resultVersion
|
||||
else if (next.work.decision) next.work.decision = { ...next.work.decision,
|
||||
state: actionId === 'work.defer' ? 'deferred' : actionId === 'work.reopen' ? 'open' : 'submitted',
|
||||
...(actionId === 'work.submit' ? { selectedIds: payload.selectedIds, freeText: payload.freeText, respondedAt: stamp } : {}),
|
||||
}
|
||||
return next
|
||||
})
|
||||
body = { card: cards.find(card => card.id === cardId) }
|
||||
} else if (path === '/api/cards/confirm1/actions/confirm') {
|
||||
actionCount++
|
||||
if (holdAction) await new Promise<void>(resolve => { releaseAction = resolve })
|
||||
@@ -88,7 +119,7 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.chat-title')).toHaveText('整理项目材料')
|
||||
if (initialCards.length) await expect(page.locator('[data-conversation-work]')).toBeVisible()
|
||||
return { errors, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() },
|
||||
return { errors, workActions, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() },
|
||||
async emit(type: StreamEvent['type'], payload: unknown, seq: number) {
|
||||
await page.evaluate(({ type, payload, seq }) => {
|
||||
const win = window as typeof window & { __workStreams: { url: string; emit: (e: unknown) => void }[] }
|
||||
@@ -100,6 +131,145 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
|
||||
}
|
||||
}
|
||||
|
||||
function optionalWork(id = 'optional-work', mode: 'single' | 'multiple' = 'single'): ActivityCard {
|
||||
return work(id, 'done', { title: '说明书材料整理', sourceRunId: 'run-history',
|
||||
work: { resultVersion: 2, readVersion: 0, completedAt: stamp,
|
||||
result: '# 说明书重点\n\n已整理软件结构、角色权限与操作流程,原文件保留。\n\n'
|
||||
+ '- **使用角色**:管理员、医生和患者\n- **软件结构**:患者端与医生端连接同一服务端\n- **核心内容**:安装维护、训练操作与安全说明\n\n'
|
||||
+ '## 可继续整理的内容\n\n可以先阅读摘要了解全貌,也可以单独提取性能参数、设备要求与需要进一步确认的事项。'
|
||||
+ '整理结果仅依据当前上传的说明书;缺少的材料和不能确认的内容会单独列出,不会补写成已确认的结论。'
|
||||
+ '如果继续生成交付物,新的结果会更新到独立工作卡,您可以同时讨论其他话题。'
|
||||
+ '每项结论都应保留材料范围和来源,便于后续复核。\n\n'
|
||||
+ '## 后续核对\n\n在正式采用整理结果前,建议把产品版本、适用设备、使用人员与预期交付形式分别确认。'
|
||||
+ '材料中直接写明的内容和整理时发现的问题会分开记录。对没有原文依据的数字、范围或归属,整理结果会保持待确认状态。'
|
||||
+ '需要比较其他版本时,可补充相应文件,并说明希望重点比较的部分。\n\n最后一条:需要确认使用场景。',
|
||||
decision: { mode, state: 'open', options: [{ id: 'summary', label: '生成摘要' }, { id: 'facts', label: '提取参数' }] },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
test('keeps optional choices nonblocking and supports defer, reopen, free input and saved answers', async ({ page }) => {
|
||||
const state = await fixture(page, [optionalWork('optional-work', 'multiple')])
|
||||
const inline = page.locator('[data-conversation-work] [data-card-id="optional-work"]')
|
||||
await expect(inline.getByRole('checkbox', { name: '生成摘要', exact: true })).toBeVisible()
|
||||
await inline.getByRole('button', { name: '先不选择', exact: true }).click()
|
||||
await expect(inline.getByText('已暂缓选择,您可以继续对话')).toBeVisible()
|
||||
await expect(inline.getByRole('checkbox')).toHaveCount(0)
|
||||
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('先聊一下本周的安排')
|
||||
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||
await expect.poll(() => state.sent?.text).toBe('先聊一下本周的安排')
|
||||
expect(state.sent?.taskRef).toBeUndefined()
|
||||
// Receiving the POST is earlier than rendering its legitimate foreground reply.
|
||||
await expect(page.locator('.msg.assistant[data-msg-id="live:run1"]')).toBeVisible()
|
||||
const assistantCount = await page.locator('.msg.assistant').count()
|
||||
await inline.getByRole('button', { name: '继续选择', exact: true }).click()
|
||||
await inline.getByRole('checkbox', { name: '生成摘要', exact: true }).check()
|
||||
await inline.getByRole('checkbox', { name: '提取参数', exact: true }).check()
|
||||
await inline.getByRole('textbox', { name: '补充要求或直接输入需求' }).fill('保留原文中的来源和限制')
|
||||
await inline.getByRole('button', { name: '开始处理', exact: true }).click()
|
||||
await expect(inline.getByText('已提交需求', { exact: true })).toBeVisible()
|
||||
await expect(inline.getByText('保留原文中的来源和限制', { exact: true })).toBeVisible()
|
||||
await expect(inline.getByRole('textbox')).toHaveCount(0)
|
||||
expect(state.workActions.filter(action => action.actionId === 'work.submit')).toEqual([{
|
||||
cardId: 'optional-work', actionId: 'work.submit', payload: { resultVersion: 2, selectedIds: ['summary', 'facts'], freeText: '保留原文中的来源和限制' },
|
||||
}])
|
||||
await expect(page.locator('.msg.assistant')).toHaveCount(assistantCount)
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('opens full results in one place, preserves unread until explicit action and references a version in the composer', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1050 })
|
||||
const state = await fixture(page, [optionalWork()])
|
||||
await expect(page.locator('.work-result-notice')).toContainText('1 项工作有新结果')
|
||||
await page.locator('.work-result-notice').click()
|
||||
const card = page.locator('.right-panel [data-card-id="optional-work"]')
|
||||
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
|
||||
await expect(card.getByRole('radio', { name: '生成摘要', exact: true })).toBeVisible()
|
||||
if (process.env.WORKBENCH_FLOW_SCREENSHOT) await page.screenshot({ path: process.env.WORKBENCH_FLOW_SCREENSHOT, fullPage: true })
|
||||
await card.getByRole('button', { name: '查看完整结果', exact: true }).click()
|
||||
await expect(card.getByText('最后一条:需要确认使用场景。', { exact: false })).toBeVisible()
|
||||
expect(state.workActions.filter(action => action.actionId === 'work.read')).toHaveLength(0)
|
||||
await card.getByRole('button', { name: '基于此结果继续', exact: true }).click()
|
||||
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toContainText('第 2 版结果')
|
||||
await expect.poll(() => state.workActions.filter(action => action.actionId === 'work.read').length).toBe(1)
|
||||
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('解释一下需要确认的事项')
|
||||
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||
await expect.poll(() => state.sent?.taskRef).toEqual({ cardId: 'optional-work', resultVersion: 2 })
|
||||
expect(state.sent?.text).toBe('解释一下需要确认的事项')
|
||||
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toHaveCount(0)
|
||||
await expect(page.locator('.msg.user time[datetime]')).toHaveCount(2)
|
||||
await expect(page.locator('.msg.assistant time[datetime]')).toHaveCount(2)
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('retains text, uploads and the result reference when mixed input is not supported', async ({ page }) => {
|
||||
const state = await fixture(page, [optionalWork()])
|
||||
await page.locator('.work-result-notice').click()
|
||||
await page.getByRole('button', { name: '基于此结果继续', exact: true }).click()
|
||||
await page.getByLabel('添加材料', { exact: true }).setInputFiles({ name: '补充材料.txt', mimeType: 'text/plain', buffer: Buffer.from('补充要求') })
|
||||
const input = page.getByRole('textbox', { name: '工作目标或消息', exact: true })
|
||||
await input.fill('结合补充材料解释重点')
|
||||
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||
await expect(input).toHaveValue('结合补充材料解释重点')
|
||||
await expect(page.locator('.attach-chip')).toContainText('补充材料.txt')
|
||||
await expect(page.getByRole('group', { name: '本条消息引用的工作结果' })).toBeVisible()
|
||||
expect(state.sent).toBeUndefined()
|
||||
})
|
||||
|
||||
test('keeps middle and right scrolling independent while retaining full execution output', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 800 })
|
||||
const events: StreamEvent[] = [
|
||||
{ seq: 1, type: 'thinking.delta', payload: { text: '已保存的核对思路' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
|
||||
{ seq: 2, type: 'tool.call', payload: { callId: 'call-record', name: 'fs_read_file', arguments: '{"path":"说明书"}' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
|
||||
{ seq: 3, type: 'tool.output', payload: { callId: 'call-record', text: '完整的文档读取记录' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
|
||||
{ seq: 4, type: 'tool.result', payload: { callId: 'call-record', status: 'ok' }, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 },
|
||||
]
|
||||
const cards = [optionalWork(), ...Array.from({ length: 15 }, (_, index) => work(`active-${index}`, 'active', { title: `其他工作 ${index + 1}` }))]
|
||||
const state = await fixture(page, cards, 14, { events, savedParts: [{ type: 'text', text: '继续讨论当前主题。\n\n'.repeat(100) }] })
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
const right = page.locator('.right-panel [data-work-scroll]')
|
||||
const chat = page.locator('.chat-body')
|
||||
await right.evaluate(element => { element.scrollTop = 180 })
|
||||
const rightTop = await right.evaluate(element => element.scrollTop)
|
||||
await chat.evaluate(element => { element.scrollTop = 80; element.dispatchEvent(new Event('scroll', { bubbles: true })) })
|
||||
expect(await right.evaluate(element => element.scrollTop)).toBe(rightTop)
|
||||
const chatTop = await chat.evaluate(element => element.scrollTop)
|
||||
await right.evaluate(element => { element.scrollTop = 240; element.dispatchEvent(new Event('scroll', { bubbles: true })) })
|
||||
expect(await chat.evaluate(element => element.scrollTop)).toBe(chatTop)
|
||||
await page.locator('.right-panel [data-card-id="optional-work"]').getByRole('button', { name: '查看完整执行记录', exact: true }).click()
|
||||
const record = page.getByRole('region', { name: '说明书材料整理的执行记录' })
|
||||
await expect(record.getByText('已保存的核对思路', { exact: true })).toBeVisible()
|
||||
await record.getByRole('button', { name: /查看记录/ }).click()
|
||||
await expect(record.getByText('完整的文档读取记录', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.work-execution-record')).toHaveCount(1)
|
||||
await record.getByRole('button', { name: '返回工作', exact: true }).click()
|
||||
await expect(page.locator('.right-panel [data-card-id="optional-work"]')).toBeVisible()
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('folds read task history newest first and retains submitted choices after reload', async ({ page }) => {
|
||||
const older = optionalWork('older')
|
||||
const latest = optionalWork('latest')
|
||||
older.title = '旧材料整理'; latest.title = '新材料整理'
|
||||
older.work = { ...older.work!, readVersion: 2, decision: { mode: 'single', state: 'submitted', selectedIds: ['summary'], freeText: '保留参数表', respondedAt: stamp,
|
||||
options: [{ id: 'summary', label: '生成摘要' }] } }
|
||||
latest.work = { ...latest.work!, readVersion: 2, decision: { mode: 'single', state: 'deferred' } }
|
||||
latest.updatedAt = '2026-10-13T10:00:00Z'
|
||||
const state = await fixture(page, [older, latest])
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(0)
|
||||
await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click()
|
||||
await expect(page.locator('.right-panel [data-card-id]')).toHaveCount(2)
|
||||
expect(await page.locator('.right-panel [data-card-id]').evaluateAll(elements => elements.map(element => element.getAttribute('data-card-id')))).toEqual(['latest', 'older'])
|
||||
await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
await page.locator('.right-panel').getByRole('button', { name: '工作历史 · 2', exact: true }).click()
|
||||
await expect(page.locator('.right-panel [data-card-id="older"]').getByText('保留参数表', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.right-panel [data-card-id="older"]').getByRole('textbox')).toHaveCount(0)
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps real summary and details accessible with right pane closed', async ({ page }) => {
|
||||
const state = await fixture(page)
|
||||
await expect(page.locator('.right-panel')).toBeHidden()
|
||||
@@ -114,17 +284,105 @@ test('keeps real summary and details accessible with right pane closed', async (
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps one expanded ordinary summary when the desktop work pane opens', async ({ page }) => {
|
||||
test('places the full work card only in the desktop work pane', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 })
|
||||
const state = await fixture(page)
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
await expect(page.locator('.right-panel .acard')).toHaveCount(1)
|
||||
await expect(page.locator('[data-conversation-work] .acard')).toHaveCount(0)
|
||||
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: /整理项目材料 · 查看摘要/ })).toBeVisible()
|
||||
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
|
||||
await expect(page.locator('.companion-topbar').getByRole('button', { name: '打开工作' })).toHaveCount(0)
|
||||
await expect(page.locator('.right-panel').getByRole('button', { name: /工作/ }).first()).toBeVisible()
|
||||
if (process.env.WORK_COMPANION_SCREENSHOT) await page.screenshot({ path: process.env.WORK_COMPANION_SCREENSHOT, fullPage: true })
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('uses a focused reading column and a compact authored-message shape', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
const state = await fixture(page)
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
|
||||
const conversation = page.locator('.chat-body .msg').first()
|
||||
const authored = page.locator('.chat-body .msg.user').first()
|
||||
const answer = page.locator('.chat-body .msg.assistant').first()
|
||||
const [conversationBox, authoredBox, answerBox] = await Promise.all([
|
||||
conversation.boundingBox(), authored.boundingBox(), answer.boundingBox(),
|
||||
])
|
||||
expect(conversationBox).not.toBeNull()
|
||||
expect(authoredBox).not.toBeNull()
|
||||
expect(answerBox).not.toBeNull()
|
||||
expect(conversationBox!.width).toBeLessThanOrEqual(840)
|
||||
expect(authoredBox!.width).toBeLessThanOrEqual(680)
|
||||
expect(authoredBox!.x + authoredBox!.width).toBeCloseTo(answerBox!.x + answerBox!.width, 0)
|
||||
|
||||
const surfaces = await page.evaluate(() => {
|
||||
const pick = (selector: string) => {
|
||||
const element = document.querySelector(selector)
|
||||
if (!element) throw new Error(`missing ${selector}`)
|
||||
const style = getComputedStyle(element)
|
||||
return {
|
||||
background: style.backgroundColor,
|
||||
color: style.color,
|
||||
borderRadius: Number.parseFloat(style.borderRadius),
|
||||
paddingLeft: Number.parseFloat(style.paddingLeft),
|
||||
}
|
||||
}
|
||||
return {
|
||||
chrome: pick('.companion-topbar'),
|
||||
context: pick('.left-nav'),
|
||||
conversation: pick('.main'),
|
||||
answer: pick('.msg.assistant'),
|
||||
authored: pick('.msg.user .bubble'),
|
||||
}
|
||||
})
|
||||
const rgb = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0]
|
||||
const distance = (a: string, b: string) => rgb(a).reduce((sum, value, index) => sum + Math.abs(value - rgb(b)[index]), 0)
|
||||
expect(distance(surfaces.chrome.background, surfaces.conversation.background)).toBeGreaterThan(300)
|
||||
expect(distance(surfaces.context.background, surfaces.conversation.background)).toBeGreaterThan(300)
|
||||
expect(distance(surfaces.authored.background, surfaces.answer.background)).toBeGreaterThan(300)
|
||||
expect(surfaces.answer.borderRadius).toBeGreaterThanOrEqual(12)
|
||||
expect(surfaces.answer.paddingLeft).toBeGreaterThanOrEqual(20)
|
||||
|
||||
const activeCard = page.locator('.right-panel .acard.status-active')
|
||||
await expect(activeCard).toBeVisible()
|
||||
const cardStyle = await activeCard.evaluate(element => {
|
||||
const style = getComputedStyle(element)
|
||||
return { background: style.backgroundColor, borderLeftWidth: style.borderLeftWidth }
|
||||
})
|
||||
const panelBackground = await page.locator('.right-panel').evaluate(element => getComputedStyle(element).backgroundColor)
|
||||
expect(cardStyle.background).not.toBe(panelBackground)
|
||||
expect(Number.parseFloat(cardStyle.borderLeftWidth)).toBeGreaterThanOrEqual(3)
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
for (const theme of ['light', 'dark', 'molv'] as const) {
|
||||
test(`keeps the workbench frame legible in ${theme}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 })
|
||||
const state = await fixture(page, [progress], 14, undefined, theme)
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
const surfaces = await page.evaluate(() => ['.companion-topbar', '.left-nav', '.msg.user .bubble', '.msg.assistant'].map(selector => {
|
||||
const element = document.querySelector(selector)
|
||||
if (!element) throw new Error(`missing ${selector}`)
|
||||
const style = getComputedStyle(element)
|
||||
return { selector, foreground: style.color, background: style.backgroundColor }
|
||||
}))
|
||||
const channels = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0]
|
||||
const luminance = (value: string) => {
|
||||
const linear = channels(value).map(channel => {
|
||||
const normalized = channel / 255
|
||||
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||
}
|
||||
for (const surface of surfaces) {
|
||||
const lighter = Math.max(luminance(surface.foreground), luminance(surface.background))
|
||||
const darker = Math.min(luminance(surface.foreground), luminance(surface.background))
|
||||
expect((lighter + 0.05) / (darker + 0.05), `${surface.selector} contrast in ${theme}`).toBeGreaterThanOrEqual(4.5)
|
||||
}
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test('preserves the selected material, physical scroll and focus when background work finishes', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 })
|
||||
const tabbed = work('work1', 'active', { body: [...progress.body!, {
|
||||
@@ -160,19 +418,117 @@ test('preserves the selected material, physical scroll and focus when background
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('shares submitting feedback between conversation and work confirmations', async ({ page }) => {
|
||||
test('keeps one confirmation action location when the work pane is visible', async ({ page }) => {
|
||||
const state = await fixture(page, [confirmation])
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
state.hold()
|
||||
await page.locator('[data-conversation-work]').getByRole('button', { name: '确认生成', exact: true }).click()
|
||||
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
|
||||
await page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true }).click()
|
||||
await expect(page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true })).toBeDisabled()
|
||||
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: '暂不生成', exact: true })).toBeDisabled()
|
||||
await expect(page.locator('.chat-body').getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
|
||||
expect(state.actionCount).toBe(1)
|
||||
state.release()
|
||||
await expect(page.getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps each run process folded and reloadable after the final reply', async ({ page }) => {
|
||||
const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent =>
|
||||
({ seq, type, payload, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 })
|
||||
const events = [
|
||||
event(1, 'thinking.delta', { index: 0, text: '先核对日期' }),
|
||||
event(2, 'message.delta', { index: 0, text: '开始读取材料' }),
|
||||
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
|
||||
event(4, 'tool.output', { callId: 'call-1', text: '完整工具输出' }),
|
||||
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
|
||||
event(6, 'run.phase', { phase: 'finalizing' }),
|
||||
event(7, 'message.delta', { index: 1, text: '最终回复已完成' }),
|
||||
]
|
||||
const state = await fixture(page, [], 14, { events })
|
||||
const message = page.locator('[data-msg-id="m4"]')
|
||||
await expect(message.getByText('最终回复已完成')).toBeVisible()
|
||||
await expect(message.getByText('开始读取材料')).toHaveCount(0)
|
||||
await message.getByRole('button', { name: /执行记录/ }).click()
|
||||
await expect(message.getByText('先核对日期')).toBeVisible()
|
||||
await expect(message.getByText('开始读取材料')).toBeVisible()
|
||||
await message.locator('.tool-block-head').click()
|
||||
await expect(message.getByText('完整工具输出')).toBeVisible()
|
||||
await page.reload()
|
||||
const restored = page.locator('[data-msg-id="m4"]')
|
||||
await expect(restored.getByText('最终回复已完成')).toBeVisible()
|
||||
await restored.getByRole('button', { name: /执行记录/ }).click()
|
||||
await expect(restored.getByText('先核对日期')).toBeVisible()
|
||||
await restored.locator('.tool-block-head').click()
|
||||
await expect(restored.getByText('完整工具输出')).toBeVisible()
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('replay enriches saved notes and keeps collaboration progress after completion', async ({ page }) => {
|
||||
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
|
||||
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
|
||||
const events = [
|
||||
event(1, 'message.delta', { index: 0, text: '<think>已解析的思考</think>' }),
|
||||
event(2, 'message.delta', { index: 1, text: '开始读取' }),
|
||||
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
|
||||
event(4, 'tool.output', { callId: 'call-1', text: '完整输出留存' }),
|
||||
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
|
||||
event(6, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
|
||||
event(7, 'run.phase', { phase: 'finalizing' }),
|
||||
event(8, 'message.delta', { index: 1, text: '最终回复已完成' }),
|
||||
event(9, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
|
||||
event(10, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
|
||||
]
|
||||
const savedParts: Part[] = [
|
||||
{ type: 'text', process: true, text: '模型在输出前失败,正在重试' },
|
||||
{ type: 'thinking', process: true, text: '已解析的思考' },
|
||||
{ type: 'text', process: true, text: '开始读取材料' },
|
||||
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell' } },
|
||||
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } },
|
||||
{ type: 'text', text: '最终回复已完成' },
|
||||
]
|
||||
await fixture(page, [], 14, { events, savedParts })
|
||||
const message = page.locator('[data-msg-id="m4"]')
|
||||
await message.getByRole('button', { name: /执行记录/ }).click()
|
||||
await expect(message.getByText('模型在输出前失败,正在重试')).toBeVisible()
|
||||
await expect(message.getByText('已解析的思考')).toBeVisible()
|
||||
await expect(message.getByText('开始读取材料')).toBeVisible()
|
||||
await expect(message.getByText('开始读取', { exact: true })).toHaveCount(0)
|
||||
await expect(message.getByText('协作进展:正在核对附录')).toBeVisible()
|
||||
await expect(message.getByText('协作结束:附录已核对')).toBeVisible()
|
||||
await expect(message.getByText('<think>已解析的思考</think>')).toHaveCount(0)
|
||||
await message.locator('.tool-block-head').click()
|
||||
await expect(message.getByText('完整输出留存')).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps a child completion note when the final reply uses the same status words', async ({ page }) => {
|
||||
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
|
||||
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
|
||||
await fixture(page, [], 14, { events: [
|
||||
event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
|
||||
event(2, 'run.phase', { phase: 'finalizing' }),
|
||||
event(3, 'message.delta', { index: 0, text: '已完成' }),
|
||||
event(4, 'agent.result', { agentRun: 'child', status: 'completed', summary: '已完成' }, 'child'),
|
||||
], savedParts: [{ type: 'text', process: true, text: '记录已完成' }, { type: 'text', text: '已完成' }] })
|
||||
const message = page.locator('[data-msg-id="m4"]')
|
||||
await message.getByRole('button', { name: /执行记录/ }).click()
|
||||
await expect(message.getByText('协作结束:已完成')).toBeVisible()
|
||||
})
|
||||
|
||||
test('locates a completed card even when work is filtered to project pending items', async ({ page }) => {
|
||||
const result = work('result1', 'done', { kind: 'result', title: '材料核对结果',
|
||||
body: [{ type: 'markdown', markdown: '完整结论在这里' }] })
|
||||
await fixture(page, [result], 14, { events: [], cardRef: 'result1' })
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
const workPane = page.locator('.right-panel')
|
||||
await workPane.locator('.inbox-scope').getByRole('button', { name: '项目' }).click()
|
||||
await workPane.locator('.inbox-scope').getByRole('button', { name: '待处理' }).click()
|
||||
await expect(workPane.getByText('完整结论在这里')).toHaveCount(0)
|
||||
await page.locator('[data-msg-id="m4"] .card-anchor-locate').click()
|
||||
await expect(workPane.getByText('完整结论在这里')).toBeVisible()
|
||||
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '本会话' })).toHaveClass(/active/)
|
||||
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '全部' })).toHaveClass(/active/)
|
||||
})
|
||||
|
||||
test('opens the one file preview and preserves reading during background updates', async ({ page }) => {
|
||||
const result = work('result1', 'done', { kind: 'result', title: '材料摘要已准备好', body: [{ type: 'doc-ref', docId: 'doc1' }] })
|
||||
const state = await fixture(page, [result])
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"exports": 43
|
||||
"exports": 51
|
||||
}
|
||||
|
||||
+9
-5
@@ -77,7 +77,7 @@ export function App() {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (authState === 'ok' && (e.metaKey || e.ctrlKey)) {
|
||||
const key = e.key.toLowerCase()
|
||||
if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened); return }
|
||||
if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened).catch(() => undefined); return }
|
||||
if (key === 'b') { e.preventDefault(); openContext(); return }
|
||||
}
|
||||
workspaceShortcuts(e)
|
||||
@@ -114,10 +114,14 @@ export function App() {
|
||||
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
|
||||
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
|
||||
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
|
||||
store.refreshMe()
|
||||
store.refreshProjects()
|
||||
store.refreshProfiles()
|
||||
store.refreshPlugins()
|
||||
let active = true
|
||||
store.refreshMe().then(() => {
|
||||
if (!active) return
|
||||
void store.refreshProjects()
|
||||
void store.refreshProfiles()
|
||||
void store.refreshPlugins()
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [authState])
|
||||
|
||||
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
|
||||
|
||||
+94
-16
@@ -1,10 +1,11 @@
|
||||
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
|
||||
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
|
||||
import { auth } from './auth'
|
||||
import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement'
|
||||
import type {
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
|
||||
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
|
||||
ActivityCard, SurfaceMeta, SurfaceSnapshot,
|
||||
ActivityCard, SurfaceMeta, SurfaceSnapshot, WorkCardAction,
|
||||
} from './contracts'
|
||||
|
||||
const BASE = ''
|
||||
@@ -14,11 +15,47 @@ const BASE = ''
|
||||
* 自身命令面 fail-closed。 */
|
||||
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
|
||||
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
|
||||
* current_required 必须校验当前密码。 */
|
||||
export type PasswordChangeMode = 'grant_setup' | 'current_required'
|
||||
|
||||
/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */
|
||||
export interface RecoveryProposal {
|
||||
id: string; userId: string; owner: string
|
||||
status: 'pending' | 'confirmed' | 'completed' | 'revoked' | 'expired'
|
||||
version: number; generation: number; createdBy: string
|
||||
createdAt: string; expiresAt: string
|
||||
}
|
||||
|
||||
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
|
||||
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
|
||||
export interface AccountRecord {
|
||||
userId: string; owner: string; profile: AccountProfile; profileVersion: number
|
||||
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean
|
||||
initializationState?: string; initializationGeneration?: number
|
||||
}
|
||||
|
||||
export interface ExecEnvView {
|
||||
configured: boolean
|
||||
root?: string
|
||||
python?: string
|
||||
base?: string
|
||||
layers?: string[]
|
||||
requests?: { id: string; conversationId: string; owner: string; spec: string; reason: string; status: string; layerId: string; detail: string; approver: string }[]
|
||||
bindings?: { conversationId: string; baseId: string; extensions: string[] | null; isolated: string }[]
|
||||
}
|
||||
|
||||
async function checked(res: Response): Promise<Response> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(`${res.status}: ${text}`)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
await checked(res)
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
@@ -26,7 +63,7 @@ export const api = {
|
||||
listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)),
|
||||
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
||||
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
|
||||
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }),
|
||||
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
|
||||
|
||||
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
|
||||
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
|
||||
@@ -42,29 +79,60 @@ export const api = {
|
||||
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
|
||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)),
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }>
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
|
||||
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
|
||||
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }>
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }),
|
||||
logout: () => {
|
||||
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
|
||||
auth.clear()
|
||||
return request
|
||||
},
|
||||
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
|
||||
}),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)),
|
||||
createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
|
||||
// ---- 管理员:一次性登录 Key(忘记密码恢复主入口;旧"直接重置密码"接口退出常规 UI)----
|
||||
adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
|
||||
// 建号只创建待初始化账号(无密码、无权限位);初始凭证由服务器本地 CLI 发放激活 Key。
|
||||
createAdminUser: (owner: string, name: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name } }) }).then(r => json<{ owner: string; initializationState: string }>(r)),
|
||||
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
|
||||
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
|
||||
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
|
||||
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
|
||||
// ---- 管理员:恢复提案(属主确认制,替换已 410 的直接签发 Key 入口)----
|
||||
createAdminRecoveryProposal: (userId: string, expectedStateVersion: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, idempotencyKey }) }).then(r => json<RecoveryProposal>(r)),
|
||||
listAdminRecoveryProposals: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
|
||||
adminRevokeRecoveryProposal: (userId: string, id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<RecoveryProposal>(r)),
|
||||
// ---- 本人:资料与恢复提案确认(须完整密码会话)----
|
||||
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
|
||||
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
|
||||
listMyRecoveryProposals: () => auth.fetch(`${BASE}/api/me/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
|
||||
getMyRecoveryProposal: (id: string) => auth.fetch(`${BASE}/api/me/recovery-proposals/${encodeURIComponent(id)}`).then(r => json<RecoveryProposal>(r)),
|
||||
confirmMyRecoveryProposal: (id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
|
||||
rotateMyRecoveryGrant: (id: string, expectedVersion: number, expectedGeneration: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/rotate-grant`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion, expectedGeneration }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
|
||||
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
|
||||
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
|
||||
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
|
||||
adminExecEnvGC: () => auth.fetch(`${BASE}/api/admin/exec-env/gc`, { method: 'POST' }).then(r => json<{ removed: string[] | null }>(r)),
|
||||
adminExecEnvWheel: (file: File, conversationId: string, reason: string) => {
|
||||
const body = new FormData()
|
||||
body.set('wheel', file)
|
||||
body.set('conversationId', conversationId)
|
||||
body.set('reason', reason)
|
||||
return auth.fetch(`${BASE}/api/admin/exec-env/wheels`, { method: 'POST', body }).then(r => json<{ file: string; outcome: { status: string; requestId?: string; detail?: string } }>(r))
|
||||
},
|
||||
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
|
||||
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
|
||||
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
|
||||
@@ -72,8 +140,13 @@ export const api = {
|
||||
conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r =>
|
||||
json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
|
||||
|
||||
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) =>
|
||||
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId }) })
|
||||
runEventsPage: (convId: string, runId: string, after: number, signal?: AbortSignal) =>
|
||||
auth.fetch(`${BASE}/api/conversations/${encodeURIComponent(convId)}/runs/${encodeURIComponent(runId)}/events?after=${after}&limit=500`, { signal })
|
||||
.then(r => json<{ events: StreamEvent[] }>(r)),
|
||||
|
||||
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string, taskRef?: { cardId: string; resultVersion: number }) =>
|
||||
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId,
|
||||
...(taskRef ? { taskRef: { cardId: taskRef.cardId, resultVersion: taskRef.resultVersion } } : {}) }) })
|
||||
.then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
return r.json() as Promise<{ message: Message; run?: Run; command?: string; reply?: string; conversation?: Conversation }>
|
||||
@@ -87,8 +160,8 @@ export const api = {
|
||||
|
||||
listCards: (conversationId: string) =>
|
||||
auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
|
||||
cardAction: (cardId: string, actionId: string, freeText?: string) =>
|
||||
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ freeText }) })
|
||||
cardAction: (cardId: string, actionId: string, payload?: string | WorkCardAction) =>
|
||||
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(typeof payload === 'object' ? payload : { freeText: payload }) })
|
||||
.then(r => json<{ card: ActivityCard; receipt?: Message }>(r)),
|
||||
cardPin: (cardId: string, pinned: boolean) =>
|
||||
auth.fetch(`${BASE}/api/cards/${cardId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }).then(r => json<{ card: ActivityCard }>(r)),
|
||||
@@ -125,7 +198,7 @@ export const api = {
|
||||
getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + projectId : ''}`).then(r =>
|
||||
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; user: Record<string, unknown>; project: Record<string, unknown> }>(r)),
|
||||
putUserSettings: (values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
|
||||
putProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }),
|
||||
putProjectSettings: (projectId: string, values: Record<string, unknown>) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked),
|
||||
// P2 系统管理面:受管项(models/tavily/telemetry/运行基础设施)的唯一写入面。
|
||||
getSystemSettings: () => auth.fetch(`${BASE}/api/settings/system`).then(r =>
|
||||
json<{ schema: SettingDesc[]; defaults: Record<string, unknown>; system: Record<string, unknown> }>(r)),
|
||||
@@ -154,6 +227,11 @@ export const api = {
|
||||
|
||||
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
|
||||
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
|
||||
rdmProjectCommand: async <T = any>(project: Project, id: string, params: Record<string, unknown> = {}): Promise<T> => {
|
||||
const error = rdmProjectCommandError(project, id)
|
||||
if (error) throw new Error(error)
|
||||
return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) })
|
||||
},
|
||||
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
|
||||
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
+14
-3
@@ -17,11 +17,22 @@ function pickStorage(): KV {
|
||||
}
|
||||
|
||||
const storage = pickStorage()
|
||||
const identityListeners = new Set<() => void>()
|
||||
function identityChanged() { identityListeners.forEach(listener => listener()) }
|
||||
|
||||
export const auth = {
|
||||
apiKey(): string { return storage.getItem(KEY) ?? '' },
|
||||
setKey(key: string) { storage.setItem(KEY, key.trim()) },
|
||||
clear() { storage.removeItem(KEY) },
|
||||
setKey(key: string) {
|
||||
const next = key.trim()
|
||||
if (next === this.apiKey()) return
|
||||
storage.setItem(KEY, next)
|
||||
identityChanged()
|
||||
},
|
||||
clear() { storage.removeItem(KEY); identityChanged() },
|
||||
onIdentityChange(listener: () => void) {
|
||||
identityListeners.add(listener)
|
||||
return () => { identityListeners.delete(listener) }
|
||||
},
|
||||
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
|
||||
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
|
||||
beginSession() { this.clear() },
|
||||
@@ -33,7 +44,7 @@ export const auth = {
|
||||
headers.set('Authorization', `Bearer ${key}`)
|
||||
return fetch(input, { ...init, headers })
|
||||
},
|
||||
// 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。
|
||||
// 退出登录:立即清客户端投影并回登录门;调用者先经 api.logout 注销服务端会话。
|
||||
logout() {
|
||||
this.clear()
|
||||
try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ }
|
||||
|
||||
+112
-12
@@ -3,8 +3,11 @@
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
min-height: 64px;
|
||||
padding: 12px 24px;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.work-companion-chat .chat-title { font-size: var(--fs-ui-xl); font-weight: 650; letter-spacing: -0.025em; }
|
||||
.work-companion-chat .chat-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.work-companion-chat .chat-header > .mk-col-root { position: relative; }
|
||||
.work-companion-chat .chat-header > .mk-col-root > .mk-col {
|
||||
@@ -36,9 +39,71 @@
|
||||
.companion-setting select { max-width: 100%; padding: 4px 8px; background: var(--bg-panel); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-caption); }
|
||||
.companion-setting .session-note { flex: 1 1 100%; min-width: 0; width: 100%; }
|
||||
.companion-setting-note { flex: 1 1 100%; margin: 0; color: var(--text-dim); font-size: var(--fs-ui-sm); line-height: 1.6; }
|
||||
.work-companion-chat .chat-body { padding: 24px clamp(16px, 5vw, 64px); }
|
||||
.work-companion-chat .input-bar { padding: 8px clamp(16px, 5vw, 64px) 16px; }
|
||||
.work-companion-chat .input-row { flex-direction: column; align-items: stretch; padding: 12px; box-shadow: none; }
|
||||
.work-companion-chat .chat-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 36px clamp(20px, 6vw, 96px) 48px;
|
||||
scroll-padding-block: 36px;
|
||||
}
|
||||
.work-companion-chat .chat-body > * {
|
||||
width: min(100%, 840px);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.work-companion-chat .chat-body > .msg { margin-bottom: 32px; }
|
||||
.work-companion-chat .chat-body > .msg.user {
|
||||
width: min(80%, 680px);
|
||||
margin-right: max(0px, calc((100% - 840px) / 2));
|
||||
margin-left: auto;
|
||||
}
|
||||
.work-companion-chat .chat-body > .msg.assistant {
|
||||
padding: 20px 24px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--bg-raised);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.work-companion-chat .msg-role {
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-dim);
|
||||
font-size: var(--fs-ui-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
.work-companion-chat .msg.user .msg-role { justify-content: flex-end; }
|
||||
.message-timestamp { color: var(--text-dim); font-size: var(--fs-ui-sm); font-weight: 400; white-space: nowrap; }
|
||||
.work-companion-chat .msg.assistant .message-timestamp { margin-left: auto; }
|
||||
.work-result-notice, .composer-work-reference { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; box-sizing: border-box; padding: 8px 12px; margin-bottom: 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-raised); color: var(--text-dim); font-size: var(--fs-ui-caption); text-align: left; }
|
||||
.work-result-notice { cursor: pointer; }
|
||||
.work-result-notice:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.work-result-notice > span:last-child { margin-left: auto; color: var(--accent-text); }
|
||||
.composer-work-reference > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.work-execution-record { min-width: 0; padding: 16px; color: var(--text); background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); }
|
||||
.work-record-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||
.work-record-heading strong { min-width: 0; overflow-wrap: anywhere; font-size: var(--fs-ui-base); }
|
||||
.work-record-heading .btn { flex-shrink: 0; font-size: var(--fs-ui-caption); }
|
||||
.work-execution-record .tl { margin-top: 16px; }
|
||||
.work-execution-record .part-text { overflow-wrap: anywhere; }
|
||||
.work-companion-chat .msg.assistant .role-mark {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
.work-companion-chat .msg.assistant .part-text { color: var(--text); font-size: var(--fs-ui-base); line-height: 1.8; }
|
||||
.work-companion-chat .input-bar {
|
||||
padding: 12px clamp(20px, 6vw, 96px) 20px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.work-companion-chat .input-bar > * { width: min(100%, 840px); margin-left: auto; margin-right: auto; }
|
||||
.work-companion-chat .input-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 14px;
|
||||
border-color: var(--border-strong);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.work-companion-chat .input-row textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 4px; line-height: 1.6; font-size: var(--fs-ui-base); }
|
||||
.companion-compose-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
|
||||
.companion-compose-spacer { flex: 1; }
|
||||
@@ -47,7 +112,7 @@
|
||||
.companion-add-material:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.companion-file-input { position: absolute; width: 1px; height: 1px; padding: 0; margin: 0; overflow: hidden; clip-path: inset(50%); }
|
||||
.companion-input-hint { font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.companion-operation-trigger { margin-bottom: 8px; }
|
||||
.companion-operation-trigger { margin-bottom: 8px; color: var(--text-dim); }
|
||||
.companion-operation-body { padding: 12px; margin-bottom: 12px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); }
|
||||
.companion-operation-body .companion-setting { margin-bottom: 8px; }
|
||||
.work-companion-chat .input-status { flex-wrap: wrap; gap: 4px 8px; padding: 8px 0; line-height: 1.5; }
|
||||
@@ -57,28 +122,63 @@
|
||||
.companion-remove-material { display: inline-flex; align-items: center; justify-content: center; padding: 4px; color: var(--text-dim); background: transparent; border: 0; border-radius: var(--radius-xs); cursor: pointer; }
|
||||
.companion-remove-material:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.work-companion-chat .attach-chip { max-width: 100%; overflow-wrap: anywhere; }
|
||||
.companion-empty { min-height: 240px; height: 100%; text-align: center; line-height: 1.7; }
|
||||
.companion-empty-title { color: var(--text); font-size: var(--fs-ui-xl); }
|
||||
.companion-empty { min-height: 320px; height: 100%; text-align: center; line-height: 1.7; }
|
||||
.companion-empty .es-mark { width: 52px; height: 52px; margin-bottom: 8px; background: var(--bg-panel); box-shadow: var(--shadow-md); }
|
||||
.companion-empty-title { color: var(--text); font-size: var(--fs-ui-xl); font-weight: 650; letter-spacing: -0.02em; }
|
||||
.companion-examples { display: grid; gap: 8px; width: min(100%, 32rem); margin-top: 12px; }
|
||||
.companion-example { padding: 12px 16px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); color: var(--text-dim); font: inherit; text-align: left; cursor: pointer; }
|
||||
.companion-example:hover { color: var(--text); background: var(--bg-hover); }
|
||||
.companion-example { padding: 14px 16px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); color: var(--text-dim); font: inherit; text-align: left; cursor: pointer; }
|
||||
.companion-example:hover { color: var(--text); background: var(--bg-raised); border-color: var(--border-strong); }
|
||||
.conversation-work { margin: 24px 0 16px; min-width: 0; }
|
||||
.conversation-work-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 12px; color: var(--text-dim); font-size: var(--fs-ui-caption); }
|
||||
.conversation-work-heading > span:first-child { color: var(--text); font-weight: 600; }
|
||||
.conversation-work-heading > span:first-child { color: var(--text); font-size: var(--fs-ui-lg); font-weight: 650; }
|
||||
.conversation-work-current, .conversation-work-history { display: grid; gap: 12px; min-width: 0; }
|
||||
.conversation-work-more { padding: 12px 0; color: var(--text-dim); font-size: var(--fs-ui-caption); }
|
||||
.conversation-work .acard { margin: 0; }
|
||||
.conversation-action-anchor { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; width: 100%; margin-top: 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel); color: var(--text); text-align: left; font-size: var(--fs-ui-caption); cursor: pointer; }
|
||||
.conversation-action-anchor > span:last-child { color: var(--accent-text); }
|
||||
.conversation-action-anchor:hover { background: var(--bg-hover); }
|
||||
.work-companion-chat .live-bar { flex-wrap: wrap; }
|
||||
.work-companion-chat .part-error-detail, .work-companion-chat .hitl-detail { overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
@media (max-width: 540px) {
|
||||
.work-companion-chat .chat-header { padding: 12px 16px; gap: 8px; }
|
||||
.work-companion-chat .chat-body { padding: 24px 16px 32px; }
|
||||
.work-companion-chat .chat-body > .msg.user { width: 92%; margin-right: 0; }
|
||||
.work-companion-chat .input-bar { padding: 10px 12px 14px; }
|
||||
.companion-input-hint { display: none; }
|
||||
.companion-empty { min-height: 200px; }
|
||||
.work-companion-chat .hitl-choices { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
.work-companion-chat .msg.user .bubble { background: var(--bg-active); }
|
||||
.work-companion-chat .msg.user .bubble {
|
||||
padding: 14px 18px;
|
||||
background: var(--primary);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) var(--radius-xs) var(--radius-lg);
|
||||
color: var(--primary-foreground);
|
||||
line-height: 1.7;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.work-companion-chat .process-collapse {
|
||||
margin: 12px 0 16px;
|
||||
border-color: var(--border);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.work-companion-chat .process-collapse .process-collapse-summary { padding: 9px 14px; color: var(--text-dim); }
|
||||
.work-companion-chat .process-collapse .process-collapse-summary:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.work-companion-chat .process-collapse .body { padding: 8px 14px 14px; border-top: 1px solid var(--border-soft); }
|
||||
.companion-message-copy { padding: 4px 8px; border: 0; background: transparent; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); cursor: pointer; }
|
||||
.companion-message-copy:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.companion-message-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 0; }
|
||||
.companion-message-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
opacity: 0;
|
||||
}
|
||||
.work-companion-chat .msg:hover .companion-message-actions,
|
||||
.work-companion-chat .msg:focus-within .companion-message-actions { opacity: 1; }
|
||||
.work-companion-chat .msg-tools { gap: 4px; }
|
||||
|
||||
@media (hover: none) {
|
||||
.companion-message-actions { opacity: 1; }
|
||||
}
|
||||
|
||||
+117
-22
@@ -9,6 +9,20 @@
|
||||
.companion-app .left-nav,
|
||||
.companion-app .main,
|
||||
.companion-app .right-panel { min-height: 0; max-width: 100%; overflow: hidden; }
|
||||
.companion-app .left-nav,
|
||||
.companion-app .companion-topbar {
|
||||
--text: var(--workbench-chrome-text);
|
||||
--text-dim: var(--workbench-chrome-dim);
|
||||
--text-faint: var(--workbench-chrome-faint);
|
||||
--bg-hover: var(--workbench-chrome-hover);
|
||||
--bg-active: var(--workbench-chrome-active);
|
||||
--border: var(--workbench-chrome-active);
|
||||
--border-soft: var(--workbench-chrome-hover);
|
||||
--border-strong: var(--workbench-chrome-dim);
|
||||
background: var(--workbench-chrome);
|
||||
color: var(--workbench-chrome-text);
|
||||
border-color: var(--workbench-chrome-hover);
|
||||
}
|
||||
.companion-app .left-nav { transition: none; }
|
||||
.companion-app .left-nav[hidden],
|
||||
.companion-app .right-panel[hidden],
|
||||
@@ -16,12 +30,58 @@
|
||||
.companion-app .icon-only[hidden] { display: none !important; }
|
||||
|
||||
.shell-context-content { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; }
|
||||
.companion-app .companion-topbar { height: auto; min-height: 44px; gap: 8px; padding: 4px 12px; }
|
||||
.companion-app .companion-topbar {
|
||||
height: auto;
|
||||
min-height: 52px;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.topbar-identity { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.companion-topbar .topbar-name { font-size: var(--fs-ui-caption); white-space: nowrap; }
|
||||
.companion-topbar .topbar-name { font-size: var(--fs-ui-lg); font-weight: 650; letter-spacing: -0.02em; white-space: nowrap; }
|
||||
.companion-app .right-panel .tabs {
|
||||
min-height: 52px;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
padding: 0 20px;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.companion-app .right-panel .tab {
|
||||
min-height: 52px;
|
||||
padding: 0 2px;
|
||||
color: var(--text-dim);
|
||||
font-size: var(--fs-ui-base);
|
||||
font-weight: 600;
|
||||
}
|
||||
.companion-app .right-panel .tab:hover { color: var(--text); }
|
||||
.companion-app .right-panel .tab-body { padding: 20px; }
|
||||
.companion-app .right-panel .inbox-zone-label { margin: 20px 0 8px; color: var(--text); font-size: var(--fs-ui-caption); font-weight: 650; }
|
||||
.companion-app .right-panel .acard { margin: 12px 0; border-color: var(--border); padding: 18px; }
|
||||
.companion-app .right-panel .acard.status-active {
|
||||
border-left: 3px solid var(--work-focus-border);
|
||||
background: var(--work-focus-surface);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.companion-app .right-panel .acard-title { color: var(--text); font-size: var(--fs-ui-lg); line-height: 1.4; }
|
||||
.companion-app .right-panel .acard-status {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
margin-top: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-active);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.companion-app .right-panel .acard-body { margin-top: 16px; }
|
||||
.companion-app .right-panel .acard-summary-row { grid-template-columns: minmax(64px, auto) minmax(0, 1fr); gap: 12px; margin: 12px 0; }
|
||||
.companion-app .right-panel .acard-summary-row > span,
|
||||
.companion-app .right-panel .acard-summary-label { color: var(--text-dim); font-weight: 600; }
|
||||
.companion-app .right-panel .acard-summary-row p,
|
||||
.companion-app .right-panel .acard-findings li { color: var(--text); font-size: var(--fs-ui-base); }
|
||||
.companion-topbar .brand-mark {
|
||||
background: var(--primary-sidebar);
|
||||
color: var(--primary-sidebar-foreground);
|
||||
background: var(--workbench-chrome-text);
|
||||
color: var(--workbench-chrome);
|
||||
box-shadow: none;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
}
|
||||
@@ -37,39 +97,52 @@
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-sidebar-dim);
|
||||
color: var(--workbench-chrome-dim);
|
||||
font-size: var(--fs-ui-caption);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.shell-nav-button:hover,
|
||||
.shell-return:hover { background: var(--bg-hover); color: var(--text-sidebar); }
|
||||
.shell-nav-button[aria-pressed='true'] { background: var(--bg-active); color: var(--text-sidebar); }
|
||||
.shell-return:hover { background: var(--workbench-chrome-hover); color: var(--workbench-chrome-text); }
|
||||
.shell-nav-button[aria-pressed='true'] { background: var(--workbench-chrome-active); color: var(--workbench-chrome-text); }
|
||||
.shell-nav-button svg,
|
||||
.shell-return svg { flex: none; }
|
||||
.shell-back-icon { transform: rotate(180deg); }
|
||||
.shell-pending {
|
||||
padding: 0 4px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--bg-active);
|
||||
color: var(--text-sidebar);
|
||||
background: var(--workbench-chrome-active);
|
||||
color: var(--workbench-chrome-text);
|
||||
font-size: var(--fs-ui-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.companion-app .tsb-brand { gap: 4px; padding: 16px 12px 8px; }
|
||||
.companion-app .tsb-projrow { min-height: 36px; height: auto; border-color: transparent; background: none; padding: 4px 8px; }
|
||||
.companion-app .tsb-brand { gap: 8px; padding: 16px 14px 12px; border-bottom: 1px solid var(--border-soft); }
|
||||
.companion-app .tsb-projrow { min-height: 40px; height: auto; border-color: transparent; background: none; padding: 6px 10px; }
|
||||
.companion-app .tsb-projrow:hover { background: var(--bg-hover); }
|
||||
.companion-app .tsb-projrow-name { flex: 1; }
|
||||
.companion-app .tsb-projrow-name { flex: 1; font-size: var(--fs-ui-base); font-weight: 650; }
|
||||
.companion-app .tsb-projrow-stage { max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); border-radius: var(--radius-sm); }
|
||||
.companion-app .tsb-iconbtn { width: 32px; height: 32px; flex: none; }
|
||||
.companion-app .tsb-actions { padding: 8px 12px 12px; gap: 12px; }
|
||||
.companion-app .tsb-newtask { min-height: 40px; height: auto; box-shadow: none; transition: none; }
|
||||
.companion-app .tsb-actions { padding: 12px 14px; gap: 10px; }
|
||||
.companion-app .tsb-newtask {
|
||||
min-height: 40px;
|
||||
height: auto;
|
||||
background: var(--workbench-chrome-text);
|
||||
color: var(--workbench-chrome);
|
||||
box-shadow: none;
|
||||
transition: none;
|
||||
}
|
||||
.companion-app .tsb-newtask:hover { background: var(--workbench-chrome-text); }
|
||||
.companion-app .tsb-newtask-main { justify-content: center; padding: 8px 12px; }
|
||||
.companion-app .tsb-newtask-caret { width: 36px; flex: none; }
|
||||
.companion-app .tsb-search { min-height: 36px; height: auto; padding: 8px 12px; }
|
||||
.companion-app .tsb-orgrow { padding-bottom: 4px; }
|
||||
.companion-app .tsb-task { min-height: 40px; height: auto; gap: 4px; transition: none; }
|
||||
.companion-app .tsb-newtask-caret { width: 36px; flex: none; border-left-color: var(--workbench-chrome-inverse-border); }
|
||||
.companion-app .tsb-newtask-caret:hover { background: var(--workbench-chrome-inverse-hover); }
|
||||
.companion-app .tsb-search { min-height: 38px; height: auto; padding: 8px 12px; background: var(--workbench-chrome-hover); border-color: var(--workbench-chrome-hover); }
|
||||
.companion-app .tsb-search:focus-within { background: var(--workbench-chrome-active); border-color: var(--workbench-chrome-dim); }
|
||||
.companion-app .tsb-orgrow { padding: 4px 16px 8px; }
|
||||
.companion-app .tsb-seclabel { color: var(--text-dim); font-size: var(--fs-ui-sm); font-weight: 600; }
|
||||
.companion-app .tsb-scroll { padding: 0 8px 12px; }
|
||||
.companion-app .tsb-task { min-height: 42px; height: auto; gap: 6px; padding: 0 10px; transition: none; }
|
||||
.companion-app .tsb-task.active { background: var(--bg-active); color: var(--text); }
|
||||
.companion-app .tsb-task-open {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -85,6 +158,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.companion-app .tsb-task-open > svg { flex: none; }
|
||||
.companion-app .tsb-task-t { font-size: var(--fs-ui-base); }
|
||||
.companion-app .tsb-task-tm { color: var(--text-dim); }
|
||||
.companion-app .tsb-task:focus-within .tsb-row-acts { display: inline-flex; }
|
||||
.companion-app .tsb-task:focus-within .tsb-task-tm { display: none; }
|
||||
@@ -103,11 +177,32 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.companion-app .tsb-row-menu:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.companion-app .tsb-bottom { padding: 8px 12px; gap: 4px; }
|
||||
.companion-app .tsb-tool { min-height: 36px; }
|
||||
.companion-app .tsb-bottom { padding: 10px 12px; gap: 4px; background: var(--workbench-chrome-hover); }
|
||||
.companion-app .tsb-tool { min-height: 38px; padding: 7px 10px; }
|
||||
.companion-app .tsb-avatar { background: var(--bg-active); color: var(--text); box-shadow: none; border-radius: var(--radius-full); }
|
||||
.companion-app .tsb-user-sub { color: var(--text-dim); }
|
||||
.companion-app .tsb-user { min-height: 44px; height: auto; }
|
||||
.companion-app .tsb-footer { padding: 10px 12px; }
|
||||
.companion-app .tsb-user { min-height: 46px; height: auto; }
|
||||
.companion-app .right-panel .inbox-scope {
|
||||
gap: 4px;
|
||||
margin: 14px 20px 0;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
}
|
||||
.companion-app .right-panel .inbox-scope button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 5px 10px;
|
||||
opacity: 1;
|
||||
color: var(--text-dim);
|
||||
font-size: var(--fs-ui-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.companion-app .right-panel .inbox-scope button.active { background: var(--bg-raised); box-shadow: var(--shadow-md); color: var(--text); }
|
||||
.companion-app .tsb-menu {
|
||||
min-width: min(216px, calc(100vw - 16px));
|
||||
max-width: min(360px, calc(100vw - 16px));
|
||||
@@ -116,7 +211,7 @@
|
||||
}
|
||||
.companion-app .tsb-mi { overflow-wrap: anywhere; }
|
||||
|
||||
.companion-app.compact .companion-topbar { flex-wrap: wrap; padding: 8px; }
|
||||
.companion-app.compact .companion-topbar { flex-wrap: wrap; padding: 8px 12px; }
|
||||
.companion-app.compact .shell-navigation { width: 100%; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.companion-app.compact .shell-nav-button { min-width: 0; min-height: 40px; flex-wrap: wrap; gap: 0 4px; padding: 4px; }
|
||||
.companion-app.compact .shell-nav-button > svg { display: none; }
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { EmptyState, Icon, type IconName } from '@ui'
|
||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
|
||||
import { ExecEnvPanel } from './ExecEnvPanel'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
import { RdmRolesPanel } from './adminRdmSections'
|
||||
import type { AdminZoneSection } from '../api'
|
||||
@@ -34,6 +35,7 @@ const SECTIONS: AdminSectionDef[] = [
|
||||
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
|
||||
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => <SystemSettingsPanel /> },
|
||||
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
|
||||
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', render: () => <ExecEnvPanel /> },
|
||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// 文档驾驶舱 I3:阅读器(连续正文 + 版本摘要/差异折叠)与右侧依据/影响面板。
|
||||
// 消费 I1/I3 命令面 rdm.cockpit.node(+versions)/node.history(Dev 仓 docs/superpowers
|
||||
// specs 2026-10-16 view-spec §5–§6)。不新增第四栏:右栏是选中节点的同一依据面,
|
||||
// 全景与阅读器共享 selectedNodeRef;面板刷新原位进行,不抢占滚动位置(DV-R05/§5)。
|
||||
// 折叠只用 @ui Collapsible;颜色/字号只消费 @ui/tokens.css。
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { Collapsible, EmptyState } from '@ui'
|
||||
import {
|
||||
assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary,
|
||||
PANEL_TABS, type CockpitContentBlock, type CockpitHistorySummary,
|
||||
type CockpitMeta, type CockpitNodeRef, type CockpitTreeNode,
|
||||
type CockpitVersionSummary, type Density, type PanelTabId,
|
||||
} from '../documentCockpit'
|
||||
|
||||
export interface ReaderSelection { ref: CockpitNodeRef; parentDocId?: string }
|
||||
|
||||
interface NodeDetailShape {
|
||||
meta: CockpitMeta
|
||||
node: CockpitTreeNode
|
||||
body?: CockpitContentBlock[]
|
||||
children: CockpitTreeNode[]
|
||||
sources: { items: any[] }
|
||||
history: { items: CockpitHistorySummary[]; hasMore: boolean; nextCursor?: string }
|
||||
impacts: { items: any[] }
|
||||
conflicts: { items: any[] }
|
||||
versions?: CockpitVersionSummary[]
|
||||
}
|
||||
|
||||
function useNodeDetail(pid: string, sel?: CockpitNodeRef) {
|
||||
const [detail, setDetail] = useState<NodeDetailShape | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const gen = useRef(0)
|
||||
useEffect(() => {
|
||||
const g = ++gen.current
|
||||
setDetail(null); setErr('')
|
||||
if (!sel) return
|
||||
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: sel.kind, id: sel.id })
|
||||
.then(d => { if (g === gen.current) setDetail(d) })
|
||||
.catch(e => { if (g === gen.current) setErr(e.message) })
|
||||
return () => { gen.current++ }
|
||||
}, [pid, sel?.kind, sel?.id])
|
||||
return { detail, err }
|
||||
}
|
||||
|
||||
/** 版本块:当前版本 + 前一版本摘要;完整差异(行级标记)折叠。受限只给元数据。 */
|
||||
export function VersionBlock({ versions, density }: { versions?: CockpitVersionSummary[]; density: Density }) {
|
||||
if (!versions || versions.length === 0) return null
|
||||
const { current, previous, redacted } = versionSummary(versions)
|
||||
if (redacted || !current || current.content === undefined) {
|
||||
return <div className="empty-note">版本受限(仅元数据可见)</div>
|
||||
}
|
||||
const prev = previous && previous.content !== undefined ? previous : undefined
|
||||
return (
|
||||
<Collapsible
|
||||
id={`cockpit-versions-${current.version}`}
|
||||
trigger={<span className="cockpit-versions-toggle">版本 {current.version}{previous ? `(前一版 ${previous.version})` : '(首版)'}与完整差异</span>}
|
||||
>
|
||||
<div className={`cockpit-versions cockpit-density-${density}`}>
|
||||
{prev ? (
|
||||
<>
|
||||
<div className="cockpit-versions-head">当前 {current.version} · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
|
||||
<pre className="cockpit-diff">
|
||||
{diffLines(current.content || '', prev.content || '').map((l, i) => (
|
||||
<div key={i} className={`cockpit-diff-${l.flag === '=' ? 'same' : l.flag === '+' ? 'add' : 'del'}`}>{l.flag} {l.text}</div>
|
||||
))}
|
||||
</pre>
|
||||
<div className="cockpit-versions-head">前一版 {prev.version} · {prev.actor || '未知'} · {new Date(prev.at).toLocaleString('zh-CN')}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="cockpit-versions-head">首版 · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
/** 阅读器中栏:选中章节=该节连续正文+版本块;选中文档=全章节连续装配(每节详情按需拉取)。 */
|
||||
export function CockpitReader({ pid, selection, sections, density, onBack }: {
|
||||
pid: string
|
||||
selection: ReaderSelection
|
||||
sections: CockpitTreeNode[]
|
||||
density: Density
|
||||
onBack: () => void
|
||||
}) {
|
||||
const { detail, err } = useNodeDetail(pid, selection.ref)
|
||||
const isSection = selection.ref.kind === 'section'
|
||||
const [sectionDetails, setSectionDetails] = useState<Record<string, NodeDetailShape | undefined>>({})
|
||||
const gen = useRef(0)
|
||||
// 文档阅读器:按树顺序装配全部章节详情(上限 20;失败章节给受限占位不拖垮整篇)。
|
||||
useEffect(() => {
|
||||
if (isSection || selection.ref.kind !== 'document') return
|
||||
const g = ++gen.current
|
||||
const list = sections.slice(0, 20)
|
||||
setSectionDetails({})
|
||||
for (const sec of list) {
|
||||
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: 'section', id: sec.ref.id })
|
||||
.then(d => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: d })) })
|
||||
.catch(() => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: undefined })) })
|
||||
}
|
||||
return () => { gen.current++ }
|
||||
}, [pid, isSection, selection.ref.id, sections])
|
||||
const anchorRef = useRef<HTMLDivElement | null>(null)
|
||||
useEffect(() => { anchorRef.current?.scrollIntoView({ block: 'start' }) }, [selection.ref.id])
|
||||
if (err) return <EmptyState icon="alert">阅读器加载失败:{err}</EmptyState>
|
||||
return (
|
||||
<div className={`cockpit-reader cockpit-density-${density}`}>
|
||||
<div className="cockpit-nodehead" ref={anchorRef}>
|
||||
<button type="button" className="cockpit-back" onClick={onBack}>← 返回全景</button>
|
||||
<span className="cockpit-nodetitle">{detail?.node?.title || (isSection ? '章节' : '文档')}</span>
|
||||
</div>
|
||||
{isSection && detail && (
|
||||
<>
|
||||
{renderBody(detail.body, detail.meta)}
|
||||
<VersionBlock versions={detail.versions} density={density} />
|
||||
</>
|
||||
)}
|
||||
{!isSection && (
|
||||
<>
|
||||
{sections.length === 0 && <div className="empty-note">该文档暂无章节</div>}
|
||||
{assembleReader(sections, sectionDetails).map(sec => (
|
||||
<section key={sec.ref.id} id={`sec-${sec.ref.id}`} className="cockpit-reader-section">
|
||||
<div className="cockpit-reader-sechead">
|
||||
<span className="cockpit-reader-sectitle">{sec.title}</span>
|
||||
{sec.blocks[0]?.kind === 'restricted'
|
||||
? <span className="cockpit-badge">内容受限({sec.blocks[0].reason || '权限'})</span>
|
||||
: sec.contentState === 'empty' && <span className="cockpit-badge">空</span>}
|
||||
</div>
|
||||
{sec.blocks.map((b, i) => b.kind === 'text'
|
||||
? <p key={i} className="cockpit-reader-para">{b.text}</p>
|
||||
: <p key={i} className="empty-note">内容受限——标题与状态可见,正文不展示</p>)}
|
||||
<VersionBlock versions={sec.versions} density={density} />
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta) {
|
||||
if (!body || body.length === 0) return <div className="empty-note">暂无正文</div>
|
||||
if (body[0].kind === 'restricted') {
|
||||
return <div className="empty-note">内容受限({body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{body.map((b, i) => <p key={i} className="cockpit-reader-para">{b.text}</p>)}
|
||||
{meta.truncated && <div className="cockpit-truncated">详情已截断:{meta.truncationReasons?.join(';')}</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 右栏:依据/历史/影响/冲突四标签(同一选中节点;刷新原位,不抢占滚动)。 */
|
||||
export function CockpitPanel({ pid, selection, tab, onTab, density }: {
|
||||
pid: string
|
||||
selection: ReaderSelection
|
||||
tab: PanelTabId
|
||||
onTab: (t: PanelTabId) => void
|
||||
density: Density
|
||||
}) {
|
||||
const { detail, err } = useNodeDetail(pid, selection.ref)
|
||||
const [historyItems, setHistoryItems] = useState<CockpitHistorySummary[]>([])
|
||||
const [historyMore, setHistoryMore] = useState(false)
|
||||
const [historyCursor, setHistoryCursor] = useState<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
setHistoryItems([]); setHistoryMore(false); setHistoryCursor(undefined)
|
||||
}, [selection.ref.kind, selection.ref.id])
|
||||
useEffect(() => {
|
||||
if (!detail?.history) return
|
||||
// 严格合并:只接受 seq 更新的条目,乱序/重复丢弃;不重置已渲染内容。
|
||||
setHistoryItems(prev => mergeHistoryStrict(prev, detail.history.items).items)
|
||||
setHistoryMore(detail.history.hasMore)
|
||||
setHistoryCursor(detail.history.nextCursor)
|
||||
}, [detail])
|
||||
const loadMoreHistory = useCallback(() => {
|
||||
if (!historyCursor) return
|
||||
api.rdmCommand<any>('rdm.cockpit.node.history', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id, cursor: historyCursor, limit: 20 })
|
||||
.then(page => {
|
||||
setHistoryItems(prev => mergeHistoryStrict(prev, page.items).items)
|
||||
setHistoryMore(page.hasMore)
|
||||
setHistoryCursor(page.nextCursor)
|
||||
})
|
||||
.catch(() => { /* 保留已渲染历史;可再次点击重试 */ })
|
||||
}, [pid, selection.ref.kind, selection.ref.id, historyCursor])
|
||||
const node = detail?.node
|
||||
return (
|
||||
<div className={`cockpit-panel cockpit-density-${density}`}>
|
||||
<div className="cockpit-panel-tabs" role="tablist" aria-label="依据与影响">
|
||||
{PANEL_TABS.map(t => (
|
||||
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id}
|
||||
className={`cockpit-panel-tab${tab === t.id ? ' cockpit-panel-tab-active' : ''}`}
|
||||
onClick={() => onTab(t.id)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{err && <div className="empty-note">依据面板加载失败:{err}</div>}
|
||||
{!err && !detail && <div className="empty-note">加载依据…</div>}
|
||||
{detail && tab === 'sources' && (
|
||||
<div className="cockpit-panel-body">
|
||||
<div className="cockpit-panel-count">来源 {node?.state.sourceCount ?? detail.sources.items.length} 条</div>
|
||||
{detail.sources.items.length === 0 && <div className="empty-note">无来源——章节正文未挂依据切片</div>}
|
||||
{detail.sources.items.map((s: any) => (
|
||||
<div key={s.ref.id} className="cockpit-panel-row">
|
||||
<span className="cockpit-panel-kind">{s.ref.kind}</span>
|
||||
<span className="cockpit-panel-label">{s.label}</span>
|
||||
{s.confidence && <span className="cockpit-badge">置信度 {s.confidence}</span>}
|
||||
{s.redactedReason
|
||||
? <span className="cockpit-badge">受限({s.redactedReason})</span>
|
||||
: s.verification && s.verification !== 'not_applicable' && <span className="cockpit-badge">{s.verification}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{detail && tab === 'history' && (
|
||||
<div className="cockpit-panel-body">
|
||||
<div className="cockpit-panel-count">已提交事件(最新在前)</div>
|
||||
{historyItems.length === 0 && <div className="empty-note">暂无历史</div>}
|
||||
{historyItems.map(h => <div key={h.seq} className="cockpit-panel-row">{historyLine(h)}</div>)}
|
||||
{historyMore && <button type="button" className="cockpit-tree-more" onClick={loadMoreHistory}>加载更早历史</button>}
|
||||
</div>
|
||||
)}
|
||||
{detail && tab === 'impacts' && (
|
||||
<div className="cockpit-panel-body">
|
||||
<div className="cockpit-panel-count">影响 {node?.state.impactCount ?? detail.impacts.items.length} 项(一跳)</div>
|
||||
{detail.impacts.items.length === 0 && <div className="empty-note">无一跳影响对象</div>}
|
||||
{detail.impacts.items.map((r: any, i: number) => (
|
||||
<div key={`${r.ref.kind}-${r.ref.id}-${i}`} className="cockpit-panel-row">
|
||||
<span className="cockpit-panel-kind">{r.direction === 'incoming' ? '←' : '→'}</span>
|
||||
<span className="cockpit-panel-label">{r.ref.kind}:{r.ref.id}</span>
|
||||
<span className="cockpit-badge">{r.edgeType}</span>
|
||||
<span className="cockpit-badge">{r.edgeState}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{detail && tab === 'conflicts' && (
|
||||
<div className="cockpit-panel-body">
|
||||
<div className="cockpit-panel-count">冲突 {node?.state.conflictCount ?? 0} 项</div>
|
||||
{detail.conflicts.items.length === 0 && <div className="empty-note">无开放冲突</div>}
|
||||
{detail.conflicts.items.map(c => (
|
||||
<div key={c.id} className="cockpit-panel-row cockpit-panel-conflict">
|
||||
<span className="cockpit-badge">{c.state === 'open' ? '开放' : c.state}</span>
|
||||
<div className="cockpit-conflict-side">甲方:{c.left.visibility === 'full' ? c.left.claim : `受限(${c.left.redactedReason || '权限'})`}</div>
|
||||
<div className="cockpit-conflict-side">乙方:{c.right.visibility === 'full' ? c.right.claim : `受限(${c.right.redactedReason || '权限'})`}</div>
|
||||
<div className="cockpit-changetime">{new Date(c.detectedAt).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,15 +5,16 @@ import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpe
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore, activeRun, isTerminal, type LiveState, type ContextObs } from '../store'
|
||||
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
|
||||
import { liveToMessage, livePhaseLabel, showProcessInline } from '../live'
|
||||
import { liveToMessage, livePhaseLabel, runEventsToTimeline, showProcessInline } from '../live'
|
||||
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
|
||||
import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts'
|
||||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
|
||||
import { splitTimeline, mergeProcessTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
|
||||
import { ProcessTimeline } from './ProcessTimeline'
|
||||
import { ConversationWork } from './ConversationWork'
|
||||
import { MessageTimestamp } from './MessageTimestamp'
|
||||
import { loadRunEvents } from '../runEvents'
|
||||
import '../companion-conversation.css'
|
||||
|
||||
function hasFiles(dt: DataTransfer | null): boolean {
|
||||
@@ -25,6 +26,7 @@ function hasFiles(dt: DataTransfer | null): boolean {
|
||||
|
||||
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
|
||||
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return <SafeMarkdown className={live ? 'part-text cursor-blink' : 'part-text'} breaks
|
||||
@@ -46,12 +48,12 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
case 'plan':
|
||||
return null // 计划在右栏 Plan 标签呈现
|
||||
case 'card': {
|
||||
// 卡流锚点(D39):cardId 非空 = submit_card 引用部件——中栏自足(可展开全文)+ 跳收件箱。
|
||||
// 右栏工作可见时保留定位入口;详细正文只在右栏。关闭右栏时中栏可展开全文。
|
||||
if (part.card?.cardId) {
|
||||
const ref = part.card.cardId
|
||||
const card = findCard(ref)
|
||||
const bodyChars = card?.body?.reduce((n, node) => n + (node.markdown?.length ?? 0), 0)
|
||||
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={card}
|
||||
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={workPaneOwnsCards || card?.work ? undefined : card}
|
||||
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
|
||||
}
|
||||
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
|
||||
@@ -197,6 +199,8 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
||||
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
|
||||
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
|
||||
const run = useStore(s => (msg.runId ? s.runs.find(r => r.id === msg.runId) : undefined))
|
||||
const childStamp = useStore(s => msg.runId ? s.runs.filter(r => r.parentRunId === msg.runId)
|
||||
.map(r => `${r.id}:${r.status}:${r.endedAt ?? ''}`).join('|') : '')
|
||||
const parts = msg.parts ?? []
|
||||
useLiveClock(!!live)
|
||||
const finished = !live && !!run && run.status !== 'running' && run.status !== 'pending' && run.status !== 'waiting_hitl'
|
||||
@@ -211,6 +215,26 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
||||
useEffect(() => {
|
||||
if (!touchedProcess.has(memKey)) setOpen(showProcess)
|
||||
}, [memKey, showProcess])
|
||||
const [replayed, setReplayed] = useState<Part[] | null>(null)
|
||||
const [processError, setProcessError] = useState(false)
|
||||
useEffect(() => { setReplayed(null); setProcessError(false) }, [childStamp])
|
||||
useEffect(() => {
|
||||
if (!open) { setReplayed(null); setProcessError(false) }
|
||||
}, [open])
|
||||
useEffect(() => {
|
||||
if (!open || live || !msg.runId || !convId || replayed || processError) return
|
||||
const controller = new AbortController()
|
||||
let active = true
|
||||
void (async () => {
|
||||
try {
|
||||
const events = await loadRunEvents(convId, msg.runId!, controller.signal)
|
||||
if (active) setReplayed(runEventsToTimeline(events, msg.runId))
|
||||
} catch {
|
||||
if (active) setProcessError(true)
|
||||
}
|
||||
})()
|
||||
return () => { active = false; controller.abort() }
|
||||
}, [open, live, msg.runId, convId, replayed, processError])
|
||||
const extras = live ? [] : citedArtifactCards(split.final)
|
||||
const cards = live ? [] : dedupeDeliverables(split.final)
|
||||
const lastText = (() => {
|
||||
@@ -219,10 +243,11 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
||||
})()
|
||||
const streamingText = !!(live && live.phase === 'responding' && lastText >= 0
|
||||
&& !live.items.some(it => it.kind === 'tool' && it.state !== 'done'))
|
||||
const toolCount = split.timeline.filter(p => p.type === 'tool_call').length
|
||||
const processParts = replayed === null ? split.timeline : mergeProcessTimeline(split.timeline, replayed, split.final)
|
||||
const toolCount = processParts.filter(p => p.type === 'tool_call').length
|
||||
const headline = live ? '执行记录' : `执行记录 · ${processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)}`
|
||||
const timeline = (
|
||||
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
|
||||
<ProcessTimeline parts={processParts} live={!!live} idKey={msg.id}
|
||||
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
|
||||
)
|
||||
const toggleProcess = (next: boolean) => {
|
||||
@@ -235,16 +260,21 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
||||
<div className="msg-role">
|
||||
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
|
||||
<span>助手</span>
|
||||
<MessageTimestamp value={msg.createdAt || run?.createdAt} />
|
||||
{live && <LiveBar live={live} />}
|
||||
</div>
|
||||
{split.timeline.some(part => part.type !== 'thinking') && (
|
||||
{(split.timeline.length > 0 || !!msg.runId) && (
|
||||
<div className="process-collapse">
|
||||
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
|
||||
trigger={<>
|
||||
<span className="process-collapse-chev">▸</span>
|
||||
<span className={live ? 'mk-sweep' : ''}>{headline}</span>
|
||||
</>}>
|
||||
<div className="body">{timeline}</div>
|
||||
<div className="body">
|
||||
{replayed === null && !live && !processError && <div className="process-load-note">正在读取完整记录…</div>}
|
||||
{processError && <div className="process-load-note">完整记录暂不可用,以下为已保存的摘要</div>}
|
||||
{processParts.length > 0 ? timeline : replayed && <div className="process-load-note">这轮没有可回看的过程记录</div>}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
@@ -424,18 +454,19 @@ function OutboxPanel() {
|
||||
if (outbox.length === 0) return null
|
||||
return (
|
||||
<Collapsible id="composer-outbox" defaultOpen
|
||||
trigger={<span>排队 {outbox.length}</span>}
|
||||
trigger={<span>待发送 {outbox.length}</span>}
|
||||
triggerClassName="outbox-trigger">
|
||||
<ul className="outbox-list">
|
||||
{outbox.map(item => (
|
||||
<li key={item.id} className="outbox-item">
|
||||
{item.deliveryError && <span className="outbox-text" role="status">{item.deliveryError}</span>}
|
||||
{editing === item.id ? (
|
||||
<input className="search-input outbox-edit" value={draft} onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') { store.updateOutbox(item.id, draft); setEditing(null) }
|
||||
}} />
|
||||
) : (
|
||||
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}</span>
|
||||
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}{item.taskRef ? ` · 基于「${item.taskRef.title}」` : ''}</span>
|
||||
)}
|
||||
{editing === item.id ? (
|
||||
<button className="btn ghost" onClick={() => { store.updateOutbox(item.id, draft); setEditing(null) }}>保存</button>
|
||||
@@ -443,6 +474,7 @@ function OutboxPanel() {
|
||||
<button className="btn ghost" onClick={() => { setEditing(item.id); setDraft(item.text) }}>修改</button>
|
||||
)}
|
||||
<button className="btn ghost" onClick={() => store.sendOutboxNow(item.id)}>发送</button>
|
||||
{item.taskRef && <button className="btn ghost" onClick={() => store.clearOutboxReference(item.id)}>移除引用</button>}
|
||||
<button className="icon-btn" title="移出队列" onClick={() => store.removeOutbox(item.id)}><Icon name="x" size={12} /></button>
|
||||
</li>
|
||||
))}
|
||||
@@ -457,7 +489,7 @@ export function NoticeMessage({ msg }: { msg: Message }) {
|
||||
return (
|
||||
<div className="msg notice" data-msg-id={msg.id}>
|
||||
<Collapsible id={`notice:${msg.id}`} triggerClassName="notice-summary"
|
||||
trigger={<span className="notice-label">通知 · {preview}</span>}>
|
||||
trigger={<><span className="notice-label">通知 · {preview}</span><MessageTimestamp value={msg.createdAt} /></>}>
|
||||
<div className="notice-body">{text}</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
@@ -486,6 +518,8 @@ function ConversationSession() {
|
||||
const cards = useStore(s => s.cards)
|
||||
const currentProjectId = useStore(s => s.currentProjectId)
|
||||
const cardActionStates = useStore(s => s.cardActionStates)
|
||||
const workReference = useStore(s => s.workReference)
|
||||
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
|
||||
const [text, setText] = useState('')
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
|
||||
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
|
||||
@@ -514,8 +548,9 @@ function ConversationSession() {
|
||||
})
|
||||
const busy = isShellBusy(phase)
|
||||
const showStop = phase === 'running'
|
||||
// 慢通道在跑的后台子代理:前台回合结束后仍在执行,完成后经通知回到对话汇报。
|
||||
// Background work stays in its own card and does not insert another chat turn.
|
||||
const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length
|
||||
const unreadResults = cards.filter(card => card.work?.result && card.work.resultVersion > card.work.readVersion)
|
||||
|
||||
useEffect(() => {
|
||||
if (!stickRef.current) return
|
||||
@@ -549,12 +584,18 @@ function ConversationSession() {
|
||||
const t = text
|
||||
const ids = attachmentIds
|
||||
const names = attachmentNames
|
||||
setText(''); setAttachmentIds([]); setAttachmentNames([])
|
||||
if (busy) {
|
||||
store.enqueueOutbox(t, ids, names)
|
||||
const reference = workReference ?? undefined
|
||||
if (reference && ids.length > 0) {
|
||||
store.toast('这条消息已引用工作结果,请先移除引用或附件再发送')
|
||||
return
|
||||
}
|
||||
await store.sendMessage(t, ids)
|
||||
setText(''); setAttachmentIds([]); setAttachmentNames([])
|
||||
store.clearWorkReference()
|
||||
if (busy) {
|
||||
store.enqueueOutbox(t, ids, names, reference)
|
||||
return
|
||||
}
|
||||
await store.sendMessage(t, ids, undefined, reference)
|
||||
}
|
||||
|
||||
if (!currentConvId) {
|
||||
@@ -617,13 +658,6 @@ function ConversationSession() {
|
||||
const stick = dist < 80
|
||||
stickRef.current = stick
|
||||
setShowJump(prev => (prev === !stick ? prev : !stick))
|
||||
const ratio = scrollRatio(src)
|
||||
if (ratio === null) return
|
||||
const dst = document.querySelector('[data-scroll-sync="inbox"]')
|
||||
if (!dst) return
|
||||
const top = scrollTopForRatio(dst, ratio)
|
||||
if (top === null) return
|
||||
dst.scrollTop = top
|
||||
}}>
|
||||
{foldRunErrors(messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent')).map(m => {
|
||||
if (m.role === 'user') {
|
||||
@@ -633,16 +667,20 @@ function ConversationSession() {
|
||||
<div className={`msg user${m.excluded ? ' is-excluded' : ''}`} key={m.id} data-msg-id={m.id}>
|
||||
<div className="msg-role">
|
||||
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
|
||||
<MessageTimestamp value={m.createdAt} />
|
||||
</div>
|
||||
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
|
||||
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
|
||||
{anchored.map(c => (
|
||||
<InlineConfirmCard key={c.id} card={c} callbacks={{
|
||||
onAction: (card, actionId) => store.cardAction(card, actionId),
|
||||
actionState: card => cardActionStates?.[card.id],
|
||||
onLocate: card => store.openInbox(card.id),
|
||||
}} />
|
||||
))}
|
||||
{anchored.map(c => workPaneOwnsCards
|
||||
? <button type="button" key={c.id} className="conversation-action-anchor" onClick={() => store.openInbox(c.id)}>
|
||||
<span>{c.title}</span><span>在工作栏处理 ↗</span>
|
||||
</button>
|
||||
: <InlineConfirmCard key={c.id} card={c} callbacks={{
|
||||
onAction: (card, actionId) => store.cardAction(card, actionId),
|
||||
actionState: card => cardActionStates?.[card.id],
|
||||
onLocate: card => store.openInbox(card.id),
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -674,16 +712,23 @@ function ConversationSession() {
|
||||
|
||||
<div className="input-bar">
|
||||
<OutboxPanel />
|
||||
{unreadResults.length > 0 && <button type="button" className="work-result-notice" onClick={() => store.openInbox(unreadResults[0].id)}>
|
||||
<Icon name="check" size={14} /><span>{unreadResults.length} 项工作有新结果</span><span>查看工作 ↗</span>
|
||||
</button>}
|
||||
{workReference && <div className="composer-work-reference" role="group" aria-label="本条消息引用的工作结果">
|
||||
<span>基于「{workReference.title}」的第 {workReference.resultVersion} 版结果</span>
|
||||
<button type="button" className="icon-btn" aria-label="移除工作结果引用" onClick={() => store.clearWorkReference()}><Icon name="x" size={12} /></button>
|
||||
</div>}
|
||||
{!connected && <div className="companion-reconnect" role="status">正在重新连接,最新进展待确认</div>}
|
||||
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
|
||||
后台任务在回合结束后仍在执行,完成后回到对话汇报。 */}
|
||||
后台工作完成后更新原卡,用户按需打开。 */}
|
||||
{(busy || bgRunning > 0) && (
|
||||
<div className="input-status" data-testid="input-status">
|
||||
<span className="live-dot" />
|
||||
{busy
|
||||
? <span className="mk-sweep">助手正在处理{bgRunning > 0 ? ` · ${bgRunning} 项后台工作` : ''}</span>
|
||||
: <span>{bgRunning} 项后台工作</span>}
|
||||
<span className="input-status-note">{busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,完成后会在这里汇报'}</span>
|
||||
<span className="input-status-note">{busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,结果会更新到工作卡'}</span>
|
||||
</div>
|
||||
)}
|
||||
{attachmentNames.length > 0 && (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ActivityCardView, Collapsible, activitySummary, pendingCount, type ActivityCard } from '@ui'
|
||||
import { ActivityCardView, Collapsible, activitySummary, groupActivityCards, pendingCount, type ActivityCard } from '@ui'
|
||||
import { store, useStore } from '../store'
|
||||
import { docBridge } from './Workbench'
|
||||
import type { WorkCardAction } from '../contracts'
|
||||
import { WorkExecutionRecord } from './WorkExecutionRecord'
|
||||
|
||||
/** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */
|
||||
export function ConversationWork() {
|
||||
@@ -11,8 +13,11 @@ export function ConversationWork() {
|
||||
const messages = useStore(s => s.messages)
|
||||
const actionStates = useStore(s => s.cardActionStates)
|
||||
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
|
||||
const [summaryOpen, setSummaryOpen] = useState(rightCollapsed)
|
||||
useEffect(() => setSummaryOpen(rightCollapsed), [rightCollapsed])
|
||||
const rightTab = useStore(s => s.ui.rightTab)
|
||||
const record = useStore(s => s.workRecord)
|
||||
const detailHere = rightCollapsed || rightTab !== 'work'
|
||||
const [summaryOpen, setSummaryOpen] = useState(detailHere)
|
||||
useEffect(() => setSummaryOpen(detailHere), [detailHere])
|
||||
const { current, required, focused, history } = useMemo(() => {
|
||||
const current = cards.filter(card => card.status !== 'dismissed'
|
||||
&& card.conversationId === conversationId && card.projectId === projectId)
|
||||
@@ -24,22 +29,27 @@ export function ConversationWork() {
|
||||
if (card.kind === 'confirm' && card.anchorMessageId
|
||||
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
|
||||
const summary = activitySummary(card)
|
||||
if (card.status === 'action_required' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
|
||||
if (card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
|
||||
else remaining.push(card)
|
||||
}
|
||||
// Preserve the host's newest-first order for completed results and history.
|
||||
const results = groupActivityCards(remaining).find(group => group.key === 'results')?.cards ?? []
|
||||
const focused = remaining.find(card => card.pinned && card.status === 'active')
|
||||
?? remaining.find(card => card.status === 'active')
|
||||
?? remaining.find(card => card.status === 'done') ?? remaining[0]
|
||||
return { current, required, focused, history: remaining.filter(card => card !== focused) }
|
||||
?? results[0]
|
||||
return { current, required, focused, history: remaining.filter(card => card !== focused).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) }
|
||||
}, [cards, messages, conversationId, projectId])
|
||||
if (!detailHere) return null
|
||||
if (record) return <div className="conversation-work" data-conversation-work><WorkExecutionRecord /></div>
|
||||
if (current.length === 0) return null
|
||||
const pending = pendingCount(current)
|
||||
const active = current.filter(card => card.status === 'active').length
|
||||
const results = current.filter(card => card.status === 'done' || card.status === 'failed').length
|
||||
const summary = [pending ? `${pending} 项待处理` : '', active ? `${active} 项工作` : '', results ? `${results} 项结果` : ''].filter(Boolean).join(' · ')
|
||||
const callbacks = {
|
||||
onAction: (card: ActivityCard, actionId: string) => { void store.cardAction(card, actionId) },
|
||||
onAction: (card: ActivityCard, actionId: string, payload?: WorkCardAction) => { void store.cardAction(card, actionId, payload) },
|
||||
onOpenRecord: (card: ActivityCard) => store.openWorkRecord(card),
|
||||
onReference: (card: ActivityCard) => store.referenceWork(card),
|
||||
actionState: (card: ActivityCard) => actionStates?.[card.id],
|
||||
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
|
||||
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
// 文档驾驶舱 I2(rollout I2):顶部统计带 + 左侧结构树 + 中央结构全景。
|
||||
// 消费 I1 只读命令面 rdm.cockpit.*(Dev 仓 docs/superpowers/specs 2026-10-16
|
||||
// view-spec §1–§4;四项取舍冻结见 rollout §7——首屏只显示章节概览与统计,
|
||||
// 段落/正文摘要在选中与阅读器内按权限展示,本增量不含阅读器(I3))。
|
||||
// 异步所有者:generation + contextKey(项目+筛选);不匹配的旧响应直接丢弃。
|
||||
// 令牌驱动(DESIGN.md):颜色/字号只消费 @ui/tokens.css;折叠用 Collapsible。
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import { EmptyState, SectionLabel } from '@ui'
|
||||
import { classifyRdmError } from '../rdmErrors'
|
||||
import {
|
||||
contextKey, loadPrefs, nodeSummaryLine, sameSnapshot, savePrefs,
|
||||
selectionSurvives, statDisplay, STAT_FILTERS, toggleFilter, treeBadges,
|
||||
type CockpitMeta, type CockpitNodeRef, type CockpitOverview,
|
||||
type CockpitTreeNode, type CockpitTreePage,
|
||||
type Density, type PanelTabId, type ViewMode,
|
||||
} from '../documentCockpit'
|
||||
import { CockpitPanel, CockpitReader } from './CockpitReader'
|
||||
|
||||
interface ProjectView { id: string; name: string; canAudit?: boolean }
|
||||
|
||||
function CockpitErrorState({ raw }: { raw: string }) {
|
||||
const c = classifyRdmError(raw)
|
||||
return <EmptyState icon="alert">{c.message}</EmptyState>
|
||||
}
|
||||
|
||||
interface Selection { ref: CockpitNodeRef; parentDocId?: string }
|
||||
|
||||
export function DocumentCockpit() {
|
||||
const identity = useStore(() => store.getIdentityEpoch())
|
||||
const instanceId = useRef(Math.random().toString(36).slice(2, 8))
|
||||
const [projects, setProjects] = useState<ProjectView[] | null>(null)
|
||||
const [pid, setPid] = useState('')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [overview, setOverview] = useState<CockpitOverview | null>(null)
|
||||
const [treePages, setTreePages] = useState<Record<string, { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }>>({})
|
||||
const [selected, setSelected] = useState<Selection | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('panorama')
|
||||
const [density, setDensity] = useState<Density>('compact')
|
||||
const [panelTab, setPanelTab] = useState<PanelTabId>('sources')
|
||||
const gen = useRef(0)
|
||||
|
||||
const reload = useCallback((projectId: string, f: string) => {
|
||||
if (!projectId) return
|
||||
const g = ++gen.current
|
||||
const ctx = contextKey(projectId, f)
|
||||
setLoadErr('')
|
||||
api.rdmCommand<CockpitOverview>('rdm.cockpit.overview', { projectId })
|
||||
.then(ov => {
|
||||
if (g !== gen.current || ctx !== contextKey(pid, filter)) return // 旧响应:项目/筛选已切换,丢弃
|
||||
setOverview(ov)
|
||||
setTreePages({}) // 树页随新快照整体重建
|
||||
setSelected(sel => {
|
||||
const kept = selectionSurvives(sel?.ref || null, (ov.documents || []).map(d => d.ref.id), sel?.parentDocId)
|
||||
return kept ? { ref: kept, parentDocId: sel?.parentDocId } : null
|
||||
})
|
||||
})
|
||||
.catch(e => { if (g === gen.current && ctx === contextKey(pid, filter)) setLoadErr(e.message) })
|
||||
}, [pid, filter])
|
||||
|
||||
// 项目清单(rdm.project.list:成员可见项目,canAudit 与否不影响文档读取入口)。
|
||||
useEffect(() => {
|
||||
let c = false
|
||||
console.log('[cockpit-dbg] EFFECT start inst=' + instanceId.current, 'identity=', identity)
|
||||
setProjects(null); setPid(''); setOverview(null); setSelected(null); setFilter(''); setError('')
|
||||
api.rdmCommand<{ projects?: ProjectView[] } | ProjectView[]>('rdm.project.list')
|
||||
.then(list => {
|
||||
const rows = Array.isArray(list) ? list : ((list as any)?.projects ?? [])
|
||||
console.log('[cockpit-dbg] FETCH resolved inst=' + instanceId.current, 'cancelled=', c, 'rows=', rows.length)
|
||||
if (!c) return
|
||||
// rdm.project.list 返回 { projects: [...] };容忍裸数组形态。
|
||||
setProjects(rows)
|
||||
console.log('[cockpit-dbg] setProjects done inst=' + instanceId.current, 'rows=', rows.length)
|
||||
})
|
||||
.catch(e => { if (!c) setError(e.message) })
|
||||
return () => { c = true }
|
||||
}, [identity])
|
||||
|
||||
// 项目/筛选变化 → 重新取概览(统计与根文档来自同一快照)。
|
||||
useEffect(() => {
|
||||
if (!pid) return
|
||||
reload(pid, filter)
|
||||
}, [pid, filter, reload])
|
||||
|
||||
// 个人视图偏好(设计 §7.3):按 登录世代+项目 隔离,仅本地;恢复最近视图。
|
||||
useEffect(() => {
|
||||
if (!pid) return
|
||||
const prefs = loadPrefs(`epoch-${identity}`, pid)
|
||||
setViewMode(prefs.lastView)
|
||||
setDensity(prefs.density)
|
||||
setPanelTab(prefs.rightPanelTab)
|
||||
}, [identity, pid])
|
||||
const applyPrefs = useCallback((patch: { lastView?: ViewMode; density?: Density; rightPanelTab?: PanelTabId }) => {
|
||||
if (!pid) return
|
||||
setViewMode(v => patch.lastView ?? v)
|
||||
setDensity(d => patch.density ?? d)
|
||||
setPanelTab(t => patch.rightPanelTab ?? t)
|
||||
const prefs = loadPrefs(`epoch-${identity}`, pid)
|
||||
savePrefs(`epoch-${identity}`, pid, { ...prefs, ...patch })
|
||||
}, [identity, pid])
|
||||
|
||||
// 展开文档:按需加载章节子页(键集分页;旧项目/旧筛选的响应丢弃;
|
||||
// 加载失败只影响该分支——行内保留重试,不拖垮其它面板)。
|
||||
const loadChildren = useCallback((docId: string, cursor?: string) => {
|
||||
if (!pid) return
|
||||
const g = gen.current
|
||||
const ctx = contextKey(pid, filter)
|
||||
api.rdmCommand<CockpitTreePage>('rdm.cockpit.tree', { projectId: pid, parent: docId, cursor, limit: 50, filter })
|
||||
.then(page => {
|
||||
if (g !== gen.current || ctx !== contextKey(pid, filter)) return
|
||||
setTreePages(prev => {
|
||||
const old = cursor ? prev[docId] : { items: [] as CockpitTreeNode[], hasMore: false }
|
||||
const items = cursor ? [...(old?.items || []), ...page.items] : page.items
|
||||
return { ...prev, [docId]: { items, nextCursor: page.nextCursor, hasMore: page.hasMore, meta: page.meta } }
|
||||
})
|
||||
})
|
||||
.catch(() => { /* 行内重试入口承担 */ })
|
||||
}, [pid, filter])
|
||||
|
||||
console.log('[cockpit-dbg] RENDER inst=' + instanceId.current, 'identity=', identity, 'projects=', projects === null ? 'null' : projects.length, 'error=', error)
|
||||
if (error) return <CockpitErrorState raw={error} />
|
||||
if (!projects) return <div className="empty-note">加载中…</div>
|
||||
if (projects.length === 0) return <EmptyState icon="folder">暂无可见研发项目——项目成员身份由项目负责人授予</EmptyState>
|
||||
|
||||
const docIds = (overview?.documents || []).map(d => d.ref.id)
|
||||
const splitNotice = !!overview && Object.values(treePages).some(p => !sameSnapshot(overview.meta, p.meta))
|
||||
const selectDoc = (id: string) => setSelected({ ref: { kind: 'document', id } })
|
||||
const selectSection = (docId: string, sec: CockpitTreeNode) => setSelected({ ref: sec.ref, parentDocId: docId })
|
||||
return (
|
||||
<div className="cockpit">
|
||||
<div className="cockpit-header">
|
||||
<select className="ui-input cockpit-project" value={pid} onChange={e => { setPid(e.target.value); setSelected(null); setFilter('') }} aria-label="选择项目">
|
||||
{projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
{filter && <button type="button" className="cockpit-filter-chip" onClick={() => setFilter('')}>
|
||||
筛选:{STAT_FILTERS.find(s => s.filter === filter)?.label || filter} ✕
|
||||
</button>}
|
||||
<span className="cockpit-snap">{overview ? `快照 ${overview.meta.snapshotRevision}` : '加载中…'}</span>
|
||||
<div className="cockpit-viewtoggle" role="group" aria-label="视图切换">
|
||||
<button type="button" className={`cockpit-viewbtn${viewMode === 'panorama' ? ' cockpit-viewbtn-active' : ''}`} onClick={() => applyPrefs({ lastView: 'panorama' })}>全景</button>
|
||||
<button type="button" className={`cockpit-viewbtn${viewMode === 'reader' ? ' cockpit-viewbtn-active' : ''}`} onClick={() => applyPrefs({ lastView: 'reader' })}>阅读器</button>
|
||||
<button type="button" className="cockpit-viewbtn" onClick={() => applyPrefs({ density: density === 'compact' ? 'full' : 'compact' })} title="切换密度">{density === 'compact' ? '紧凑' : '宽松'}</button>
|
||||
</div>
|
||||
<button type="button" className="cockpit-refresh" onClick={() => reload(pid, filter)} disabled={!pid}>刷新</button>
|
||||
</div>
|
||||
{splitNotice && (
|
||||
<div className="cockpit-banner" role="status">统计与树快照不一致——请刷新以对齐最新快照</div>
|
||||
)}
|
||||
{loadErr && <div className="cockpit-banner cockpit-banner-err" role="alert">概览读取失败:{loadErr}(保留上一快照,可重试)</div>}
|
||||
{overview && (
|
||||
<div className="cockpit-stats" role="group" aria-label="项目统计">
|
||||
<div className="cockpit-stat">
|
||||
<span className="cockpit-stat-num">{statDisplay(overview.stats, 'completion').value}</span>
|
||||
<span className="cockpit-stat-label">完成度{statDisplay(overview.stats, 'completion').hint ? `(${statDisplay(overview.stats, 'completion').hint})` : ''}</span>
|
||||
</div>
|
||||
<div className="cockpit-stat"><span className="cockpit-stat-num">{statDisplay(overview.stats, 'documents').value}</span><span className="cockpit-stat-label">文档</span></div>
|
||||
{STAT_FILTERS.map(s => {
|
||||
const d = statDisplay(overview.stats, s.key)
|
||||
const active = filter === s.filter
|
||||
return (
|
||||
<button key={s.key} type="button" className={`cockpit-stat${active ? ' cockpit-stat-active' : ''}`}
|
||||
onClick={() => setFilter(f => toggleFilter(f, s.filter))}
|
||||
title={d.hint ? `${s.label}:${d.hint}` : `筛选:${s.label}`}>
|
||||
<span className="cockpit-stat-num">{d.value}</span>
|
||||
<span className="cockpit-stat-label">{s.label}{d.hint ? `(${d.hint})` : ''}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="cockpit-stat cockpit-stat-unavail"><span className="cockpit-stat-num">—</span><span className="cockpit-stat-label">待确认(未纳入投影)</span></div>
|
||||
<div className="cockpit-stat"><span className="cockpit-stat-num">{overview.stats.updatedAt ? new Date(overview.stats.updatedAt).toLocaleString('zh-CN') : '—'}</span><span className="cockpit-stat-label">最近更新</span></div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`cockpit-columns${selected ? ' cockpit-columns-3' : ''}`}>
|
||||
<div className="cockpit-tree" role="tree" aria-label="文档结构树">
|
||||
<SectionLabel>结构树</SectionLabel>
|
||||
{!overview && <div className="empty-note">加载结构…</div>}
|
||||
{overview && docIds.length === 0 && <div className="empty-note">暂无受控文档</div>}
|
||||
{overview && overview.meta.truncated && (
|
||||
<div className="cockpit-truncated">文档清单已截断({overview.meta.truncationReasons?.join(';') || '超出上限'})</div>
|
||||
)}
|
||||
{overview && overview.documents.map(d => (
|
||||
<TreeRow key={d.ref.id} node={d} depth={0}
|
||||
expanded={treePages[d.ref.id] !== undefined}
|
||||
childPage={treePages[d.ref.id]}
|
||||
selectedId={selected?.ref.id}
|
||||
onSelectNode={n => (n.ref.kind === 'document' ? selectDoc(n.ref.id) : selectSection(d.ref.id, n))}
|
||||
onExpand={() => { if (treePages[d.ref.id] === undefined) loadChildren(d.ref.id) }}
|
||||
onLoadMore={() => { const p = treePages[d.ref.id]; if (p?.nextCursor) loadChildren(d.ref.id, p.nextCursor) }}
|
||||
onRetryChild={() => { const p = treePages[d.ref.id]; loadChildren(d.ref.id, p?.nextCursor) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="cockpit-center">
|
||||
{!overview && <div className="empty-note">加载概览…</div>}
|
||||
{overview && !selected && (
|
||||
<PanoramaHome overview={overview} onSelect={selectDoc} />
|
||||
)}
|
||||
{overview && selected && viewMode === 'panorama' && (
|
||||
<PanoramaNode pid={pid} selection={selected} overview={overview}
|
||||
childPage={selected.ref.kind === 'document' ? treePages[selected.ref.id] : undefined}
|
||||
onClear={() => setSelected(null)}
|
||||
onOpenReader={() => applyPrefs({ lastView: 'reader' })} />
|
||||
)}
|
||||
{overview && selected && viewMode === 'reader' && (
|
||||
<CockpitReader pid={pid} selection={selected}
|
||||
sections={selected.ref.kind === 'document' ? (treePages[selected.ref.id]?.items || []) : []}
|
||||
density={density} onBack={() => applyPrefs({ lastView: 'panorama' })} />
|
||||
)}
|
||||
</div>
|
||||
{overview && selected && (
|
||||
<div className="cockpit-right">
|
||||
<CockpitPanel pid={pid} selection={selected} tab={panelTab} onTab={t => applyPrefs({ rightPanelTab: t })} density={density} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeRow({ node, depth, expanded, childPage, selectedId, onSelectNode, onExpand, onLoadMore, onRetryChild }: {
|
||||
node: CockpitTreeNode; depth: number
|
||||
expanded: boolean
|
||||
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
|
||||
selectedId?: string
|
||||
onSelectNode: (n: CockpitTreeNode) => void
|
||||
onExpand: () => void
|
||||
onLoadMore: () => void
|
||||
onRetryChild: () => void
|
||||
}) {
|
||||
const badges = treeBadges(node)
|
||||
const selected = selectedId === node.ref.id
|
||||
return (
|
||||
<div className="cockpit-tree-branch">
|
||||
<div className={`cockpit-tree-row${selected ? ' cockpit-tree-row-active' : ''}`} style={{ paddingLeft: depth * 14 }} role="treeitem" aria-selected={selected}>
|
||||
{node.ref.kind === 'document' && (
|
||||
<button type="button" className="cockpit-tree-caret" aria-label={expanded ? '折叠' : '展开'}
|
||||
aria-expanded={expanded} onClick={e => { e.stopPropagation(); if (!expanded) onExpand() }}>
|
||||
{expanded ? '▾' : '▸'}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="cockpit-tree-title" onClick={() => onSelectNode(node)}>{node.title}</button>
|
||||
{badges.map(b => <span key={b} className="cockpit-badge">{b}</span>)}
|
||||
{node.lastChangedAt && <span className="cockpit-tree-time">{new Date(node.lastChangedAt).toLocaleDateString('zh-CN')}</span>}
|
||||
</div>
|
||||
{expanded && !childPage && (
|
||||
<button type="button" className="cockpit-tree-more" style={{ paddingLeft: (depth + 1) * 14 }} onClick={onRetryChild}>章节加载失败,重试</button>
|
||||
)}
|
||||
{expanded && childPage && (
|
||||
<div>
|
||||
{childPage.items.length === 0 && <div className="cockpit-tree-empty" style={{ paddingLeft: (depth + 1) * 14 }}>无章节</div>}
|
||||
{childPage.items.map(sec => (
|
||||
<TreeRow key={sec.ref.id} node={sec} depth={depth + 1} expanded={false} childPage={undefined}
|
||||
selectedId={selectedId} onSelectNode={onSelectNode}
|
||||
onExpand={onExpand} onLoadMore={onLoadMore} onRetryChild={onRetryChild} />
|
||||
))}
|
||||
{childPage.hasMore && childPage.items.length > 0 && (
|
||||
<button type="button" className="cockpit-tree-more" style={{ paddingLeft: (depth + 1) * 14 }} onClick={onLoadMore}>加载更多章节</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PanoramaHome({ overview, onSelect }: { overview: CockpitOverview; onSelect: (id: string) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>{overview.project.title} · 文档概览</SectionLabel>
|
||||
{overview.documents.length === 0 && <EmptyState icon="folder">暂无受控文档——文档由模板实例化或导入后进入驾驶舱</EmptyState>}
|
||||
<div className="cockpit-doclist">
|
||||
{overview.documents.map(d => (
|
||||
<button key={d.ref.id} type="button" className="cockpit-docrow" onClick={() => onSelect(d.ref.id)}>
|
||||
<span className="cockpit-docrow-title">{d.title}</span>
|
||||
<span className="cockpit-docrow-meta">{nodeSummaryLine(d)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SectionLabel>最近变化</SectionLabel>
|
||||
{overview.recentChanges.length === 0 && <div className="empty-note">暂无可见变化</div>}
|
||||
<div className="cockpit-changes">
|
||||
{overview.recentChanges.map((c, i) => (
|
||||
<div key={`${c.node.id}-${i}`} className="cockpit-changerow">
|
||||
<span className="cockpit-changesum">{c.summary} · {c.title}{c.actor ? ` · ${c.actor}` : ''}</span>
|
||||
<span className="cockpit-changetime">{new Date(c.changedAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PanoramaNode({ pid, selection, overview, childPage, onClear, onOpenReader }: {
|
||||
pid: string; selection: Selection; overview: CockpitOverview
|
||||
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
|
||||
onClear: () => void
|
||||
onOpenReader: () => void
|
||||
}) {
|
||||
const [detail, setDetail] = useState<any>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const gen = useRef(0)
|
||||
useEffect(() => {
|
||||
const g = ++gen.current
|
||||
setDetail(null); setErr('')
|
||||
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id })
|
||||
.then(d => { if (g === gen.current) setDetail(d) })
|
||||
.catch(e => { if (g === gen.current) setErr(e.message) })
|
||||
return () => { gen.current++ }
|
||||
}, [pid, selection.ref.kind, selection.ref.id])
|
||||
const node = (detail?.node || null) as CockpitTreeNode | null
|
||||
const title = node?.title || selection.ref.id
|
||||
return (
|
||||
<div>
|
||||
<div className="cockpit-nodehead">
|
||||
<button type="button" className="cockpit-back" onClick={onClear}>← 返回概览</button>
|
||||
<span className="cockpit-nodetitle">{title}</span>
|
||||
{node && <span className="cockpit-nodesum">{nodeSummaryLine(node)}</span>}
|
||||
<button type="button" className="cockpit-filter-chip" onClick={onOpenReader}>在阅读器中打开</button>
|
||||
</div>
|
||||
{err && <CockpitErrorState raw={err} />}
|
||||
{detail?.body?.length === 1 && detail.body[0].kind === 'restricted' && (
|
||||
<div className="empty-note">内容受限({detail.body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
|
||||
)}
|
||||
{detail && detail.meta && !sameSnapshot(detail.meta, overview.meta) && (
|
||||
<div className="cockpit-truncated">节点详情与统计快照不同({detail.meta.snapshotRevision} vs {overview.meta.snapshotRevision})——刷新对齐</div>
|
||||
)}
|
||||
{node && node.ref.kind === 'document' && (
|
||||
<>
|
||||
<SectionLabel>章节状态</SectionLabel>
|
||||
{!childPage && <div className="empty-note">章节按需加载——在左侧树展开</div>}
|
||||
{childPage && childPage.items.length === 0 && <div className="empty-note">无章节</div>}
|
||||
{childPage && childPage.items.length > 0 && (
|
||||
<table className="cockpit-childtable">
|
||||
<thead><tr><th>章节</th><th>内容</th><th>核实</th><th>来源</th><th>冲突</th><th>最近更新</th></tr></thead>
|
||||
<tbody>
|
||||
{childPage.items.map(sec => (
|
||||
<tr key={sec.ref.id}>
|
||||
<td>{sec.title}</td>
|
||||
<td>{{ complete: '完整', partial: '部分', empty: '空', inaccessible: '受限', unavailable: '不可读' }[sec.state.content] || sec.state.content}</td>
|
||||
<td>{sec.state.verification === 'not_applicable' ? '—' : sec.state.verification}</td>
|
||||
<td>{sec.state.sourceCount}</td>
|
||||
<td>{sec.state.conflictCount}</td>
|
||||
<td>{sec.lastChangedAt ? new Date(sec.lastChangedAt).toLocaleDateString('zh-CN') : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{node && node.ref.kind === 'section' && (
|
||||
<div className="empty-note">点「在阅读器中打开」查看连续正文与版本差异;右侧为该节点的依据与影响面板</div>
|
||||
)}
|
||||
{detail?.meta?.truncated && (
|
||||
<div className="cockpit-truncated">详情已截断:{detail.meta.truncationReasons?.join(';')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type ExecEnvView } from '../api'
|
||||
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup } from '@ui'
|
||||
|
||||
export function ExecEnvPanel() {
|
||||
const [view, setView] = useState<ExecEnvView | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [conv, setConv] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [wheel, setWheel] = useState<File | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
try { setView(await api.adminExecEnv()) } catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
const run = async (work: () => Promise<string>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try { setNotice(await work()); await refresh() } catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (!view) return <EmptyState icon="package">加载 Python 环境…</EmptyState>
|
||||
if (!view.configured) return <EmptyState icon="package">这台服务没有配置分层 Python 环境根</EmptyState>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsGroup title="当前环境">
|
||||
<SettingRow label="环境根" desc={view.root || '未设置'}><span /></SettingRow>
|
||||
<SettingRow label="解释器" desc={view.python || '尚未安装托管解释器'}><span /></SettingRow>
|
||||
<SettingRow label="基础层" desc={view.base || '尚未建立'}><span /></SettingRow>
|
||||
<SettingRow label="层目录" desc={(view.layers ?? []).join('、') || '无'}>
|
||||
<Btn variant="ghost" icon="undo" disabled={busy} onClick={() => run(async () => {
|
||||
const out = await api.adminExecEnvRebase()
|
||||
return `基础层已切换到 ${out.base}。已有会话仍用原来的绑定。`
|
||||
})}>重建基础层</Btn>
|
||||
</SettingRow>
|
||||
</SettingsGroup>
|
||||
<SettingsGroup title="申请">
|
||||
{(view.requests ?? []).length === 0 && <EmptyState icon="inbox">还没有扩展包申请</EmptyState>}
|
||||
{(view.requests ?? []).map(req => (
|
||||
<SettingRow key={req.id} label={req.spec || req.id} desc={`${req.status} · ${req.conversationId} · ${req.detail}`}>
|
||||
{req.status === 'pending_approval' && (
|
||||
<span className="ad-row">
|
||||
<Btn variant="primary" icon="check" disabled={busy} onClick={() => run(async () => {
|
||||
const out = await api.adminExecEnvDecision(req.id, true, false)
|
||||
return out.detail || out.status
|
||||
})}>批准</Btn>
|
||||
<Btn variant="ghost" disabled={busy} onClick={() => run(async () => {
|
||||
const out = await api.adminExecEnvDecision(req.id, true, true)
|
||||
return out.detail || '已用隔离层'
|
||||
})}>隔离层</Btn>
|
||||
<Btn variant="ghost" icon="x" disabled={busy} onClick={() => run(async () => {
|
||||
const out = await api.adminExecEnvDecision(req.id, false, false)
|
||||
return out.detail || '已拒绝'
|
||||
})}>拒绝</Btn>
|
||||
</span>
|
||||
)}
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
<SettingsGroup title="会话绑定">
|
||||
{(view.bindings ?? []).map(b => (
|
||||
<SettingRow key={b.conversationId} label={b.conversationId} desc={b.isolated ? `隔离 ${b.isolated}` : (b.extensions ?? []).join('、') || '只用基础层'}>
|
||||
<span />
|
||||
</SettingRow>
|
||||
))}
|
||||
<SettingRow label="回收" desc="删掉 7 天内没有会话引用、也不是当前基础层的目录">
|
||||
<Btn variant="ghost" icon="trash" disabled={busy} onClick={() => run(async () => {
|
||||
const out = await api.adminExecEnvGC()
|
||||
const n = out.removed?.length ?? 0
|
||||
return n === 0 ? '没有可回收的层' : `已回收 ${n} 层`
|
||||
})}>回收未引用层</Btn>
|
||||
</SettingRow>
|
||||
</SettingsGroup>
|
||||
<SettingsGroup title="上传 wheel">
|
||||
<SettingRow label="会话" desc="这个 wheel 只挂到指定会话,并进入审批">
|
||||
<input className="lg-input" value={conv} onChange={e => setConv(e.target.value)} placeholder="会话 ID" />
|
||||
</SettingRow>
|
||||
<SettingRow label="理由" desc="写给审批记录">
|
||||
<input className="lg-input" value={reason} onChange={e => setReason(e.target.value)} />
|
||||
</SettingRow>
|
||||
<SettingRow label="文件" desc="只接受预编译 wheel,不执行里面的脚本">
|
||||
<input type="file" accept=".whl" onChange={e => setWheel(e.target.files?.[0] ?? null)} />
|
||||
</SettingRow>
|
||||
<div className="ad-row">
|
||||
<Btn variant="primary" icon="download" disabled={busy || !wheel || !conv.trim()} onClick={() => run(async () => {
|
||||
const out = await api.adminExecEnvWheel(wheel as File, conv.trim(), reason.trim())
|
||||
return `${out.file}:${out.outcome.status}`
|
||||
})}>提交审批</Btn>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
|
||||
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key);
|
||||
// ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。
|
||||
import { useState } from 'react'
|
||||
import { auth } from '../auth'
|
||||
import { api } from '../api'
|
||||
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(恢复/激活,只输 Key);
|
||||
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
|
||||
// grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码
|
||||
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type PasswordChangeMode } from '../api'
|
||||
import { Icon, BrandMark } from '@ui'
|
||||
|
||||
function Err({ msg }: { msg: string }) {
|
||||
@@ -43,8 +44,6 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
const me = await api.login(owner.trim(), password)
|
||||
// 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。
|
||||
auth.beginSession()
|
||||
// 受限会话(must-change 首登)或正常会话都由后端 sessionScope 声明;
|
||||
// 受限会话先进设密门,完成前不放行。
|
||||
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
|
||||
@@ -62,7 +61,6 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
try {
|
||||
// 只提交 Key:目标账户由服务端绑定记录确定,客户端不能指定身份。
|
||||
const me = await api.redeemLoginKey(key.trim())
|
||||
auth.beginSession()
|
||||
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
|
||||
onMustChange(me.owner)
|
||||
return
|
||||
@@ -100,7 +98,7 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="alk_…" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '兑换中…' : '兑换并设置新密码'}</button>
|
||||
<p className="lg-hint">忘记密码时由管理员签发;单次有效、默认 30 分钟内兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
|
||||
<p className="lg-hint">忘记密码时由管理员发起恢复提案,并由你在已登录设备的「设置 → 账号」中本人确认后获得;新账号使用管理员交付的激活 Key。单次有效、限时兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
@@ -109,23 +107,46 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
)
|
||||
}
|
||||
|
||||
/** 强制设密门:恢复 Key 兑换或初始密码首登建立的受限会话,必须完成设密。
|
||||
* 免当前密码(R5):身份与授权均由服务端受限会话确认,页面不出现当前密码输入框;
|
||||
* 完成前不能进入工作台,完成后的唯一出口是回密码登录页。 */
|
||||
/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked
|
||||
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)免当前密码。 */
|
||||
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
|
||||
return mode === 'current_required'
|
||||
}
|
||||
|
||||
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
|
||||
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
|
||||
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
|
||||
const [mode, setMode] = useState<PasswordChangeMode | null>(null)
|
||||
const [current, setCurrent] = useState('')
|
||||
const [next, setNext] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.me().then(me => {
|
||||
if (!active) return
|
||||
if (me.owner !== owner || me.sessionScope !== 'set_password_only' ||
|
||||
(me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) {
|
||||
setErr('当前设密凭据不再有效,请重新登录;恢复场景请先核对「设置 → 账号」中的恢复提案状态。')
|
||||
return
|
||||
}
|
||||
setMode(me.passwordChangeMode)
|
||||
}).catch(() => { if (active) setErr('无法确认当前设密方式,请重新登录。') })
|
||||
return () => { active = false }
|
||||
}, [owner])
|
||||
|
||||
const submit = async () => {
|
||||
setErr('')
|
||||
if (!mode) { setErr('设密方式尚未由服务端确认'); return }
|
||||
if (requiresCurrentPassword(mode) && !current) { setErr('请填写当前密码'); return }
|
||||
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
|
||||
if (next.length < 8) { setErr('新密码至少 8 位'); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.changePassword('', next)
|
||||
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
|
||||
setDone(true)
|
||||
} catch (e) {
|
||||
setErr(humanChangePasswordError(String(e)))
|
||||
@@ -147,13 +168,19 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
<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>
|
||||
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
|
||||
{mode === 'current_required' && <>
|
||||
<label className="lg-label" htmlFor="forced-current">当前密码</label>
|
||||
<input id="forced-current" className="lg-input" type="password" value={current}
|
||||
onChange={e => setCurrent(e.target.value)} autoComplete="current-password" />
|
||||
</>}
|
||||
{mode === 'grant_setup' && <p className="lg-hint">身份经可信激活/恢复能力确认,此次无需输入当前密码。</p>}
|
||||
<label className="lg-label">新密码(≥8 位)</label>
|
||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
|
||||
<label className="lg-label">确认新密码</label>
|
||||
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
|
||||
<button className="lg-submit" type="submit" disabled={busy || !mode || (requiresCurrentPassword(mode) && !current) || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
|
||||
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>退出并返回登录</button>}
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Stable server time, with the full local date available to keyboard and assistive readers. */
|
||||
export function MessageTimestamp({ value }: { value?: string }) {
|
||||
const date = value ? new Date(value) : null
|
||||
if (!date || !Number.isFinite(date.getTime())) return null
|
||||
const full = date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
||||
const label = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
return <time className="message-timestamp" dateTime={date.toISOString()} title={full} aria-label={full}>{label}</time>
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。
|
||||
// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话
|
||||
//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应
|
||||
// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { Collapsible, Icon, SettingsGroup } from '@ui'
|
||||
import { recoveryStatusLabel } from '../recovery'
|
||||
|
||||
const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
|
||||
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
|
||||
]
|
||||
|
||||
export function MyAccountSettings() {
|
||||
const me = useStore(s => s.me)
|
||||
const [account, setAccount] = useState<AccountRecord | null>(null)
|
||||
const [draft, setDraft] = useState<AccountProfile | null>(null)
|
||||
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
|
||||
const [grant, setGrant] = useState<{ proposalId: string; value: string } | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const sequence = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!me?.auth) return
|
||||
const seq = ++sequence.current
|
||||
Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => {
|
||||
if (seq !== sequence.current) return
|
||||
setAccount(profile); setDraft(profile.profile); setProposals(statuses)
|
||||
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
|
||||
return () => { sequence.current++ }
|
||||
}, [me?.owner, me?.auth])
|
||||
|
||||
if (!me?.auth) return null
|
||||
|
||||
const save = async () => {
|
||||
if (!account || !draft) return
|
||||
const patch: Partial<AccountProfile> = {}
|
||||
for (const field of fields) if (draft[field.key] !== account.profile[field.key]) patch[field.key] = draft[field.key]
|
||||
if (Object.keys(patch).length === 0) { setNotice('资料没有变化'); return }
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const updated = await api.patchMeProfile(account.profileVersion, patch)
|
||||
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存;资料不会成为恢复身份的证明')
|
||||
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const refreshProposal = async (id: string) => {
|
||||
const updated = await api.getMyRecoveryProposal(id)
|
||||
setProposals(current => current?.map(item => item.id === id ? updated : item) ?? [updated])
|
||||
return updated
|
||||
}
|
||||
|
||||
const confirm = async (proposal: RecoveryProposal) => {
|
||||
setBusy(true); setErr(''); setNotice(''); setGrant(null)
|
||||
try {
|
||||
const result = await api.confirmMyRecoveryProposal(proposal.id, proposal.version)
|
||||
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
|
||||
if (!result.grant) {
|
||||
setErr('提案已确认,但一次性明文不可读回。若仍保有原本人会话,可显式重新生成设密能力。')
|
||||
return
|
||||
}
|
||||
setGrant({ proposalId: proposal.id, value: result.grant })
|
||||
setNotice('本人确认成功。设密能力只在此响应可用,请立即兑换并设置新密码。')
|
||||
} catch (error) {
|
||||
const current = await refreshProposal(proposal.id).catch(() => null)
|
||||
setErr(current?.status === 'confirmed'
|
||||
? '确认结果待核验:提案已确认,但明文不能读回。若仍保有原本人会话,可显式重新生成设密能力。'
|
||||
: `确认未完成或结果未知:${String(error)}。请核对当前提案状态。`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const rotate = async (proposal: RecoveryProposal) => {
|
||||
setBusy(true); setErr(''); setNotice(''); setGrant(null)
|
||||
try {
|
||||
const result = await api.rotateMyRecoveryGrant(proposal.id, proposal.version, proposal.generation)
|
||||
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
|
||||
if (!result.grant) {
|
||||
setErr('重新生成已提交,但一次性明文不可读回。请核对当前提案状态,再由本人显式操作。')
|
||||
return
|
||||
}
|
||||
setGrant({ proposalId: proposal.id, value: result.grant })
|
||||
setNotice('已重新生成设密能力;旧能力与派生受限会话已失效。')
|
||||
} catch (error) {
|
||||
await refreshProposal(proposal.id).catch(() => null)
|
||||
setErr(`重新生成结果待核验:${String(error)}。请核对提案状态,再由本人显式操作。`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const redeem = async () => {
|
||||
if (!grant) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
await api.redeemLoginKey(grant.value)
|
||||
location.reload() // 新 HttpOnly 受限会话由应用启动门送到设密页
|
||||
} catch (error) {
|
||||
const session = await api.me().catch(() => null)
|
||||
if (session?.sessionScope === 'set_password_only' && session.passwordChangeMode === 'grant_setup') {
|
||||
location.reload()
|
||||
return
|
||||
}
|
||||
await refreshProposal(grant.proposalId).catch(() => null)
|
||||
setGrant(null)
|
||||
setErr(`兑换结果待核验:${String(error)}。不会自动重复兑换;若仍保有原本人会话,请显式重新生成能力。`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <SettingsGroup title="我的账号与恢复">
|
||||
{account && draft ? <>
|
||||
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空,也不会作为本人恢复证明。</p>
|
||||
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
|
||||
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
|
||||
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
|
||||
<input id={`my-profile-${field.key}`} className="lg-input" value={draft[field.key]}
|
||||
onChange={event => setDraft({ ...draft, [field.key]: event.target.value })} />
|
||||
</div>)}
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={save}>保存我的资料</button></div>
|
||||
</Collapsible>
|
||||
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
|
||||
|
||||
<p className="ad-hint">忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 Key 兑换设密;历史未知来源账号无法在线证明。</p>
|
||||
{(proposals ?? []).map(proposal => <div className="ad-create" key={proposal.id}>
|
||||
<div className="ad-row"><span className="ad-mono">{proposal.id}</span><b>{recoveryStatusLabel[proposal.status]}</b>
|
||||
<span className="ad-dim">到期 {new Date(proposal.expiresAt).toLocaleString()}</span></div>
|
||||
<div className="ad-row">
|
||||
{proposal.status === 'pending' && <button className="ad-mini" disabled={busy} onClick={() => confirm(proposal)}>由本人确认</button>}
|
||||
{proposal.status === 'confirmed' && <button className="ad-mini" disabled={busy} onClick={() => rotate(proposal)}>显式重新生成设密能力</button>}
|
||||
</div>
|
||||
</div>)}
|
||||
{proposals !== null && proposals.length === 0 && <p className="ad-hint">当前没有恢复提案。</p>}
|
||||
{grant && <div className="ad-reset">
|
||||
<p className="ad-hint">设密能力仅保存在当前页面内存,离开后不可读回;不自动复制到剪贴板。</p>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={redeem}>立即兑换并设置新密码</button>
|
||||
</div>}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||
</SettingsGroup>
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// 执行记录按工作动作分组;展开状态由调用方控制。
|
||||
import type { ReactNode } from 'react'
|
||||
import { ToolBlock } from '@ui'
|
||||
import { Collapsible, ToolBlock } from '@ui'
|
||||
import type { Part } from '../contracts'
|
||||
import { formatDuration, humanSize } from '../messageparts'
|
||||
import { timelineEntries, toolGroupLabel, type ToolGroupItem } from '../toolkind'
|
||||
@@ -15,8 +15,12 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
|
||||
return (
|
||||
<div className="tl">
|
||||
{rows.map((row, i) => {
|
||||
// 模型内部思考不是用户工作内容,也不属于可展开的执行记录。
|
||||
if (row.kind === 'thinking') return null
|
||||
if (row.kind === 'thinking') {
|
||||
return row.text && <div className="tl-thinking" key={`${idKey}:th:${i}`}>
|
||||
<span className="tl-thinking-label">思考过程</span>
|
||||
<div className="tl-thinking-body">{row.text}</div>
|
||||
</div>
|
||||
}
|
||||
if (row.kind === 'text') {
|
||||
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
|
||||
}
|
||||
@@ -38,6 +42,11 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
|
||||
<span className="tl-tools-name">{it.headline}</span>
|
||||
{it.status === 'error' && <span className="status-error">失败</span>}
|
||||
{it.summary && <span className="tl-tools-sum">{it.summary}</span>}
|
||||
{(it.arguments || it.output) && <Collapsible id={`tool-record:${idKey}:${it.id}:${j}`}
|
||||
triggerClassName="tl-tool-detail" trigger={<span>查看记录 ▸</span>}>
|
||||
{it.arguments && <pre className="tool-preview">{it.arguments}</pre>}
|
||||
{it.output && <pre className="tool-preview">{it.output}</pre>}
|
||||
</Collapsible>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { SettingDesc } from '../contracts'
|
||||
import { SettingsShell, Btn, EmptyState } from '@ui'
|
||||
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
|
||||
import { RdmMembersSection, RdmDutiesSection, RdmPermMatrixSection } from './adminRdmSections'
|
||||
import { rdmProjectReadError } from '../rdmManagement'
|
||||
import { projectActionError, projectWorkError } from '../projectmeta'
|
||||
|
||||
export function ProjectSettingsModal() {
|
||||
const projectId = useStore(s => s.ui.projectSettingsId)
|
||||
@@ -16,21 +18,31 @@ export function ProjectSettingsModal() {
|
||||
const rdmMembersAvailable = useStore(s => s.rdmMembersAvailable)
|
||||
const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable)
|
||||
const rdmPermMatrixAvailable = useStore(s => s.rdmPermMatrixAvailable)
|
||||
const owner = useStore(s => s.me?.owner)
|
||||
const isAdmin = useStore(s => s.me?.isAdmin ?? false)
|
||||
// P1-B 裁定:宿主覆盖设置(含模型与密钥)仅项目负责人/系统管理员可查看与修改;
|
||||
// RDM 页签(成员/文档职责/项目权限)不在此列——数据面由 RDM 权限自行收敛,
|
||||
// 授权的职责管理者仍可进入。
|
||||
const projectPrivileged = !!project && (project.owner === owner || isAdmin)
|
||||
const [tab, setTab] = useState('project')
|
||||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
setLoaded(false)
|
||||
let active = true
|
||||
setLoaded(false); setSaved(false); setError(''); setDraft({}); setSchema([])
|
||||
api.getSettings(projectId).then(s => {
|
||||
if (!active) return
|
||||
setSchema(s.schema)
|
||||
setDraft(flatten(s.project))
|
||||
setLoaded(true)
|
||||
}).catch(() => undefined)
|
||||
}, [projectId])
|
||||
}).catch(() => { if (active) setError('项目设置未能加载,请关闭后重新打开或联系项目负责人') })
|
||||
return () => { active = false }
|
||||
}, [projectId, owner])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const g: Record<string, SettingDesc[]> = {}
|
||||
@@ -39,15 +51,19 @@ export function ProjectSettingsModal() {
|
||||
}, [schema])
|
||||
|
||||
if (!projectId) return null
|
||||
const rdmError = rdmProjectReadError(project)
|
||||
const writeError = projectWorkError(project)
|
||||
const rdmTab = tab === 'members' || tab === 'duties' || tab === 'matrix'
|
||||
|
||||
const save = async () => {
|
||||
if (!projectId) return
|
||||
if (writeError) { store.toast(writeError); return }
|
||||
try {
|
||||
await api.putProjectSettings(projectId, unflatten(draft))
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 1500)
|
||||
} catch (e) {
|
||||
store.toast(`项目设置保存失败:${e}`)
|
||||
store.toast(`项目设置保存失败:${projectActionError(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,21 +77,25 @@ export function ProjectSettingsModal() {
|
||||
{ key: 'project', label: '本项目', icon: 'folder' },
|
||||
// U6/KTD5:成员与文档职责页签按 RDM 命令面门控(普通基座不出现)。
|
||||
...(rdmMembersAvailable ? [{ key: 'members', label: '成员', icon: 'users' as const }] : []),
|
||||
...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责 · 整文审签', icon: 'shield' as const }] : []),
|
||||
...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责', icon: 'shield' as const }] : []),
|
||||
...(rdmPermMatrixAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []),
|
||||
]}
|
||||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
|
||||
footer={saved ? <span style={{ color: 'var(--ok-text)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
|
||||
>
|
||||
<div>
|
||||
{tab === 'members' && <RdmMembersSection projectId={projectId} />}
|
||||
{tab === 'duties' && <RdmDutiesSection projectId={projectId} />}
|
||||
{tab === 'matrix' && <RdmPermMatrixSection projectId={projectId} />}
|
||||
{rdmTab && rdmError ? <EmptyState icon="folder">{rdmError}</EmptyState> : project && <>
|
||||
{tab === 'members' && <RdmMembersSection key={project.id} project={project} />}
|
||||
{tab === 'duties' && <RdmDutiesSection key={project.id} project={project} />}
|
||||
{tab === 'matrix' && <RdmPermMatrixSection key={project.id} project={project} />}
|
||||
</>}
|
||||
{tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !loaded ? (
|
||||
<EmptyState icon="settings">加载中…</EmptyState>
|
||||
<EmptyState icon="settings">{error || '加载中…'}</EmptyState>
|
||||
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !projectPrivileged ? (
|
||||
<EmptyState icon="settings">项目覆盖设置(含模型与密钥)仅项目负责人或系统管理员可查看与修改。</EmptyState>
|
||||
) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? (
|
||||
<>
|
||||
<div className="empty-note" style={{ marginBottom: 10 }}>
|
||||
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
|
||||
{writeError ?? '此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。'}
|
||||
</div>
|
||||
{Object.entries(groups).map(([g, descs]) => (
|
||||
<div className="settings-group" key={g}>
|
||||
@@ -91,7 +111,7 @@ export function ProjectSettingsModal() {
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
|
||||
<Btn variant="primary" icon="check" disabled={!!writeError} onClick={save}>保存项目设置</Btn>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
|
||||
import { classifyRdmError } from '../rdmErrors'
|
||||
import { store, useStore } from '../store'
|
||||
import { exportNeedsDraft } from '../workspaces'
|
||||
|
||||
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
|
||||
|
||||
@@ -45,22 +47,30 @@ function RdmErrorState({ raw }: { raw: string }) {
|
||||
}
|
||||
|
||||
export function RdmAuditWorkspace() {
|
||||
const identity = useStore(() => store.getIdentityEpoch())
|
||||
const [caps, setCaps] = useState<Capabilities | null>(null)
|
||||
const [capsIdentity, setCapsIdentity] = useState(-1)
|
||||
const [pid, setPid] = useState('')
|
||||
const [tab, setTab] = useState<TabId>('summary')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setCaps(null); setPid(''); setError('')
|
||||
api.rdmCommand<Capabilities>('rdm.inspect.capabilities')
|
||||
.then(c => { if (!cancelled) { setCaps(c); if (c.projects.length > 0) setPid(prev => prev || c.projects[0].projectId) } })
|
||||
.catch(e => { if (!cancelled) setError(e.message) })
|
||||
.then(c => {
|
||||
if (cancelled || identity !== store.getIdentityEpoch()) return
|
||||
setCaps(c); setCapsIdentity(identity)
|
||||
if (c.projects.length > 0) setPid(c.projects[0].projectId)
|
||||
})
|
||||
.catch(e => { if (!cancelled && identity === store.getIdentityEpoch()) setError(e.message) })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
}, [identity])
|
||||
|
||||
const project = caps?.projects.find(p => p.projectId === pid)
|
||||
const exportOn = useStore(s => s.rdmExportAvailable)
|
||||
if (error) return <RdmErrorState raw={error} />
|
||||
if (!caps) return <div className="empty-note">加载中…</div>
|
||||
if (!caps || capsIdentity !== identity) return <div className="empty-note">加载中…</div>
|
||||
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
|
||||
|
||||
return (
|
||||
@@ -82,7 +92,7 @@ export function RdmAuditWorkspace() {
|
||||
))}
|
||||
</div>
|
||||
<div className="rdm-audit-body">
|
||||
{tab === 'summary' && <SummaryTab pid={pid} />}
|
||||
{tab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
|
||||
{tab === 'materials' && <MaterialsTab pid={pid} />}
|
||||
{tab === 'trace' && <TraceTab pid={pid} />}
|
||||
{tab === 'timeline' && <TimelineTab pid={pid} />}
|
||||
@@ -93,7 +103,7 @@ export function RdmAuditWorkspace() {
|
||||
|
||||
// ---- 概览 ----
|
||||
|
||||
function SummaryTab({ pid }: { pid: string }) {
|
||||
function SummaryTab({ pid, exportOn }: { pid: string; exportOn: boolean }) {
|
||||
const [data, setData] = useState<SummaryData | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
useEffect(() => {
|
||||
@@ -133,6 +143,131 @@ function SummaryTab({ pid }: { pid: string }) {
|
||||
{data.coverage && data.coverage.length > 0 && (
|
||||
<CoverageList cov={data.coverage} />
|
||||
)}
|
||||
{exportOn && <ExportSection pid={pid} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 概览导出(三条命令都启用才挂上;不参与七命令就绪门) ----
|
||||
|
||||
interface ExportCreated {
|
||||
exportId: string
|
||||
fileName: string
|
||||
watermark: number
|
||||
sha256: string
|
||||
draft?: boolean
|
||||
}
|
||||
interface ExportStatus {
|
||||
expiresAt: string
|
||||
sha256: string
|
||||
watermark: number
|
||||
}
|
||||
interface ExportDownloaded {
|
||||
fileName: string
|
||||
mime: string
|
||||
dataB64: string
|
||||
sha256: string
|
||||
}
|
||||
|
||||
function saveExportFile(fileName: string, mime: string, dataB64: string) {
|
||||
const bin = atob(dataB64)
|
||||
const bytes = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
||||
const blob = new Blob([bytes], { type: mime || 'application/octet-stream' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function ExportSection({ pid }: { pid: string }) {
|
||||
const identity = useStore(() => store.getIdentityEpoch())
|
||||
const generation = useRef(0)
|
||||
const [kind, setKind] = useState<'cgraph-docx' | 'cgraph-matrix'>('cgraph-docx')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [needDraft, setNeedDraft] = useState(false)
|
||||
const [artifact, setArtifact] = useState<(ExportCreated & { expiresAt: string }) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
generation.current++
|
||||
setArtifact(null)
|
||||
setErr('')
|
||||
setNeedDraft(false)
|
||||
setBusy(false)
|
||||
return () => { generation.current++ }
|
||||
}, [pid, identity])
|
||||
|
||||
const generate = async (draft: boolean) => {
|
||||
const request = ++generation.current
|
||||
const current = () => request === generation.current && identity === store.getIdentityEpoch()
|
||||
setBusy(true)
|
||||
setErr('')
|
||||
setNeedDraft(false)
|
||||
try {
|
||||
const created = await api.rdmCommand<ExportCreated>('rdm.inspect.export.create', { projectId: pid, kind, draft })
|
||||
if (!current()) return
|
||||
const st = await api.rdmCommand<ExportStatus>('rdm.inspect.export.status', { projectId: pid, exportId: created.exportId })
|
||||
if (!current()) return
|
||||
setArtifact({ ...created, expiresAt: st.expiresAt })
|
||||
} catch (e) {
|
||||
if (!current()) return
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
if (!draft && exportNeedsDraft(msg)) setNeedDraft(true)
|
||||
else setErr(msg)
|
||||
} finally {
|
||||
if (current()) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
if (!artifact) return
|
||||
const request = ++generation.current
|
||||
const current = () => request === generation.current && identity === store.getIdentityEpoch()
|
||||
setBusy(true)
|
||||
setErr('')
|
||||
try {
|
||||
const dl = await api.rdmCommand<ExportDownloaded>('rdm.inspect.export.download', { projectId: pid, exportId: artifact.exportId })
|
||||
if (!current()) return
|
||||
saveExportFile(dl.fileName, dl.mime, dl.dataB64)
|
||||
} catch (e) {
|
||||
if (current()) setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
if (current()) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rdm-audit-export">
|
||||
<SectionLabel>导出</SectionLabel>
|
||||
<div className="rdm-audit-export-row">
|
||||
<label className="rdm-audit-export-kind">
|
||||
类型
|
||||
<select className="ui-input" aria-label="导出类型" value={kind} onChange={e => setKind(e.target.value as 'cgraph-docx' | 'cgraph-matrix')}>
|
||||
<option value="cgraph-docx">合规追溯报告</option>
|
||||
<option value="cgraph-matrix">追溯矩阵</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(false) }}>生成</button>
|
||||
</div>
|
||||
{needDraft && (
|
||||
<div className="rdm-audit-export-draft">
|
||||
<p>正式报告不完整。可以生成带缺口标注的草稿。</p>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(true) }}>生成草稿</button>
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="rdm-audit-export-err">{err}</div>}
|
||||
{artifact && (
|
||||
<div className="rdm-audit-export-result">
|
||||
<div>水位 {artifact.watermark}</div>
|
||||
<div>sha256 {artifact.sha256}</div>
|
||||
<div>有效期 {artifact.expiresAt ? new Date(artifact.expiresAt).toLocaleString('zh-CN') : '—'}</div>
|
||||
{artifact.draft && <div>草稿</div>}
|
||||
<button type="button" className="btn v-ghost" disabled={busy} onClick={() => { void download() }}>下载</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ import type { ThemeChoice } from '../theme'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
|
||||
import { BorrowPanels } from './BorrowPanels'
|
||||
import { MyAccountSettings } from './MyAccountSettings'
|
||||
|
||||
type Section = 'settings'
|
||||
type Section = 'settings' | 'account'
|
||||
|
||||
// 分组显示名(项目设置面板共用)。
|
||||
export const groupNames: Record<string, string> = {
|
||||
@@ -19,7 +20,7 @@ export const groupNames: Record<string, string> = {
|
||||
}
|
||||
|
||||
export function SettingsModal() {
|
||||
const [section] = useState<Section>('settings')
|
||||
const [section, setSection] = useState<Section>('settings')
|
||||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||||
const [, setUser] = useState<Record<string, any>>({})
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
@@ -54,10 +55,11 @@ export function SettingsModal() {
|
||||
|
||||
return (
|
||||
<SettingsShell
|
||||
title="设置" active={section} onSelect={() => undefined}
|
||||
title="设置" active={section} onSelect={key => setSection(key as Section)}
|
||||
onClose={() => store.openSettings(false)}
|
||||
sections={[
|
||||
{ key: 'settings', label: '常规', icon: 'settings' },
|
||||
{ key: 'account', label: '账号', icon: 'user' },
|
||||
]}
|
||||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
|
||||
actions={section === 'settings' && schema.length > 0 ? (
|
||||
@@ -108,6 +110,7 @@ export function SettingsModal() {
|
||||
)
|
||||
})}
|
||||
{section === 'settings' && <BorrowPanels />}
|
||||
{section === 'account' && <MyAccountSettings />}
|
||||
</SettingsShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { api } from '../api'
|
||||
import { Icon } from '@ui'
|
||||
import type { Conversation } from '../contracts'
|
||||
import { relativeTime, filterTasks } from '../tasklist'
|
||||
import { projectStage } from '../projectmeta'
|
||||
import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta'
|
||||
import { buildWorkspaceEntries } from '../workspaces'
|
||||
|
||||
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
|
||||
@@ -57,6 +57,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||||
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
|
||||
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
|
||||
const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable)
|
||||
const theme = useStore(s => s.ui.theme)
|
||||
const pendingBadge = useStore(s =>
|
||||
s.cards.filter(c => c.status === 'action_required').length +
|
||||
@@ -68,16 +69,19 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
const [newProjectName, setNewProjectName] = useState('')
|
||||
|
||||
const createConversation = async (projectId?: string) => {
|
||||
await store.createTask(projectId)
|
||||
onConversationOpen?.()
|
||||
try {
|
||||
await store.createTask(projectId)
|
||||
onConversationOpen?.()
|
||||
} catch { /* store owns the actionable failure toast */ }
|
||||
}
|
||||
|
||||
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
|
||||
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable, rdmCockpit: rdmCockpitAvailable })
|
||||
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
|
||||
const pinned = visible.filter(t => t.pinned)
|
||||
const rest = visible.filter(t => !t.pinned)
|
||||
const currentProject = projects.find(p => p.id === currentProjectId)
|
||||
const stage = projectStage(currentProject?.settings)
|
||||
const stage = projectStateLabel(currentProject?.settings) ?? projectStage(currentProject?.settings)
|
||||
const workError = currentProject ? projectWorkError(currentProject) : null
|
||||
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
|
||||
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
|
||||
|
||||
@@ -162,8 +166,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
onChange={e => setNewProjectName(e.target.value)}
|
||||
onKeyDown={async e => {
|
||||
if (e.key === 'Enter' && newProjectName.trim()) {
|
||||
await store.createProject(newProjectName.trim())
|
||||
setCreatingProject(false); setNewProjectName('')
|
||||
try {
|
||||
await store.createProject(newProjectName.trim())
|
||||
setCreatingProject(false); setNewProjectName('')
|
||||
} catch { /* keep the input available for a corrected name */ }
|
||||
}
|
||||
if (e.key === 'Escape') setCreatingProject(false)
|
||||
}}
|
||||
@@ -179,7 +185,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
)}
|
||||
|
||||
{rest.length === 0 && pinned.length === 0 && (
|
||||
<div className="tsb-empty">从「新建对话」开始,描述你想完成的工作。</div>
|
||||
<div className="tsb-empty">{workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
|
||||
)}
|
||||
{rest.map(t => taskRow(t))}
|
||||
</div>
|
||||
@@ -224,7 +230,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||
{projects.map(p => {
|
||||
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
||||
const pstage = projectStage(p.settings)
|
||||
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
|
||||
return (
|
||||
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
|
||||
{p.name}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function TopBar({ compact = false, activePane = 'conversation', showRetur
|
||||
<Icon name="message" size={15} /><span>对话</span>
|
||||
</button>
|
||||
</>}
|
||||
<button className="shell-nav-button" aria-label="打开工作"
|
||||
{(compact || ui.rightCollapsed) && <><button className="shell-nav-button" aria-label="打开工作"
|
||||
aria-pressed={compact ? activePane === 'work' : !ui.rightCollapsed && ui.rightTab === 'work'}
|
||||
title={pending > 0 ? `${pending} 项待处理` : '工作'} onClick={onWork}>
|
||||
<Icon name="inbox" size={15} /><span>工作</span>
|
||||
@@ -45,7 +45,7 @@ export function TopBar({ compact = false, activePane = 'conversation', showRetur
|
||||
<button className="shell-nav-button" aria-label="打开文件"
|
||||
aria-pressed={compact ? activePane === 'files' : !ui.rightCollapsed && ui.rightTab === 'files'} onClick={onFiles}>
|
||||
<Icon name="folder" size={15} /><span>文件</span>
|
||||
</button>
|
||||
</button></>}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { SafeMarkdown } from '@ui'
|
||||
import type { StreamEvent } from '../contracts'
|
||||
import { loadRunEvents } from '../runEvents'
|
||||
import { store, useStore } from '../store'
|
||||
import { runEventsToTimeline } from '../live'
|
||||
import { mergeProcessTimeline } from '../messageparts'
|
||||
import { ProcessTimeline } from './ProcessTimeline'
|
||||
import { MessageTimestamp } from './MessageTimestamp'
|
||||
|
||||
/** The work pane and inline fallback share one selection. Only this detail host
|
||||
* reads that run's authorized durable events; opening a record never adds chat. */
|
||||
export function WorkExecutionRecord() {
|
||||
const selected = useStore(s => s.workRecord)
|
||||
const conversationId = useStore(s => s.currentConvId)
|
||||
const messages = useStore(s => s.messages)
|
||||
const run = useStore(s => s.runs.find(r => r.id === selected?.runId))
|
||||
const [result, setResult] = useState<{ runId: string; events: StreamEvent[] } | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!selected || selected.conversationId !== conversationId) return
|
||||
const controller = new AbortController()
|
||||
let active = true
|
||||
setResult(null); setFailed(false)
|
||||
void (async () => {
|
||||
try {
|
||||
const events = await loadRunEvents(selected.conversationId, selected.runId, controller.signal)
|
||||
if (active) setResult({ runId: selected.runId, events })
|
||||
} catch { if (active) setFailed(true) }
|
||||
})()
|
||||
return () => { active = false; controller.abort() }
|
||||
}, [selected?.runId, selected?.conversationId, conversationId, run?.status, run?.endedAt])
|
||||
const parts = useMemo(() => {
|
||||
if (!selected || result?.runId !== selected.runId) return []
|
||||
const saved = messages.filter(message => message.runId === selected.runId && message.role === 'assistant')
|
||||
.flatMap(message => message.parts).map(part => ({ ...part, process: true }))
|
||||
return mergeProcessTimeline(saved, runEventsToTimeline(result.events, selected.runId, true), [])
|
||||
}, [messages, result, selected?.runId])
|
||||
if (!selected || selected.conversationId !== conversationId) return null
|
||||
return <section className="work-execution-record" aria-label={`${selected.title}的执行记录`}>
|
||||
<div className="work-record-heading"><strong>{selected.title} · 执行记录</strong>
|
||||
<button type="button" className="btn ghost" onClick={() => store.closeWorkRecord()}>返回工作</button></div>
|
||||
<MessageTimestamp value={run?.createdAt} />
|
||||
{failed ? <p role="status">完整记录暂不可用,请稍后重新打开</p>
|
||||
: !result ? <p role="status">正在读取已保存的记录…</p>
|
||||
: parts.length === 0 ? <p>这项工作没有已保存的过程输出</p>
|
||||
: <ProcessTimeline parts={parts} live={false} idKey={`work-record:${selected.runId}`}
|
||||
renderPart={part => <SafeMarkdown className="part-text" markdown={part.text ?? ''} breaks />} />}
|
||||
</section>
|
||||
}
|
||||
@@ -2,13 +2,14 @@
|
||||
// 工作 = 当前任务卡 + 收件箱(待处理事项、后台任务卡、执行记录);
|
||||
// 文件 = 文件列表(搜索/上传)与多标签预览(预览、编辑、下载)——预览不再是独立第四栏。
|
||||
import { store, useStore } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import type { Run, WorkCardAction } from '../contracts'
|
||||
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
|
||||
import { Icon, SurfaceView } from '@ui'
|
||||
import { previewTabsKey } from '../previewtabs'
|
||||
import { surfaceKey } from '../surfaceKey'
|
||||
import { ArtifactsPanel } from './ArtifactsPanel'
|
||||
import { PreviewPane } from './PreviewPane'
|
||||
import { WorkExecutionRecord } from './WorkExecutionRecord'
|
||||
|
||||
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
|
||||
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
|
||||
@@ -21,7 +22,9 @@ export const docBridge: DocBridge = {
|
||||
}
|
||||
|
||||
const inboxCallbacks = {
|
||||
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
|
||||
onAction: (c: ActivityCard, id: string, payload?: WorkCardAction) => store.cardAction(c, id, payload),
|
||||
onOpenRecord: (c: ActivityCard) => store.openWorkRecord(c),
|
||||
onReference: (c: ActivityCard) => store.referenceWork(c),
|
||||
actionState: (c: ActivityCard) => store.getState().cardActionStates[c.id],
|
||||
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
|
||||
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
|
||||
@@ -64,6 +67,7 @@ export function Workbench() {
|
||||
const filter = useStore(s => s.ui.inboxFilter)
|
||||
const visible = inboxScope === 'project' ? projectCards : cards
|
||||
const pending = pendingCount(visible)
|
||||
const unread = visible.filter(card => card.work && card.work.resultVersion > card.work.readVersion).length
|
||||
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
|
||||
const switchTab = (tab: 'work' | 'files') =>
|
||||
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
|
||||
@@ -71,7 +75,7 @@ export function Workbench() {
|
||||
<>
|
||||
<div className="tabs">
|
||||
<button className={`tab ${rightTab === 'work' ? 'active' : ''}`} onClick={() => switchTab('work')}
|
||||
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}</button>
|
||||
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}{unread > 0 ? ` · ${unread} 项新结果` : ''}</button>
|
||||
<button className={`tab ${rightTab === 'files' ? 'active' : ''}`} onClick={() => switchTab('files')}
|
||||
title="文件搜索、预览、编辑与下载">文件</button>
|
||||
<span className="spacer" style={{ flex: 1 }} />
|
||||
@@ -108,10 +112,12 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
||||
projectCards: ActivityCard[]
|
||||
cards: ActivityCard[]
|
||||
}) {
|
||||
const record = useStore(s => s.workRecord)
|
||||
const showCurrentTask = !hasActiveProgressCard(cards)
|
||||
return (
|
||||
<>
|
||||
<div className="tab-body" data-scroll-sync="inbox">
|
||||
<div className="tab-body" data-work-scroll>
|
||||
{record ? <WorkExecutionRecord /> : <>
|
||||
{showCurrentTask ? <CurrentTaskCard plan={plan} todos={todos} /> : null}
|
||||
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
|
||||
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
|
||||
@@ -126,6 +132,7 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
||||
? <ProjectInbox cards={projectCards} filter={filter} />
|
||||
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
|
||||
<WorkspaceSurfaces />
|
||||
</>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -8,12 +8,13 @@ import { docBridge } from './Workbench'
|
||||
import { AgentRow } from './Workbench'
|
||||
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
import { DocumentCockpit } from './DocumentCockpit'
|
||||
|
||||
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。
|
||||
* 追溯审计按命令面能力显隐(增量 2):store.rdmAuditAvailable 为 true 才入列。 */
|
||||
export function workspaceEntries(): WorkspaceEntry[] {
|
||||
const s = store.getState()
|
||||
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable })
|
||||
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable, rdmCockpit: s.rdmCockpitAvailable })
|
||||
}
|
||||
|
||||
export function Workspaces() {
|
||||
@@ -36,6 +37,11 @@ export function Workspaces() {
|
||||
<RdmAuditWorkspace />
|
||||
</OverlayHost>
|
||||
}
|
||||
if (active === 'rdm-cockpit') {
|
||||
return <OverlayHost title="文档驾驶舱" icon="📋" shortcut={entry?.shortcut} sayHint="打开文档驾驶舱" onClose={close}>
|
||||
<DocumentCockpit />
|
||||
</OverlayHost>
|
||||
}
|
||||
if (active.startsWith('plugin:')) {
|
||||
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
|
||||
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
|
||||
|
||||
+266
-180
@@ -1,18 +1,19 @@
|
||||
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 /
|
||||
// 文档职责 · 整文审签」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
|
||||
// 文档职责」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
|
||||
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
|
||||
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
|
||||
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../contracts'
|
||||
import { projectWorkError } from '../projectmeta'
|
||||
import { useStore } from '../store'
|
||||
import { Btn, EmptyState, Icon } from '@ui'
|
||||
import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui'
|
||||
import {
|
||||
ROLE_ACTIONS, parseReviewers,
|
||||
ROLE_ACTIONS,
|
||||
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
|
||||
memberAddPayload, memberRemovePayload,
|
||||
assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload,
|
||||
docFinalPayload, docStatusPayload, permCheckPayload, describeSource,
|
||||
memberAddPayload, memberRemovePayload, memberSetRolePayload,
|
||||
permCheckPayload, describeSource,
|
||||
rolesListPlaceholder, describeRoleListError,
|
||||
} from '../rdmManagement'
|
||||
|
||||
@@ -144,25 +145,54 @@ export function RdmRolesPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 项目设置:成员 ----
|
||||
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||||
|
||||
interface MemberRow { owner: string; role: string; addedBy: string; addedAt: string }
|
||||
interface MemberRow {
|
||||
owner: string; role: string; addedBy: string; addedAt: string
|
||||
profRole?: { roleId: string; name: string } | null
|
||||
}
|
||||
|
||||
export function RdmMembersSection({ projectId }: { projectId: string }) {
|
||||
export function RdmMembersSection({ project }: { project: Project }) {
|
||||
const projectId = project.id
|
||||
const writeError = projectWorkError(project)
|
||||
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||||
const [rows, setRows] = useState<MemberRow[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [member, setMember] = useState('')
|
||||
const [picked, setPicked] = useState<string[]>([])
|
||||
const [role, setRole] = useState<'member' | 'owner'>('member')
|
||||
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
|
||||
const rowsRef = useRef<MemberRow[] | null>(null)
|
||||
rowsRef.current = rows
|
||||
|
||||
/** 人员选择器取数适配(P7-A):rdm.account.list 目录(member.manage 生效者)。
|
||||
* query 透传后端(基座升级 AccountSearcher 后全量生效);旧基座忽略 query 时
|
||||
* 对首页做本地包含匹配降级(翻页为浏览模式,不过滤)。 */
|
||||
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
|
||||
const out = await cmd<{ accounts?: { owner: string; name?: string; valid: boolean }[]; next?: string }>(
|
||||
'rdm.account.list', { projectId, cursor, limit, query })
|
||||
const joined = new Set((rowsRef.current ?? []).map(m => m.owner))
|
||||
let items = (out.accounts ?? []).filter(a => a.valid).map(a => ({
|
||||
id: a.owner,
|
||||
title: a.name || a.owner,
|
||||
subtitle: a.owner,
|
||||
badge: joined.has(a.owner) ? '已加入' : undefined,
|
||||
}))
|
||||
if (query !== '' && cursor === '') {
|
||||
const q = query.toLowerCase()
|
||||
items = items.filter(it => it.id.toLowerCase().includes(q) || it.title.toLowerCase().includes(q))
|
||||
}
|
||||
return { items, nextCursor: out.next || undefined }
|
||||
}, [project])
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const out = await api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||||
const out = await cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||||
setRows(out.members ?? [])
|
||||
} catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [projectId])
|
||||
useEffect(() => { setPicked([]); refresh() }, [project])
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
@@ -170,40 +200,57 @@ export function RdmMembersSection({ projectId }: { projectId: string }) {
|
||||
catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const pickedId = picked[0] ?? ''
|
||||
|
||||
return (
|
||||
<div>
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="成员账号(owner)" value={member}
|
||||
onChange={e => setMember(e.target.value)} />
|
||||
<Picker
|
||||
aria-label="选择人员"
|
||||
selected={picked}
|
||||
onSelect={setPicked}
|
||||
fetchPage={fetchPage}
|
||||
placeholder="输入账号或姓名,或浏览候选…"
|
||||
/>
|
||||
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||
<option value="member">member(成员)</option>
|
||||
<option value="owner">owner(负责人)</option>
|
||||
<option value="member">项目成员</option>
|
||||
<option value="owner">项目负责人</option>
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !member.trim()}
|
||||
onClick={() => run(`已添加 ${member.trim()}`, async () => {
|
||||
await api.rdmCommand('rdm.project.member.add', memberAddPayload(projectId, member.trim(), role))
|
||||
setMember('')
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !pickedId}
|
||||
onClick={() => run(`已添加 ${pickedId}`, async () => {
|
||||
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedId, role))
|
||||
setPicked([])
|
||||
})}>添加成员</button>
|
||||
</div>
|
||||
<p className="ad-hint">加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。</p>
|
||||
<p className="ad-hint">候选来自系统账号目录(姓名为空时显示账号);「已加入」标记避免重复添加造成隐式变更——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
||||
</div>
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>账号</th><th>项目角色</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(rows ?? []).map(m => (
|
||||
<tr key={m.owner}>
|
||||
<td className="ad-mono">{m.owner}</td>
|
||||
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">OWNER</span> : <span className="ad-chip">MEMBER</span>}</td>
|
||||
<td><span className="ad-mono">{m.owner}</span>{m.profRole?.name ? <span className="ad-dim"> · {m.profRole.name}</span> : null}</td>
|
||||
<td className="ad-dim">{m.profRole?.name || '—'}</td>
|
||||
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
|
||||
<td className="ad-dim">{m.addedBy || '—'}</td>
|
||||
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
|
||||
<td><button className="ad-mini" disabled={busy}
|
||||
onClick={() => run(`已移除 ${m.owner}`,
|
||||
() => api.rdmCommand('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button></td>
|
||||
<td>
|
||||
<button className="ad-mini" disabled={busy || !!writeError}
|
||||
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
|
||||
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
|
||||
cmd('rdm.project.member.set-role', memberSetRolePayload(projectId, m.owner, m.role === 'owner' ? 'member' : 'owner')))}>
|
||||
{m.role === 'owner' ? '降为成员' : '升为负责人'}
|
||||
</button>{' '}
|
||||
<button className="ad-mini" disabled={busy || !!writeError}
|
||||
onClick={() => run(`已移除 ${m.owner}`,
|
||||
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={5} className="ad-dim">暂无成员</td></tr>}
|
||||
{rows === null && <tr><td colSpan={5} className="ad-dim">加载中…</td></tr>}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无成员</td></tr>}
|
||||
{rows === null && <tr><td colSpan={6} className="ad-dim">加载中…</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
@@ -212,168 +259,72 @@ export function RdmMembersSection({ projectId }: { projectId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 项目设置:文档职责 · 整文审签 ----
|
||||
export { RdmDutiesSection } from './duties/RdmDutiesSection'
|
||||
|
||||
interface DocSubmissionView {
|
||||
round: number
|
||||
state: string
|
||||
digest: string
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
duties: { author: string; reviewers: string[]; approver: string }
|
||||
reviews: Record<string, boolean>
|
||||
sections: Record<string, number>
|
||||
// ---- 项目设置:项目权限(P7-C:单成员详情标签化 + 多人矩阵 + 三态覆盖;
|
||||
// 与实际判定同源——UI 不重算,全部读服务端解释) ----
|
||||
|
||||
interface MatrixAction {
|
||||
action: string; label?: string; scope?: string
|
||||
allowed: boolean; source: string; reason?: string
|
||||
}
|
||||
|
||||
export function RdmDutiesSection({ projectId }: { projectId: string }) {
|
||||
const [docId, setDocId] = useState('')
|
||||
const [sub, setSub] = useState<DocSubmissionView | null | undefined>(undefined)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [author, setAuthor] = useState('')
|
||||
const [reviewers, setReviewers] = useState('')
|
||||
const [approver, setApprover] = useState('')
|
||||
|
||||
const cmd = <T,>(id: string, params: Record<string, unknown>) => api.rdmCommand<T>(id, params)
|
||||
|
||||
const refreshStatus = async (id: string) => {
|
||||
if (!id) return
|
||||
try {
|
||||
const out = await cmd<{ submission: DocSubmissionView | null }>('rdm.review.doc.status', docStatusPayload(projectId, id))
|
||||
setSub(out.submission)
|
||||
} catch (e) { setErr(String(e)); setSub(undefined) }
|
||||
}
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try { await fn(); setNotice(label); await refreshStatus(docId) }
|
||||
catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
|
||||
onChange={e => setDocId(e.target.value)} />
|
||||
<Btn variant="ghost" icon="eye" disabled={busy || !docId}
|
||||
onClick={() => refreshStatus(docId)}>查看整文状态</Btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sub === null && <EmptyState icon="inbox">该文档尚无整文提交记录</EmptyState>}
|
||||
{sub && (
|
||||
<div className="settings-group">
|
||||
<h4>第 {sub.round} 轮 · {sub.state === 'submitted' ? <span className="ad-chip ad-chip-warn">审签在途(全文锁定)</span> : sub.state === 'final' ? <span className="ad-chip ad-chip-admin">已终批</span> : <span className="ad-chip">已驳回</span>}</h4>
|
||||
<div className="setting-row"><label>三权</label><span>编制 <b className="ad-mono">{sub.duties.author}</b> · 审核 <b className="ad-mono">{(sub.duties.reviewers ?? []).join('、')}</b> · 批准 <b className="ad-mono">{sub.duties.approver}</b></span></div>
|
||||
<div className="setting-row"><label>审核进度</label><span>{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}</span></div>
|
||||
<div className="setting-row"><label>冻结摘要</label><span className="ad-mono ad-dim">{(sub.digest ?? '').slice(0, 16)}…</span></div>
|
||||
<div className="ad-row" style={{ marginTop: 8 }}>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || sub.state !== 'submitted'}
|
||||
onClick={() => run('整文终批完成(各章节已基线)',
|
||||
() => cmd('rdm.review.doc.final', docFinalPayload(projectId, docId)))}>终批(批准人)</button>
|
||||
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
onClick={() => run('已通过整文审核',
|
||||
() => cmd('rdm.review.doc.approve', docApprovePayload(projectId, docId, '通过')))}>审核通过</button>
|
||||
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
onClick={() => run('已驳回整文(解锁回编写态)',
|
||||
() => cmd('rdm.review.doc.reject', docRejectPayload(projectId, docId, '退回修改')))}>驳回</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sub === undefined && docId && <p className="ad-hint">输入文档 ID 后查看整文状态。</p>}
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>分配文档三权(整文)</h4>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
|
||||
onChange={e => setDocId(e.target.value)} />
|
||||
<input className="lg-input lg-mono" placeholder="编制人(author)" value={author}
|
||||
onChange={e => setAuthor(e.target.value)} />
|
||||
<input className="lg-input lg-mono" placeholder="审核人(逗号分隔,≥1 且异于编制/批准)" value={reviewers}
|
||||
onChange={e => setReviewers(e.target.value)} />
|
||||
<input className="lg-input lg-mono" placeholder="批准人(approver)" value={approver}
|
||||
onChange={e => setApprover(e.target.value)} />
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<button className="lg-submit ad-create-btn"
|
||||
disabled={busy || !docId || !author.trim() || !approver.trim() || parseReviewers(reviewers).length === 0}
|
||||
onClick={() => run('已分配文档三权', () => cmd('rdm.review.assign.doc',
|
||||
assignDocDutiesPayload(projectId, docId, author.trim(), parseReviewers(reviewers), approver.trim())))}>分配三权</button>
|
||||
<button className="ad-mini" disabled={busy || !docId}
|
||||
onClick={() => run('整文已提交审核(全文锁定)',
|
||||
() => cmd('rdm.review.doc.submit', docSubmitPayload(projectId, docId)))}>整文提交(编制人)</button>
|
||||
</div>
|
||||
<p className="ad-hint">三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。</p>
|
||||
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ---- 项目设置:项目权限(矩阵解释读面,与实际判定同源——UI 不重算) ----
|
||||
|
||||
interface MatrixAction { action: string; allowed: boolean; source: string; reason?: string }
|
||||
interface MatrixOut {
|
||||
projectId: string; member: string; role: string
|
||||
profRole: { roleId: string; name: string } | null
|
||||
actions: MatrixAction[]
|
||||
}
|
||||
interface MatrixRowOut {
|
||||
member: string; role: string
|
||||
profRole: { roleId: string; name: string } | null
|
||||
actions: MatrixAction[]
|
||||
}
|
||||
interface MatrixBatchOut { projectId: string; rows: MatrixRowOut[] }
|
||||
|
||||
export function RdmPermMatrixSection({ projectId }: { projectId: string }) {
|
||||
const me = useStore(s => s.me)
|
||||
const [member, setMember] = useState('')
|
||||
const [members, setMembers] = useState<MemberRow[] | null>(null)
|
||||
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||||
.then(out => {
|
||||
setMembers(out.members ?? [])
|
||||
// 默认自省:普通成员只看自己的矩阵;负责人/管理员可切换他人。
|
||||
if (me?.owner) setMember(prev => prev || me.owner)
|
||||
})
|
||||
.catch(e => setErr(String(e)))
|
||||
}, [projectId, me?.owner])
|
||||
|
||||
const query = async (m: string) => {
|
||||
if (!m) return
|
||||
setErr(''); setBusy(true); setMatrix(null)
|
||||
try {
|
||||
setMatrix(await api.rdmCommand<MatrixOut>('rdm.perm.check', permCheckPayload(projectId, m)))
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
function actionTitle(a: MatrixAction) { return a.label || a.action }
|
||||
|
||||
/** 动作行:中文标签为主,内部标识进 title 技术详情(方案 §5)。 */
|
||||
function ActionDetailTable({ actions, onOverride, canManage }: {
|
||||
actions: MatrixAction[]
|
||||
onOverride?: (action: string, mode: 'allow' | 'deny' | 'default') => void
|
||||
canManage?: boolean
|
||||
}) {
|
||||
const project = actions.filter(a => a.scope !== 'system')
|
||||
const system = actions.filter(a => a.scope === 'system')
|
||||
const overrideOf = (a: MatrixAction): 'allow' | 'deny' | 'default' =>
|
||||
a.source.includes('override') || a.source === 'project' ? (a.allowed ? 'allow' : 'deny') : 'default'
|
||||
return (
|
||||
<div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<select className="lg-input" value={member} onChange={e => { setMember(e.target.value); query(e.target.value) }}>
|
||||
<option value="">选择成员(默认自省)…</option>
|
||||
{(members ?? []).map(m => <option key={m.owner} value={m.owner}>{m.owner}</option>)}
|
||||
</select>
|
||||
<Btn variant="ghost" icon="eye" disabled={busy || !member} onClick={() => query(member)}>查看矩阵</Btn>
|
||||
</div>
|
||||
<p className="ad-hint">普通成员可查看自己的有效权限;查看他人矩阵须项目管理权。矩阵与实际判定同源(服务端解释,非前端推算)。</p>
|
||||
</div>
|
||||
{matrix && (
|
||||
<>
|
||||
<div className="ad-row">
|
||||
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目关系 <b>{matrix.role}</b>
|
||||
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b>({matrix.profRole.roleId})</>}
|
||||
</span>
|
||||
</div>
|
||||
<>
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因(拒绝时)</th>{canManage ? <th>项目覆盖</th> : null}</tr></thead>
|
||||
<tbody>
|
||||
{project.map(a => (
|
||||
<tr key={a.action}>
|
||||
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</td>
|
||||
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
|
||||
<td className="ad-dim">{describeSource(a.source)}</td>
|
||||
<td className="ad-dim">{a.reason || '—'}</td>
|
||||
{canManage ? (
|
||||
<td>
|
||||
<select className="lg-input" value={overrideOf(a)} style={{ minWidth: 120 }}
|
||||
onChange={e => onOverride?.(a.action, e.target.value as 'allow' | 'deny' | 'default')}>
|
||||
<option value="default">沿用角色默认</option>
|
||||
<option value="allow">项目内允许</option>
|
||||
<option value="deny">项目内禁止</option>
|
||||
</select>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{system.length > 0 && (
|
||||
<Collapsible trigger={`系统范围动作(${system.length} 项,不在项目内配置)`}>
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因(拒绝时)</th></tr></thead>
|
||||
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因</th></tr></thead>
|
||||
<tbody>
|
||||
{(matrix.actions ?? []).map(a => (
|
||||
{system.map(a => (
|
||||
<tr key={a.action}>
|
||||
<td className="ad-mono">{a.action}</td>
|
||||
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</td>
|
||||
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
|
||||
<td className="ad-dim">{describeSource(a.source)}</td>
|
||||
<td className="ad-dim">{a.reason || '—'}</td>
|
||||
@@ -381,9 +332,144 @@ export function RdmPermMatrixSection({ projectId }: { projectId: string }) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Collapsible>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function RdmPermMatrixSection({ project }: { project: Project }) {
|
||||
const projectId = project.id
|
||||
const writeError = projectWorkError(project)
|
||||
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||||
const me = useStore(s => s.me)
|
||||
const [view, setView] = useState<'member' | 'matrix'>('member')
|
||||
const [member, setMember] = useState('')
|
||||
const [members, setMembers] = useState<MemberRow[] | null>(null)
|
||||
const [matrix, setMatrix] = useState<MatrixOut | null>(null)
|
||||
const [batch, setBatch] = useState<MatrixBatchOut | null>(null)
|
||||
const [expand, setExpand] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const loadMembers = () =>
|
||||
cmd<{ members: MemberRow[] }>('rdm.project.member.list', { projectId })
|
||||
.then(out => {
|
||||
setMembers(out.members ?? [])
|
||||
if (me?.owner) setMember(prev => prev || me.owner)
|
||||
}).catch(e => setErr(String(e)))
|
||||
useEffect(() => { loadMembers() }, [project, me?.owner]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const query = async (m: string) => {
|
||||
if (!m) return
|
||||
setErr(''); setBusy(true); setMatrix(null)
|
||||
try {
|
||||
setMatrix(await cmd<MatrixOut>('rdm.perm.check', permCheckPayload(projectId, m)))
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
const queryBatch = async () => {
|
||||
setErr(''); setBusy(true); setBatch(null)
|
||||
try {
|
||||
setBatch(await cmd<MatrixBatchOut>('rdm.perm.matrix', { projectId }))
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
useEffect(() => { if (view === 'matrix') queryBatch() }, [view]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/** 三态覆盖写回:default=实际撤销(perm.unset);allow/deny=perm.set。 */
|
||||
const override = async (m: string, action: string, mode: 'allow' | 'deny' | 'default') => {
|
||||
if (writeError) { setErr(writeError); return }
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
if (mode === 'default') {
|
||||
await cmd('rdm.perm.unset', { projectId, member: m, action })
|
||||
setNotice(`已恢复默认(撤销 ${action} 的项目覆盖)`)
|
||||
} else {
|
||||
await cmd('rdm.perm.set', { projectId, member: m, action, allow: mode === 'allow' })
|
||||
setNotice(mode === 'allow' ? `已在项目内允许 ${action}` : `已在项目内禁止 ${action}`)
|
||||
}
|
||||
await query(m)
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const canManage = !writeError && !!matrix && matrix.role !== undefined
|
||||
|
||||
return (
|
||||
<div>
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<select className="lg-input" value={view} onChange={e => setView(e.target.value as 'member' | 'matrix')}>
|
||||
<option value="member">按成员查看(默认自省)</option>
|
||||
<option value="matrix">权限矩阵(成员 × 动作,须管理权)</option>
|
||||
</select>
|
||||
{view === 'member' && (
|
||||
<>
|
||||
<select className="lg-input" value={member} onChange={e => { setMember(e.target.value); query(e.target.value) }}>
|
||||
<option value="">选择成员…</option>
|
||||
{(members ?? []).map(m => <option key={m.owner} value={m.owner}>{m.owner}</option>)}
|
||||
</select>
|
||||
<Btn variant="ghost" icon="eye" disabled={busy || !member} onClick={() => query(member)}>查看</Btn>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="ad-hint">普通成员可查看自己的有效权限;查看他人与矩阵须项目管理权。解释与实际判定同源(服务端,非前端推算);系统范围动作折叠为技术详情。</p>
|
||||
</div>
|
||||
|
||||
{view === 'member' && matrix && (
|
||||
<>
|
||||
<div className="ad-row">
|
||||
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目身份 <b>{matrix.role === 'owner' ? '项目负责人' : matrix.role === 'member' ? '项目成员' : matrix.role}</b>
|
||||
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b></>}
|
||||
</span>
|
||||
</div>
|
||||
<ActionDetailTable actions={matrix.actions ?? []}
|
||||
canManage={canManage}
|
||||
onOverride={(action, mode) => override(matrix.member, action, mode)} />
|
||||
</>
|
||||
)}
|
||||
{!matrix && !err && members !== null && <p className="ad-hint">选择成员后展示 12 项动作的有效权限与第一拒绝原因。</p>}
|
||||
{view === 'member' && !matrix && !err && members !== null && <p className="ad-hint">选择成员后展示各动作的有效权限、来源与第一拒绝原因。</p>}
|
||||
|
||||
{view === 'matrix' && batch && (
|
||||
<table className="ad-table" style={{ overflowX: 'auto', display: 'block' }}>
|
||||
<thead><tr>
|
||||
<th>成员</th>
|
||||
{(batch.rows[0]?.actions ?? []).filter(a => a.scope !== 'system').map(a => (
|
||||
<th key={a.action} title={`内部标识 ${a.action}`}>{actionTitle(a)}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{batch.rows.map(row => (
|
||||
<tr key={row.member}>
|
||||
<td>
|
||||
<button className="ad-mini" onClick={() => setExpand(expand === row.member ? '' : row.member)}>
|
||||
{row.member}
|
||||
</button>
|
||||
<span className="ad-dim"> · {row.role === 'owner' ? '负责人' : '成员'}</span>
|
||||
</td>
|
||||
{row.actions.filter(a => a.scope !== 'system').map(a => (
|
||||
<td key={a.action} title={`${a.label ?? a.action}:${a.allowed ? '允许' : '拒绝'}(${describeSource(a.source)}${a.reason ? ';' + a.reason : ''})`}>
|
||||
{a.allowed ? <span className="ad-chip ad-chip-admin">✓</span> : <span className="ad-chip ad-chip-warn">✗</span>}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{view === 'matrix' && batch && expand && (
|
||||
<div className="settings-group" style={{ marginTop: 12 }}>
|
||||
<h4>{expand} · 单成员详情与三态覆盖</h4>
|
||||
{(() => {
|
||||
const row = batch.rows.find(r => r.member === expand)
|
||||
if (!row) return null
|
||||
return <ActionDetailTable actions={row.actions} canManage={!writeError}
|
||||
onOverride={(action, mode) => override(expand, action, mode)} />
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,26 +2,20 @@
|
||||
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
|
||||
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { api, type RecoveryProposal } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import type { AgentProfile } from '../contracts'
|
||||
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
|
||||
import { SettingInput, flatten, unflatten } from './SettingsModal'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { docBridge } from './Workbench'
|
||||
import { randomPassword } from '../passwords'
|
||||
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery'
|
||||
|
||||
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制 + 2026-10-13 恢复 Key)----
|
||||
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
|
||||
interface LoginKeyRow { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }
|
||||
|
||||
/** Key 状态徽标:复用既有 chip 体系,不新增颜色。 */
|
||||
function keyStatusChip(status: string) {
|
||||
if (status === 'unused') return <span className="ad-chip ad-chip-warn">待使用</span>
|
||||
if (status === 'consumed') return <span className="ad-chip">已使用</span>
|
||||
if (status === 'expired') return <span className="ad-chip">已过期</span>
|
||||
return <span className="ad-chip">已撤销</span>
|
||||
}
|
||||
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制):建号仅创建待初始化
|
||||
// 账号(HTTP 契约不含密码与管理员位),初始凭证由服务器本地 CLI 发放激活 Key;
|
||||
// 恢复走属主确认制提案:管理员提案 → 用户在「设置 → 账号」本人确认 → 一次性 Key
|
||||
// 只在属主会话内出现(直接签发 Key 的 HTTP 入口已被 410 替换)。----
|
||||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||
|
||||
export function UsersPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
@@ -30,60 +24,51 @@ export function UsersPanel() {
|
||||
const [notice, setNotice] = useState('')
|
||||
const [owner, setOwner] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [pw, setPw] = useState('')
|
||||
const [asAdmin, setAsAdmin] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 登录 Key 面板:keyFor 打开指定用户的签发/状态视图;issued 明文仅签发响应持有一次。
|
||||
const [keyFor, setKeyFor] = useState<string | null>(null)
|
||||
const [issued, setIssued] = useState<{ key: string; expiresAt: string } | null>(null)
|
||||
const [keys, setKeys] = useState<LoginKeyRow[] | null>(null)
|
||||
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
|
||||
const [propFor, setPropFor] = useState<AdminUserRow | null>(null)
|
||||
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
|
||||
}
|
||||
const refreshKeys = async (owner: string) => {
|
||||
try { setKeys(await api.adminListLoginKeys(owner)) } catch (e) { setErr(String(e)) }
|
||||
const refreshProposals = async (userId: string) => {
|
||||
try { setProposals(await api.listAdminRecoveryProposals(userId)) } catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
const openKeyPanel = async (owner: string) => {
|
||||
setErr(''); setNotice(''); setIssued(null); setKeyFor(owner); setKeys(null)
|
||||
await refreshKeys(owner)
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
setErr(''); setNotice('')
|
||||
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
|
||||
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
|
||||
setOwner(''); setName(''); setPw('')
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim())
|
||||
setNotice(`已创建 ${out.owner}(待初始化):请在服务器执行 user initialize-account 发放激活 Key`)
|
||||
setOwner(''); setName('')
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const issue = async () => {
|
||||
if (!keyFor) return
|
||||
const openProposals = async (u: AdminUserRow) => {
|
||||
setErr(''); setNotice(''); setPropFor(u); setProposals(null)
|
||||
await refreshProposals(u.userId)
|
||||
}
|
||||
|
||||
const propose = async () => {
|
||||
if (!propFor) return
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
const out = await api.adminIssueLoginKey(keyFor)
|
||||
setIssued({ key: out.key, expiresAt: out.expiresAt })
|
||||
await refreshKeys(keyFor)
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID())
|
||||
setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`)
|
||||
await refreshProposals(propFor.userId)
|
||||
} catch (e) { setErr(recoveryProposalErrorMessage(String(e))) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const copyKey = async () => {
|
||||
if (!issued) return
|
||||
try { await navigator.clipboard.writeText(issued.key); setNotice('已复制到剪贴板,请安全交付给该用户') } catch { setErr('复制失败,请手动选择复制') }
|
||||
}
|
||||
|
||||
const revoke = async (id: string) => {
|
||||
if (!keyFor) return
|
||||
const revokeProposal = async (p: RecoveryProposal) => {
|
||||
if (!propFor) return
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
await api.adminRevokeLoginKey(keyFor, id)
|
||||
await refreshKeys(keyFor)
|
||||
await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version)
|
||||
await refreshProposals(propFor.userId)
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
@@ -94,48 +79,40 @@ export function UsersPanel() {
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
|
||||
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
|
||||
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
|
||||
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
|
||||
</div>
|
||||
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码;忘记密码时为其生成一次性登录 Key。</p>
|
||||
<p className="ad-hint">账号创建后处于待初始化状态:在服务器执行 user initialize-account 发放一次性激活 Key,用户经兑换入口设置密码后登录</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
{keyFor ? (
|
||||
{propFor ? (
|
||||
<div className="ad-reset">
|
||||
<div className="ad-row">
|
||||
<span><b>{keyFor}</b> 的一次性登录 Key(忘记密码恢复)</span>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={issue}>{issued ? '重新签发(旧 Key 与受限会话立即失效)' : '生成 Key'}</button>
|
||||
<button type="button" className="ad-mini" onClick={() => { setKeyFor(null); setIssued(null); setKeys(null) }}>关闭</button>
|
||||
<span><b>{propFor.owner}</b> 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key)</span>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={propose}>发起恢复提案</button>
|
||||
<button type="button" className="ad-mini" onClick={() => { setPropFor(null); setProposals(null) }}>关闭</button>
|
||||
</div>
|
||||
{issued && (
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" readOnly value={issued.key} aria-label="一次性登录 Key(仅显示一次)" onFocus={e => e.currentTarget.select()} />
|
||||
<button type="button" className="ad-mini" onClick={copyKey}>复制</button>
|
||||
</div>
|
||||
)}
|
||||
{issued && <p className="ad-hint">明文仅此一次显示,关闭后不可再取;单次有效,{new Date(issued.expiresAt).toLocaleString()} 过期。请经安全渠道交付本人。</p>}
|
||||
{keys !== null && keys.length > 0 && (
|
||||
<p className="ad-hint">前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。</p>
|
||||
{proposals !== null && proposals.length > 0 && (
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>签发时间</th><th>用途</th><th>状态</th><th>过期时间</th><th></th></tr></thead>
|
||||
<thead><tr><th>提案</th><th>状态</th><th>到期时间</th><th>发起人</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{keys.map(k => (
|
||||
<tr key={k.id}>
|
||||
<td className="ad-dim">{new Date(k.createdAt).toLocaleString()}</td>
|
||||
<td>{k.purpose === 'recover' ? '恢复' : '激活'}</td>
|
||||
<td>{keyStatusChip(k.status)}</td>
|
||||
<td className="ad-dim">{new Date(k.expiresAt).toLocaleString()}</td>
|
||||
<td>{k.status === 'unused' && <button className="ad-mini" disabled={busy} onClick={() => revoke(k.id)}>撤销</button>}</td>
|
||||
{proposals.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td className="ad-mono">{p.id}</td>
|
||||
<td>{recoveryStatusLabel[p.status]}{recoveryProposalHint(p.status) && <span className="ad-dim"> · {recoveryProposalHint(p.status)}</span>}</td>
|
||||
<td className="ad-dim">{new Date(p.expiresAt).toLocaleString()}</td>
|
||||
<td className="ad-dim">{p.createdBy}</td>
|
||||
<td>{(p.status === 'pending' || p.status === 'confirmed') && (
|
||||
<button className="ad-mini" disabled={busy} onClick={() => revokeProposal(p)}>撤销</button>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{proposals !== null && proposals.length === 0 && <p className="ad-hint">该用户当前没有恢复提案。</p>}
|
||||
</div>
|
||||
) : (
|
||||
<table className="ad-table">
|
||||
@@ -149,7 +126,7 @@ export function UsersPanel() {
|
||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{me?.isAdmin && (
|
||||
<button className="ad-mini" onClick={() => openKeyPanel(u.owner)}>生成登录 Key</button>
|
||||
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../../api'
|
||||
import type { Project } from '../../contracts'
|
||||
import { projectWorkError } from '../../projectmeta'
|
||||
import { Btn, Collapsible, EmptyState, Icon } from '@ui'
|
||||
import { dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload } from '../../rdmManagement'
|
||||
import {
|
||||
absorbServerDocs, dirtyDocs, editCell, savePayload,
|
||||
type CellDuty, type DutyDocDraft, type DutyMember,
|
||||
} from '../../duties/draft'
|
||||
import { DutyByDocumentView, DutyByMemberView, DutyChangePreview, DutyMatrixView } from '../../duties/views'
|
||||
|
||||
type ViewName = 'member' | 'document' | 'matrix'
|
||||
|
||||
interface MatrixDoc {
|
||||
docId: string
|
||||
title: string
|
||||
code?: string
|
||||
typeKey: string
|
||||
stage?: string
|
||||
dutyVersion: number
|
||||
activeRound?: number
|
||||
legacySectionOverrides?: number
|
||||
}
|
||||
interface MatrixOut {
|
||||
canAssign: boolean
|
||||
docs: MatrixDoc[]
|
||||
members: DutyMember[]
|
||||
cells: { docId: string; owner: string; duty: string }[]
|
||||
nextDocCursor?: string
|
||||
nextMemberCursor?: string
|
||||
}
|
||||
interface PreviewOut {
|
||||
ok: boolean
|
||||
docs: {
|
||||
docId: string
|
||||
title?: string
|
||||
replacedAuthor?: string
|
||||
replacedApprover?: string
|
||||
needsConfirm?: boolean
|
||||
reasons?: string[]
|
||||
}[]
|
||||
}
|
||||
interface LegacyOut {
|
||||
findings: { docId: string; title: string; sectionTitle: string; kind: string }[]
|
||||
}
|
||||
|
||||
export function RdmDutiesSection({ project }: { project: Project }) {
|
||||
const projectId = project.id
|
||||
const writeError = projectWorkError(project)
|
||||
const [view, setView] = useState<ViewName>('member')
|
||||
const [docs, setDocs] = useState<DutyDocDraft[]>([])
|
||||
const [members, setMembers] = useState<DutyMember[]>([])
|
||||
const [member, setMember] = useState('')
|
||||
const [docId, setDocId] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [stage, setStage] = useState('')
|
||||
const [typeKey, setTypeKey] = useState('')
|
||||
const [assignment, setAssignment] = useState('')
|
||||
const [docCursor, setDocCursor] = useState('')
|
||||
const [nextDoc, setNextDoc] = useState('')
|
||||
const [canAssign, setCanAssign] = useState(false)
|
||||
const [narrow, setNarrow] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [preview, setPreview] = useState<PreviewOut['docs']>([])
|
||||
const [confirm, setConfirm] = useState(false)
|
||||
const [legacy, setLegacy] = useState<LegacyOut['findings']>([])
|
||||
const [legacyOpen, setLegacyOpen] = useState(false)
|
||||
const seq = useRef(0)
|
||||
const op = useRef(0)
|
||||
const docsRef = useRef(docs)
|
||||
docsRef.current = docs
|
||||
|
||||
const load = async (cursor: string, request = ++seq.current, replaceDirty = false) => {
|
||||
const seenMembers: DutyMember[] = []
|
||||
const seenCells: MatrixOut['cells'] = []
|
||||
let pageDocs: MatrixDoc[] = []
|
||||
let memberCursor = ''
|
||||
let nextDocument = ''
|
||||
let assignable = false
|
||||
try {
|
||||
do {
|
||||
const page = await api.rdmProjectCommand<MatrixOut>(project, 'rdm.duty.matrix', dutyMatrixPayload(projectId, {
|
||||
view: 'matrix', query, stage, typeKey, assignment, docCursor: cursor, memberCursor, docLimit: 10, memberLimit: 20,
|
||||
}))
|
||||
if (request !== seq.current) return
|
||||
if (pageDocs.length === 0) pageDocs = page.docs ?? []
|
||||
seenMembers.push(...(page.members ?? []))
|
||||
seenCells.push(...(page.cells ?? []))
|
||||
assignable = page.canAssign
|
||||
nextDocument = page.nextDocCursor ?? ''
|
||||
memberCursor = page.nextMemberCursor ?? ''
|
||||
} while (memberCursor)
|
||||
if (request !== seq.current) return
|
||||
setMembers(seenMembers)
|
||||
setCanAssign(assignable)
|
||||
setNextDoc(nextDocument)
|
||||
setDocs(current => absorbServerDocs(current, pageDocs, seenCells, replaceDirty))
|
||||
} catch (error) {
|
||||
if (request !== seq.current) return
|
||||
setErr(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(max-width: 720px)')
|
||||
const apply = () => setNarrow(media.matches)
|
||||
apply()
|
||||
media.addEventListener('change', apply)
|
||||
return () => media.removeEventListener('change', apply)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load(docCursor)
|
||||
return () => { seq.current += 1 }
|
||||
}, [project, query, stage, typeKey, assignment, docCursor])
|
||||
|
||||
const onCell = (id: string, owner: string, duty: CellDuty) => {
|
||||
setPreview([])
|
||||
setConfirm(false)
|
||||
setDocs(current => editCell(current, id, owner, duty))
|
||||
}
|
||||
|
||||
const visibleDocs = docs.filter(doc => {
|
||||
const text = `${doc.title} ${doc.code} ${doc.docId}`.toLowerCase()
|
||||
if (query && !text.includes(query.trim().toLowerCase())) return false
|
||||
if (stage && doc.stage !== stage) return false
|
||||
if (typeKey && doc.typeKey !== typeKey) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const save = async () => {
|
||||
const payload = savePayload(docsRef.current, confirm)
|
||||
if (payload.issues.length > 0) {
|
||||
setErr(payload.issues.join(';'))
|
||||
return
|
||||
}
|
||||
const request = ++seq.current
|
||||
const ticket = ++op.current
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
let sent = false
|
||||
try {
|
||||
const looked = await api.rdmProjectCommand<PreviewOut>(project, 'rdm.duty.preview', dutyPreviewPayload(projectId, payload.docs))
|
||||
if (request !== seq.current) return
|
||||
setPreview(looked.docs ?? [])
|
||||
if (!looked.ok) {
|
||||
setErr('预览未通过,未写入')
|
||||
return
|
||||
}
|
||||
if ((looked.docs ?? []).some(item => item.needsConfirm) && !confirm) {
|
||||
setErr('在途审签将失效。确认后再保存。')
|
||||
return
|
||||
}
|
||||
sent = true
|
||||
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm).docs))
|
||||
if (request !== seq.current) return
|
||||
setNotice('已保存文档职责')
|
||||
setPreview([])
|
||||
setConfirm(false)
|
||||
setDocs(current => current.map(doc => ({ ...doc, dirty: false })))
|
||||
await load(docCursor, request)
|
||||
} catch (error) {
|
||||
if (request !== seq.current) return
|
||||
if (!sent) {
|
||||
setErr(String(error))
|
||||
return
|
||||
}
|
||||
setErr(`保存结果未确认:${error}`)
|
||||
setConfirm(false)
|
||||
await load(docCursor, request, true)
|
||||
} finally {
|
||||
if (ticket === op.current) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const scanLegacy = async (request = ++seq.current) => {
|
||||
try {
|
||||
const out = await api.rdmProjectCommand<LegacyOut>(project, 'rdm.duty.legacy.scan', dutyLegacyPayload(projectId))
|
||||
if (request !== seq.current) return
|
||||
setLegacy(out.findings ?? [])
|
||||
} catch (error) {
|
||||
if (request !== seq.current) return
|
||||
setErr(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
const applyIdentical = async () => {
|
||||
const request = ++seq.current
|
||||
const ticket = ++op.current
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.rdmProjectCommand(project, 'rdm.duty.legacy.apply', dutyLegacyPayload(projectId))
|
||||
if (request !== seq.current) return
|
||||
setNotice('已归并无歧义的章节职责')
|
||||
await scanLegacy(request)
|
||||
await load(docCursor, request)
|
||||
} catch (error) {
|
||||
if (request !== seq.current) return
|
||||
setErr(String(error))
|
||||
} finally {
|
||||
if (ticket === op.current) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const disabled = busy || !!writeError || !canAssign
|
||||
const ambiguous = legacy.filter(item => item.kind !== 'identical')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="ad-hint">本页只分配已有文档的职责,不创建或生成文档。</p>
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
<div className="ad-row" role="tablist" aria-label="职责视图">
|
||||
<button className="ad-mini" role="tab" aria-selected={view === 'member'} onClick={() => setView('member')}>按人员</button>
|
||||
<button className="ad-mini" role="tab" aria-selected={view === 'document'} onClick={() => setView('document')}>按文档</button>
|
||||
<button className="ad-mini" role="tab" aria-selected={view === 'matrix'} onClick={() => setView('matrix')}>矩阵</button>
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<input aria-label="筛选文档名称或编号" value={query} placeholder="名称或编号" disabled={busy} onChange={event => { setDocCursor(''); setQuery(event.target.value) }} />
|
||||
<input aria-label="筛选类型" value={typeKey} placeholder="类型" disabled={busy} onChange={event => { setDocCursor(''); setTypeKey(event.target.value) }} />
|
||||
<input aria-label="筛选阶段" value={stage} placeholder="阶段" disabled={busy} onChange={event => { setDocCursor(''); setStage(event.target.value) }} />
|
||||
<select aria-label="筛选当前职责" value={assignment} disabled={busy} onChange={event => { setDocCursor(''); setAssignment(event.target.value) }}>
|
||||
<option value="">全部</option>
|
||||
<option value="assigned">已分配</option>
|
||||
<option value="unassigned">未分配</option>
|
||||
</select>
|
||||
</div>
|
||||
{view === 'member' && <DutyByMemberView member={member} members={members} docs={visibleDocs} disabled={disabled} onMember={setMember} onCell={onCell} />}
|
||||
{view === 'document' && <DutyByDocumentView docId={docId} docs={visibleDocs} members={members} disabled={disabled} onDoc={setDocId} onCell={onCell} />}
|
||||
{view === 'matrix' && <DutyMatrixView docs={visibleDocs} members={members} disabled={disabled} narrow={narrow} onCell={onCell} />}
|
||||
<div className="ad-row">
|
||||
{docCursor && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor('')}>文档首页</button>}
|
||||
{nextDoc && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor(nextDoc)}>下一页文档</button>}
|
||||
<Btn variant="primary" icon="check" disabled={disabled || dirtyDocs(docs).length === 0} onClick={() => { void save() }}>保存职责</Btn>
|
||||
</div>
|
||||
<DutyChangePreview impacts={preview} confirm={confirm} onConfirm={setConfirm} />
|
||||
<Collapsible id="duty-legacy" open={legacyOpen} onToggle={setLegacyOpen} trigger="旧章节职责">
|
||||
<button className="ad-mini" disabled={busy} onClick={() => { void scanLegacy() }}>扫描</button>
|
||||
<button className="ad-mini" disabled={disabled} onClick={() => { void applyIdentical() }}>归并无歧义覆盖</button>
|
||||
{legacy.length === 0 ? <p className="ad-hint">尚未扫描,或没有章节级覆盖。</p> : (
|
||||
<ul>
|
||||
{legacy.map(item => <li key={`${item.docId}-${item.sectionTitle}`}>{item.title} / {item.sectionTitle}:{item.kind === 'identical' ? '与文档职责一致' : '有歧义,需逐文档确认'}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{ambiguous.length > 0 && <p className="ad-hint">有歧义的文档不会自动覆盖。请在上面的视图里逐份确认后再保存。</p>}
|
||||
</Collapsible>
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
{docs.length === 0 && !err && <EmptyState icon="inbox">暂无文档</EmptyState>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+15
-1
@@ -151,7 +151,21 @@ export interface ActivityCard {
|
||||
sourceRunId?: ID; anchorMessageId?: ID;
|
||||
kind: CardKind; title: string; status: CardStatus; tone?: string;
|
||||
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
|
||||
pinned?: boolean; createdAt: string; updatedAt: string;
|
||||
pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard;
|
||||
}
|
||||
|
||||
export interface WorkCard {
|
||||
result?: string; resultVersion: number; readVersion: number; completedAt?: string;
|
||||
decision?: WorkDecision;
|
||||
}
|
||||
export interface WorkDecision {
|
||||
runId?: ID;
|
||||
mode: 'single' | 'multiple'; state: 'open' | 'deferred' | 'dispatching' | 'submitted';
|
||||
options?: { id: string; label: string }[];
|
||||
selectedIds?: string[]; freeText?: string; respondedAt?: string;
|
||||
}
|
||||
export interface WorkCardAction {
|
||||
resultVersion: number; selectedIds?: string[]; freeText?: string;
|
||||
}
|
||||
|
||||
export interface CardPayload { card: ActivityCard }
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// 文档驾驶舱 I2 纯逻辑层测试:能力门控、筛选切换、统计呈现(真 0 vs 不可算)、
|
||||
// 快照一致性/更新提示、上下文键、选择保留、树徽标。组件取数编排不在此覆盖。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
contextKey, nodeSummaryLine, sameSnapshot, selectionSurvives, statDisplay,
|
||||
STAT_FILTERS, toggleFilter, treeBadges, updateNotice,
|
||||
type CockpitMeta, type CockpitStats, type CockpitTreeNode,
|
||||
} from './documentCockpit'
|
||||
import { cockpitReady, COCKPIT_COMMANDS } from './workspaces'
|
||||
|
||||
const meta = (rev: string, seq: number): CockpitMeta => ({
|
||||
schemaVersion: 'document-cockpit.v1', projectId: 'p1', snapshotRevision: rev,
|
||||
asOfSeq: seq, generatedAt: '2026-10-16T00:00:00Z', truncated: false,
|
||||
})
|
||||
|
||||
describe('cockpitReady', () => {
|
||||
const all = COCKPIT_COMMANDS.map(id => ({ id, enabled: true }))
|
||||
it('七命令齐备且 enabled 才就绪', () => {
|
||||
expect(cockpitReady(null)).toBe(false)
|
||||
expect(cockpitReady([])).toBe(false)
|
||||
expect(cockpitReady(all.map(c => ({ ...c, enabled: false })))).toBe(false)
|
||||
expect(cockpitReady(all.slice(0, 6))).toBe(false)
|
||||
expect(cockpitReady(all)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('统计呈现:真 0 与不可算不得互相冒充', () => {
|
||||
const stats: CockpitStats = {
|
||||
documentCount: 0,
|
||||
completion: { known: 3, total: 5, state: 'known' },
|
||||
emptyNodeCount: 0,
|
||||
unverifiedSourceCount: 2,
|
||||
conflictCount: null as unknown as number,
|
||||
pendingChangeCount: null,
|
||||
updatedAt: '',
|
||||
statReasons: { conflictCount: 'upstream_error', pendingChangeCount: 'not_projected_v1' },
|
||||
}
|
||||
it('真 0 显示 0', () => {
|
||||
expect(statDisplay(stats, 'empty').value).toBe('0')
|
||||
expect(statDisplay(stats, 'documents').value).toBe('0')
|
||||
})
|
||||
it('null 显示 — 并带原因', () => {
|
||||
const d = statDisplay(stats, 'conflict')
|
||||
expect(d.value).toBe('—')
|
||||
expect(d.hint).toBe('upstream_error')
|
||||
})
|
||||
it('未纳入投影的统计显式标注原因', () => {
|
||||
const d = statDisplay(stats, 'pending')
|
||||
expect(d.value).toBe('—')
|
||||
expect(d.hint).toBe('not_projected_v1')
|
||||
})
|
||||
it('完成度三分态:known/partial/unavailable', () => {
|
||||
expect(statDisplay(stats, 'completion').value).toBe('3/5')
|
||||
expect(statDisplay({ ...stats, completion: { known: 1, total: 5, state: 'partial' } }, 'completion').hint).toBe('部分计算')
|
||||
const u = statDisplay({ ...stats, completion: { known: 0, total: 0, state: 'unavailable' } }, 'completion')
|
||||
expect(u.value).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('筛选切换', () => {
|
||||
it('点击切换、再点清除;统计键与筛选值一一对应', () => {
|
||||
expect(toggleFilter('', 'state:empty')).toBe('state:empty')
|
||||
expect(toggleFilter('state:empty', 'state:empty')).toBe('')
|
||||
expect(toggleFilter('state:empty', 'state:conflict')).toBe('state:conflict')
|
||||
expect(STAT_FILTERS.map(s => s.filter)).toEqual(['state:empty', 'state:unverified', 'state:conflict'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('快照一致性与更新提示', () => {
|
||||
it('同 revision 同 seq 视为同快照', () => {
|
||||
expect(sameSnapshot(meta('wm:5', 5), meta('wm:5', 5))).toBe(true)
|
||||
expect(sameSnapshot(meta('wm:5', 5), meta('wm:6', 6))).toBe(false)
|
||||
expect(sameSnapshot(undefined, meta('wm:5', 5))).toBe(false)
|
||||
})
|
||||
it('展示快照落后时提示刷新,一致时不提示', () => {
|
||||
expect(updateNotice(meta('wm:5', 5), meta('wm:6', 6))).toContain('wm:6')
|
||||
expect(updateNotice(meta('wm:6', 6), meta('wm:6', 6))).toBeNull()
|
||||
expect(updateNotice(null, meta('wm:6', 6))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('旧响应丢弃的上下文键', () => {
|
||||
it('项目与筛选任一变化都改变键', () => {
|
||||
expect(contextKey('p1', '')).not.toBe(contextKey('p2', ''))
|
||||
expect(contextKey('p1', '')).not.toBe(contextKey('p1', 'state:empty'))
|
||||
expect(contextKey('p1', 'state:empty')).toBe(contextKey('p1', 'state:empty'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('刷新不重置选择', () => {
|
||||
it('文档仍在根清单则保留', () => {
|
||||
const kept = selectionSurvives({ kind: 'document', id: 'd1' }, ['d1', 'd2'])
|
||||
expect(kept).toEqual({ kind: 'document', id: 'd1' })
|
||||
})
|
||||
it('文档被筛掉则清空', () => {
|
||||
expect(selectionSurvives({ kind: 'document', id: 'd9' }, ['d1', 'd2'])).toBeNull()
|
||||
})
|
||||
it('章节选择随父文档存活', () => {
|
||||
expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d1'], 'd1')).toEqual({ kind: 'section', id: 's1' })
|
||||
expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d2'], 'd1')).toBeNull()
|
||||
expect(selectionSurvives(null, ['d1'])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('树徽标与状态行', () => {
|
||||
const node = (over: Partial<CockpitTreeNode['state']> & { visibility?: string }): CockpitTreeNode => ({
|
||||
ref: { kind: 'section', id: 's1' }, title: '章节', path: [], childCount: 0,
|
||||
visibility: over.visibility || 'full',
|
||||
state: {
|
||||
content: 'complete', verification: 'not_applicable', sourceCount: 0,
|
||||
conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true, ...over,
|
||||
},
|
||||
} as CockpitTreeNode)
|
||||
it('诚实徽标:空/受限/未核实/冲突计数', () => {
|
||||
expect(treeBadges(node({}))).toEqual([])
|
||||
expect(treeBadges(node({ content: 'empty' }))).toContain('空')
|
||||
expect(treeBadges(node({ verification: 'unverified' }))).toContain('未核实')
|
||||
expect(treeBadges(node({ conflictCount: 2 }))).toContain('冲突 2')
|
||||
expect(treeBadges(node({ visibility: 'metadata_only' }))).toContain('仅元数据')
|
||||
})
|
||||
it('结构缺口显式标注(冻结事实:doc→section 两级)', () => {
|
||||
const line = nodeSummaryLine(node({ content: 'empty' }))
|
||||
expect(line).toContain('内容为空')
|
||||
expect(line).toContain('层级止于章节')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- I3:阅读器与右栏纯逻辑 ----
|
||||
import {
|
||||
assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary,
|
||||
prefsKey, mergePrefs, loadPrefs,
|
||||
type CockpitHistorySummary, type CockpitVersionSummary,
|
||||
} from './documentCockpit'
|
||||
|
||||
describe('偏好:按用户+项目隔离,枚举外脏值回落默认', () => {
|
||||
it('prefsKey 区分用户与项目', () => {
|
||||
expect(prefsKey('a', 'p1')).not.toBe(prefsKey('a', 'p2'))
|
||||
expect(prefsKey('a', 'p1')).not.toBe(prefsKey('b', 'p1'))
|
||||
})
|
||||
it('mergePrefs 逐项校验', () => {
|
||||
expect(mergePrefs(null)).toEqual({ lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' })
|
||||
expect(mergePrefs({ lastView: 'reader', density: 'full', rightPanelTab: 'conflicts' })).toEqual({
|
||||
lastView: 'reader', density: 'full', rightPanelTab: 'conflicts',
|
||||
})
|
||||
const junk = mergePrefs({ lastView: 'galaxy', density: 42, rightPanelTab: 'nope' })
|
||||
expect(junk.lastView).toBe('panorama')
|
||||
expect(junk.density).toBe('compact')
|
||||
expect(junk.rightPanelTab).toBe('sources')
|
||||
})
|
||||
it('loadPrefs 在无 localStorage 环境安全回落', () => {
|
||||
expect(loadPrefs('a', 'p1').lastView).toBe('panorama')
|
||||
})
|
||||
})
|
||||
|
||||
describe('版本摘要与行级差异', () => {
|
||||
const v = (seq: number, content?: string, redacted?: boolean): CockpitVersionSummary => ({
|
||||
seq, version: `v${seq}`, at: '2026-10-16T00:00:00Z', basisCount: 0, content, redacted,
|
||||
})
|
||||
it('当前+前一版;受限整体降级', () => {
|
||||
const s = versionSummary([v(3, '三'), v(2, '二'), v(1, '一')])
|
||||
expect(s.current?.version).toBe('v3')
|
||||
expect(s.previous?.version).toBe('v2')
|
||||
expect(s.redacted).toBe(false)
|
||||
const r = versionSummary([v(3, undefined, true)])
|
||||
expect(r.redacted).toBe(true)
|
||||
expect(versionSummary(undefined).current).toBeUndefined()
|
||||
})
|
||||
it('diffLines 标记新增/删除/保留', () => {
|
||||
const d = diffLines('共同\n新增行', '共同\n删除行')
|
||||
expect(d).toContainEqual({ flag: '=', text: '共同' })
|
||||
expect(d).toContainEqual({ flag: '+', text: '新增行' })
|
||||
expect(d).toContainEqual({ flag: '-', text: '删除行' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('历史严格合并:乱序与重复丢弃(DV-R05)', () => {
|
||||
const h = (seq: number): CockpitHistorySummary => ({ seq, at: '', op: 'x', summary: `s${seq}` })
|
||||
it('只接受 seq 更大的条目', () => {
|
||||
const r = mergeHistoryStrict([h(10), h(9)], [h(11), h(8)])
|
||||
expect(r.items.map(i => i.seq)).toEqual([10, 9, 11])
|
||||
expect(r.dropped).toBe(1)
|
||||
})
|
||||
it('重复 seq 丢弃且计入 dropped', () => {
|
||||
const r = mergeHistoryStrict([h(10)], [h(10), h(11)])
|
||||
expect(r.items.map(i => i.seq)).toEqual([10, 11])
|
||||
expect(r.dropped).toBe(1)
|
||||
})
|
||||
it('空历史接受全部', () => {
|
||||
expect(mergeHistoryStrict([], [h(1), h(2)]).items.map(i => i.seq)).toEqual([1, 2])
|
||||
})
|
||||
it('historyLine 带操作者与本地时间', () => {
|
||||
expect(historyLine(h(1))).toContain('s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('阅读器装配:受限章节给占位不冒充空内容', () => {
|
||||
const sec = (id: string, content: string): CockpitTreeNode => ({
|
||||
ref: { kind: 'section', id }, title: id, path: [], childCount: 0,
|
||||
state: { content, verification: 'not_applicable', sourceCount: 0, conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true },
|
||||
visibility: 'full',
|
||||
})
|
||||
it('正文/受限/未加载三分态', () => {
|
||||
const out = assembleReader(
|
||||
[sec('a', 'complete'), sec('b', 'complete'), sec('c', 'empty')],
|
||||
{
|
||||
a: { body: [{ kind: 'text', text: '正文A' }] },
|
||||
b: { body: [{ kind: 'restricted', reason: 'section-duty-or-authorship' }] },
|
||||
},
|
||||
)
|
||||
expect(out[0].blocks).toEqual([{ kind: 'text', text: '正文A' }])
|
||||
expect(out[1].blocks[0].kind).toBe('restricted')
|
||||
expect(out[2].blocks[0].reason).toBe('not_loaded')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,342 @@
|
||||
// 文档驾驶舱 I2 纯逻辑层(rollout I2;契约:Dev 仓 docs/superpowers/specs
|
||||
// 2026-10-16-document-visualization-read-model-contract.md @ 37b272ba,
|
||||
// 四项取舍冻结见 plans/…-rollout.md §7)。只放可测判定:筛选切换、统计呈现
|
||||
//(真 0 vs 不可算)、快照一致性与更新提示、上下文键与选择保留。组件只做取数编排与渲染。
|
||||
|
||||
// ---- 契约类型(I1 后端 DTO 的前端精简形态) ----
|
||||
|
||||
export interface CockpitNodeRef { kind: string; id: string; version?: string }
|
||||
|
||||
export interface CockpitMeta {
|
||||
schemaVersion: string
|
||||
projectId: string
|
||||
snapshotRevision: string
|
||||
asOfSeq: number
|
||||
generatedAt: string
|
||||
truncated: boolean
|
||||
truncationReasons?: string[]
|
||||
}
|
||||
|
||||
export interface CockpitNodeState {
|
||||
content: string // complete|partial|empty|inaccessible|unavailable
|
||||
verification: string // verified|mixed|unverified|disputed|not_applicable
|
||||
sourceCount: number
|
||||
conflictCount: number
|
||||
impactCount: number
|
||||
missingChildCount: number
|
||||
structuralGap: boolean
|
||||
}
|
||||
|
||||
export interface CockpitTreeNode {
|
||||
ref: CockpitNodeRef
|
||||
title: string
|
||||
path: string[]
|
||||
parent?: CockpitNodeRef
|
||||
childCount: number
|
||||
state: CockpitNodeState
|
||||
lastChangedAt?: string
|
||||
lastChangedBy?: string
|
||||
visibility: string // full|redacted|metadata_only
|
||||
}
|
||||
|
||||
export interface CockpitStats {
|
||||
documentCount: number
|
||||
completion: { known: number; total: number; state: string }
|
||||
emptyNodeCount: number
|
||||
unverifiedSourceCount: number
|
||||
conflictCount: number
|
||||
pendingChangeCount: number | null
|
||||
updatedAt?: string
|
||||
statReasons: Record<string, string>
|
||||
}
|
||||
|
||||
export interface CockpitChangeSummary {
|
||||
node: CockpitNodeRef
|
||||
title: string
|
||||
changedAt: string
|
||||
actor?: string
|
||||
kind: string
|
||||
summary: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
export interface CockpitOverview {
|
||||
meta: CockpitMeta
|
||||
project: { title: string }
|
||||
stats: CockpitStats
|
||||
documents: CockpitTreeNode[]
|
||||
recentChanges: CockpitChangeSummary[]
|
||||
}
|
||||
|
||||
export interface CockpitTreePage {
|
||||
meta: CockpitMeta
|
||||
items: CockpitTreeNode[]
|
||||
nextCursor?: string
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
// ---- 统计带:点击即切换共享筛选(视图规格 §2;未知筛选由后端 400 拒绝) ----
|
||||
|
||||
export const STAT_FILTERS: { key: string; label: string; filter: string }[] = [
|
||||
{ key: 'empty', label: '空缺节点', filter: 'state:empty' },
|
||||
{ key: 'unverified', label: '未核实来源', filter: 'state:unverified' },
|
||||
{ key: 'conflict', label: '冲突', filter: 'state:conflict' },
|
||||
]
|
||||
|
||||
/** 点击统计卡:同筛选再次点击=清除(只定位,不执行写入)。 */
|
||||
export function toggleFilter(current: string, filter: string): string {
|
||||
return current === filter ? '' : filter
|
||||
}
|
||||
|
||||
/** 统计呈现:null(不可算)显示 — 加原因;真 0 显示 0。两侧都不得互相冒充(契约 §2/§6)。 */
|
||||
export function statDisplay(stats: CockpitStats, key: string): { value: string; hint?: string } {
|
||||
const reasons = stats.statReasons || {}
|
||||
if (key === 'documents') return { value: String(stats.documentCount) }
|
||||
if (key === 'completion') {
|
||||
const c = stats.completion
|
||||
if (c.state === 'unavailable') return { value: '—', hint: reasons['completion'] || '无法计算' }
|
||||
if (c.state === 'partial') return { value: `${c.known}/${c.total}`, hint: '部分计算' }
|
||||
return { value: `${c.known}/${c.total}` }
|
||||
}
|
||||
const map: Record<string, number | null | undefined> = {
|
||||
empty: stats.emptyNodeCount,
|
||||
unverified: stats.unverifiedSourceCount,
|
||||
conflict: stats.conflictCount,
|
||||
pending: stats.pendingChangeCount,
|
||||
}
|
||||
const v = map[key]
|
||||
// 原因表按后端 DTO 字段名(statReasons 键=字段名),展示键需映射。
|
||||
const reasonKey: Record<string, string> = {
|
||||
empty: 'emptyNodeCount', unverified: 'unverifiedSourceCount',
|
||||
conflict: 'conflictCount', pending: 'pendingChangeCount',
|
||||
}
|
||||
if (v === null || v === undefined) return { value: '—', hint: reasons[reasonKey[key] || key] || '暂不可计算' }
|
||||
return { value: String(v) }
|
||||
}
|
||||
|
||||
// ---- 快照一致性(DV-R05 前端侧):统计与树徽标同快照才同屏可信 ----
|
||||
|
||||
export function sameSnapshot(a?: CockpitMeta, b?: CockpitMeta): boolean {
|
||||
if (!a || !b) return false
|
||||
return a.snapshotRevision === b.snapshotRevision && a.asOfSeq === b.asOfSeq
|
||||
}
|
||||
|
||||
/** 更新提示:展示中快照落后于最新已见快照时提示刷新(不抢占滚动,视图规格 §5)。 */
|
||||
export function updateNotice(viewed: CockpitMeta | null, latest: CockpitMeta | null): string | null {
|
||||
if (!viewed || !latest) return null
|
||||
if (viewed.snapshotRevision === latest.snapshotRevision && viewed.asOfSeq === latest.asOfSeq) return null
|
||||
return `内容已更新(快照 ${latest.snapshotRevision})——刷新查看最新状态`
|
||||
}
|
||||
|
||||
/** 上下文键:项目+筛选标识一轮取数上下文;不匹配的旧响应直接丢弃(契约 §8)。 */
|
||||
export function contextKey(projectId: string, filter: string): string {
|
||||
return `${projectId}\u0001${filter}`
|
||||
}
|
||||
|
||||
/** 刷新不重置选择:文档仍在根清单(或选中章节的父文档仍在)则保留,否则清空。 */
|
||||
export function selectionSurvives(
|
||||
selected: CockpitNodeRef | null,
|
||||
rootDocIds: string[],
|
||||
parentDocId?: string,
|
||||
): CockpitNodeRef | null {
|
||||
if (!selected) return null
|
||||
if (selected.kind === 'document') return rootDocIds.includes(selected.id) ? selected : null
|
||||
if (parentDocId && rootDocIds.includes(parentDocId)) return selected
|
||||
return null
|
||||
}
|
||||
|
||||
/** 树行徽标(诚实状态:空/部分/受限/未核实/冲突计数;不虚构成功)。 */
|
||||
export function treeBadges(n: CockpitTreeNode): string[] {
|
||||
const out: string[] = []
|
||||
if (n.state.content === 'empty') out.push('空')
|
||||
else if (n.state.content === 'partial') out.push('部分')
|
||||
else if (n.state.content === 'inaccessible') out.push('受限')
|
||||
if (n.visibility === 'metadata_only') out.push('仅元数据')
|
||||
if (n.state.verification === 'unverified') out.push('未核实')
|
||||
if (n.state.conflictCount > 0) out.push(`冲突 ${n.state.conflictCount}`)
|
||||
return out
|
||||
}
|
||||
|
||||
/** 节点状态一句话总结(全景 §4.2)。 */
|
||||
export function nodeSummaryLine(n: CockpitTreeNode): string {
|
||||
const parts: string[] = []
|
||||
const content = { complete: '内容完整', partial: '内容部分', empty: '内容为空', inaccessible: '内容受限', unavailable: '暂不可读' }[n.state.content] || n.state.content
|
||||
parts.push(content)
|
||||
if (n.state.structuralGap) parts.push('层级止于章节')
|
||||
if (n.state.sourceCount > 0) parts.push(`来源 ${n.state.sourceCount}`)
|
||||
if (n.state.conflictCount > 0) parts.push(`冲突 ${n.state.conflictCount}`)
|
||||
if (n.state.impactCount > 0) parts.push(`影响 ${n.state.impactCount}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
// ---- I3:阅读器与右栏纯逻辑 ----
|
||||
|
||||
export interface CockpitVersionSummary {
|
||||
seq: number
|
||||
version: string
|
||||
actor?: string
|
||||
at: string
|
||||
basisCount: number
|
||||
content?: string
|
||||
redacted?: boolean
|
||||
}
|
||||
|
||||
export interface CockpitContentBlock { kind: string; text?: string; reason?: string }
|
||||
|
||||
export interface CockpitSourceSummary {
|
||||
ref: { kind: string; id: string }
|
||||
label: string
|
||||
capturedAt?: string
|
||||
confidence: string
|
||||
verification: string
|
||||
visibility: string
|
||||
redactedReason?: string
|
||||
}
|
||||
|
||||
export interface CockpitHistorySummary {
|
||||
seq: number
|
||||
at: string
|
||||
op: string
|
||||
actor?: string
|
||||
summary: string
|
||||
operationId?: string
|
||||
}
|
||||
|
||||
export interface CockpitImpactSummary {
|
||||
ref: { kind: string; id: string }
|
||||
edgeType: string
|
||||
edgeState: string
|
||||
direction: string
|
||||
layer: string
|
||||
}
|
||||
|
||||
export interface CockpitAssertion { node: CockpitNodeRef; claim: string; visibility: string; redactedReason?: string }
|
||||
|
||||
export interface CockpitConflictSummary {
|
||||
id: string
|
||||
node: CockpitNodeRef
|
||||
state: string
|
||||
left: CockpitAssertion
|
||||
right: CockpitAssertion
|
||||
detectedAt: string
|
||||
resolutionRef?: string
|
||||
}
|
||||
|
||||
export interface CockpitPage<T> { meta?: CockpitMeta; items: T[]; nextCursor?: string; hasMore: boolean; truncated?: boolean }
|
||||
|
||||
export const PANEL_TABS = [
|
||||
{ id: 'sources', label: '内容来源' },
|
||||
{ id: 'history', label: '修改历史' },
|
||||
{ id: 'impacts', label: '影响对象' },
|
||||
{ id: 'conflicts', label: '冲突与风险' },
|
||||
] as const
|
||||
export type PanelTabId = (typeof PANEL_TABS)[number]['id']
|
||||
|
||||
export type ViewMode = 'panorama' | 'reader'
|
||||
export type Density = 'compact' | 'full'
|
||||
|
||||
export interface CockpitPrefs {
|
||||
lastView: ViewMode
|
||||
density: Density
|
||||
rightPanelTab: PanelTabId
|
||||
}
|
||||
|
||||
/** 偏好按 用户+项目 隔离(设计 §7.3);仅本地存储,不构成授权。 */
|
||||
export function prefsKey(actor: string, projectId: string): string {
|
||||
return `cockpit-prefs:${actor}:${projectId}`
|
||||
}
|
||||
|
||||
/** 解析偏好:枚举外的脏值逐项回落默认(panorama+compact+sources)。 */
|
||||
export function mergePrefs(raw: unknown): CockpitPrefs {
|
||||
const base: CockpitPrefs = { lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' }
|
||||
if (!raw || typeof raw !== 'object') return base
|
||||
const r = raw as Record<string, unknown>
|
||||
const out = { ...base }
|
||||
if (r.lastView === 'panorama' || r.lastView === 'reader') out.lastView = r.lastView
|
||||
if (r.density === 'compact' || r.density === 'full') out.density = r.density
|
||||
if (PANEL_TABS.some(t => t.id === r.rightPanelTab)) out.rightPanelTab = r.rightPanelTab as PanelTabId
|
||||
return out
|
||||
}
|
||||
|
||||
export function loadPrefs(actor: string, projectId: string): CockpitPrefs {
|
||||
try {
|
||||
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(prefsKey(actor, projectId)) : null
|
||||
return mergePrefs(raw ? JSON.parse(raw) : null)
|
||||
} catch {
|
||||
return mergePrefs(null)
|
||||
}
|
||||
}
|
||||
|
||||
export function savePrefs(actor: string, projectId: string, prefs: CockpitPrefs): void {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem(prefsKey(actor, projectId), JSON.stringify(prefs))
|
||||
} catch { /* 存储不可用时偏好静默降级为会话内 */ }
|
||||
}
|
||||
|
||||
/** 版本摘要:当前版本 + 前一版本(视图规格 §5)。versions 为最新在前;受限版本无 content。 */
|
||||
export function versionSummary(versions: CockpitVersionSummary[] | undefined): {
|
||||
current?: CockpitVersionSummary
|
||||
previous?: CockpitVersionSummary
|
||||
redacted: boolean
|
||||
} {
|
||||
if (!versions || versions.length === 0) return { redacted: false }
|
||||
const current = versions[0]
|
||||
const previous = versions.length > 1 ? versions[1] : undefined
|
||||
return { current, previous, redacted: !!current.redacted }
|
||||
}
|
||||
|
||||
/** 历史面板合并:只接受严格更新(seq 更大且未见)的条目;过期与重复都计入 dropped(DV-R05/契约 §8)。 */
|
||||
export function mergeHistoryStrict(existing: CockpitHistorySummary[], incoming: CockpitHistorySummary[]): {
|
||||
items: CockpitHistorySummary[]
|
||||
dropped: number
|
||||
} {
|
||||
const seen = new Set(existing.map(e => e.seq))
|
||||
const maxSeq = existing.length > 0 ? Math.max(...seen) : -1
|
||||
const items = [...existing]
|
||||
let dropped = 0
|
||||
for (const e of incoming) {
|
||||
if (e.seq <= maxSeq || seen.has(e.seq)) {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
seen.add(e.seq)
|
||||
items.push(e)
|
||||
}
|
||||
return { items, dropped }
|
||||
}
|
||||
|
||||
/** 阅读器装配:把文档的章节详情按树顺序组装为连续正文;受限章节给占位说明而非空白。 */
|
||||
export function assembleReader(
|
||||
sections: CockpitTreeNode[],
|
||||
details: Record<string, { body?: CockpitContentBlock[]; versions?: CockpitVersionSummary[] } | undefined>,
|
||||
): { ref: CockpitNodeRef; title: string; contentState: string; blocks: CockpitContentBlock[]; versions?: CockpitVersionSummary[] }[] {
|
||||
return sections.map(sec => {
|
||||
const d = details[sec.ref.id]
|
||||
const readable = !!d?.body && d.body.length > 0 && d.body[0].kind === 'text'
|
||||
const restricted = !!d?.body && d.body.length > 0 && d.body[0].kind === 'restricted'
|
||||
const blocks: CockpitContentBlock[] = readable
|
||||
? (d!.body as CockpitContentBlock[])
|
||||
: restricted
|
||||
? [{ kind: 'restricted', reason: d?.body?.[0]?.reason || 'section-duty-or-authorship' }]
|
||||
: [{ kind: 'restricted', reason: 'not_loaded' }]
|
||||
return { ref: sec.ref, title: sec.title, contentState: sec.state.content, blocks, versions: d?.versions }
|
||||
})
|
||||
}
|
||||
|
||||
/** 行级差异(阅读器 Collapsible 的完整对比):按行对齐的朴素 LCS 免实现——
|
||||
* 以行为单位做集合比对标记 +/=/-,足够呈现「完整差异」而不冒充精确补丁。 */
|
||||
export function diffLines(current: string, previous: string): { flag: '+' | '-' | '='; text: string }[] {
|
||||
const cur = current.split('\n')
|
||||
const prev = new Set(previous.split('\n'))
|
||||
const curSet = new Set(cur)
|
||||
const out: { flag: '+' | '-' | '='; text: string }[] = []
|
||||
for (const line of cur) out.push({ flag: prev.has(line) ? '=' : '+', text: line })
|
||||
for (const line of previous.split('\n')) if (!curSet.has(line)) out.push({ flag: '-', text: line })
|
||||
return out
|
||||
}
|
||||
|
||||
/** 历史条目的自然语言时间线文本(右栏 §6)。 */
|
||||
export function historyLine(h: CockpitHistorySummary): string {
|
||||
const who = h.actor ? `${h.actor} ` : ''
|
||||
return `${h.summary}(${who}${new Date(h.at).toLocaleString('zh-CN')})`
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。
|
||||
export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver'
|
||||
|
||||
interface DutyRoles {
|
||||
author: string
|
||||
reviewers: string[]
|
||||
approver: string
|
||||
}
|
||||
|
||||
export interface DutyDocDraft extends DutyRoles {
|
||||
docId: string
|
||||
title: string
|
||||
code: string
|
||||
typeKey: string
|
||||
stage: string
|
||||
dutyVersion: number
|
||||
activeRound: number
|
||||
legacySectionOverrides: number
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
export interface DutyMember {
|
||||
owner: string
|
||||
role: string
|
||||
valid: boolean
|
||||
canAuthor: boolean
|
||||
canReview: boolean
|
||||
canApprove: boolean
|
||||
}
|
||||
|
||||
interface DutySaveItem {
|
||||
docId: string
|
||||
author: string
|
||||
reviewers: string[]
|
||||
approver: string
|
||||
expectedDutyVersion: number
|
||||
confirmInvalidation: boolean
|
||||
}
|
||||
|
||||
export function cellDuty(roles: DutyRoles, owner: string): CellDuty {
|
||||
if (roles.author === owner) return 'author'
|
||||
if (roles.approver === owner) return 'approver'
|
||||
if (roles.reviewers.includes(owner)) return 'reviewer'
|
||||
return 'none'
|
||||
}
|
||||
|
||||
export function applyCell(roles: DutyRoles, owner: string, duty: CellDuty): {
|
||||
roles: DutyRoles
|
||||
replacedAuthor: string
|
||||
replacedApprover: string
|
||||
} {
|
||||
const replacedAuthor = duty === 'author' && roles.author !== '' && roles.author !== owner ? roles.author : ''
|
||||
const replacedApprover = duty === 'approver' && roles.approver !== '' && roles.approver !== owner ? roles.approver : ''
|
||||
let author = roles.author === owner ? '' : roles.author
|
||||
let approver = roles.approver === owner ? '' : roles.approver
|
||||
let reviewers = roles.reviewers.filter(person => person !== owner)
|
||||
switch (duty) {
|
||||
case 'author':
|
||||
author = owner
|
||||
break
|
||||
case 'approver':
|
||||
approver = owner
|
||||
break
|
||||
case 'reviewer':
|
||||
reviewers = [...reviewers, owner]
|
||||
break
|
||||
case 'none':
|
||||
break
|
||||
default: {
|
||||
const exhaustive: never = duty
|
||||
throw new Error(exhaustive)
|
||||
}
|
||||
}
|
||||
return { roles: { author, reviewers, approver }, replacedAuthor, replacedApprover }
|
||||
}
|
||||
|
||||
export function replacementNote(roles: DutyRoles, owner: string, duty: CellDuty): string {
|
||||
if (duty === 'author' && roles.author !== '' && roles.author !== owner) return `将替换${roles.author}`
|
||||
if (duty === 'approver' && roles.approver !== '' && roles.approver !== owner) return `将替换${roles.approver}`
|
||||
return ''
|
||||
}
|
||||
|
||||
function dutyConflicts(roles: DutyRoles): number {
|
||||
const seen = new Map<string, number>()
|
||||
const mark = (owner: string) => {
|
||||
if (!owner) return
|
||||
seen.set(owner, (seen.get(owner) ?? 0) + 1)
|
||||
}
|
||||
mark(roles.author)
|
||||
mark(roles.approver)
|
||||
for (const reviewer of roles.reviewers) mark(reviewer)
|
||||
let conflicts = 0
|
||||
for (const count of seen.values()) if (count > 1) conflicts += 1
|
||||
return conflicts
|
||||
}
|
||||
|
||||
export function completeness(roles: DutyRoles): { authors: number; reviewers: number; approvers: number; conflicts: number } {
|
||||
return {
|
||||
authors: roles.author ? 1 : 0,
|
||||
reviewers: roles.reviewers.length,
|
||||
approvers: roles.approver ? 1 : 0,
|
||||
conflicts: dutyConflicts(roles),
|
||||
}
|
||||
}
|
||||
|
||||
export function saveIssues(roles: DutyRoles): string[] {
|
||||
const issues: string[] = []
|
||||
if (!roles.author) issues.push('需指定编制人')
|
||||
if (roles.reviewers.length === 0) issues.push('审核至少一人')
|
||||
if (!roles.approver) issues.push('需指定批准人')
|
||||
if (dutyConflicts(roles) > 0) issues.push('一人只能担一个职责')
|
||||
return issues
|
||||
}
|
||||
|
||||
function rolesFromCells(docId: string, cells: { docId: string; owner: string; duty: string }[]): DutyRoles {
|
||||
const roles: DutyRoles = { author: '', reviewers: [], approver: '' }
|
||||
for (const cell of cells) {
|
||||
if (cell.docId !== docId) continue
|
||||
if (cell.duty !== 'none' && cell.duty !== 'author' && cell.duty !== 'reviewer' && cell.duty !== 'approver') continue
|
||||
const next = applyCell(roles, cell.owner, cell.duty)
|
||||
roles.author = next.roles.author
|
||||
roles.reviewers = next.roles.reviewers
|
||||
roles.approver = next.roles.approver
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
export function absorbServerDocs(
|
||||
current: DutyDocDraft[],
|
||||
docs: {
|
||||
docId: string; title: string; code?: string; typeKey: string; stage?: string
|
||||
dutyVersion: number; activeRound?: number; legacySectionOverrides?: number
|
||||
}[],
|
||||
cells: { docId: string; owner: string; duty: string }[],
|
||||
replaceDirty = false,
|
||||
): DutyDocDraft[] {
|
||||
const byId = new Map(current.map(doc => [doc.docId, doc]))
|
||||
const pageIds = new Set(docs.map(doc => doc.docId))
|
||||
const next: DutyDocDraft[] = []
|
||||
for (const doc of docs) {
|
||||
const previous = byId.get(doc.docId)
|
||||
const serverRoles = rolesFromCells(doc.docId, cells)
|
||||
const keepDraft = !replaceDirty && previous?.dirty === true
|
||||
next.push({
|
||||
docId: doc.docId,
|
||||
title: doc.title,
|
||||
code: doc.code ?? doc.docId,
|
||||
typeKey: doc.typeKey,
|
||||
stage: doc.stage ?? '',
|
||||
dutyVersion: keepDraft ? previous.dutyVersion : doc.dutyVersion,
|
||||
activeRound: doc.activeRound ?? 0,
|
||||
legacySectionOverrides: doc.legacySectionOverrides ?? 0,
|
||||
dirty: keepDraft,
|
||||
author: keepDraft ? previous.author : serverRoles.author,
|
||||
reviewers: keepDraft ? previous.reviewers : serverRoles.reviewers,
|
||||
approver: keepDraft ? previous.approver : serverRoles.approver,
|
||||
})
|
||||
}
|
||||
for (const doc of current) {
|
||||
if (!replaceDirty && doc.dirty && !pageIds.has(doc.docId)) next.push(doc)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function editCell(docs: DutyDocDraft[], docId: string, owner: string, duty: CellDuty): DutyDocDraft[] {
|
||||
return docs.map(doc => {
|
||||
if (doc.docId !== docId) return doc
|
||||
const next = applyCell(doc, owner, duty)
|
||||
return { ...doc, ...next.roles, dirty: true }
|
||||
})
|
||||
}
|
||||
|
||||
export function dirtyDocs(docs: DutyDocDraft[]): DutyDocDraft[] {
|
||||
return docs.filter(doc => doc.dirty)
|
||||
}
|
||||
|
||||
export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean): { docs: DutySaveItem[]; issues: string[] } {
|
||||
const dirty = dirtyDocs(docs)
|
||||
const issues: string[] = []
|
||||
if (dirty.length === 0) issues.push('没有未保存的职责')
|
||||
if (dirty.length > 20) issues.push('一次保存最多 20 份文档')
|
||||
for (const doc of dirty) {
|
||||
for (const issue of saveIssues(doc)) issues.push(`${doc.title}:${issue}`)
|
||||
}
|
||||
return {
|
||||
docs: dirty.slice(0, 20).map(doc => ({
|
||||
docId: doc.docId,
|
||||
author: doc.author,
|
||||
reviewers: doc.reviewers,
|
||||
approver: doc.approver,
|
||||
expectedDutyVersion: doc.dutyVersion,
|
||||
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
|
||||
})),
|
||||
issues,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { CellDuty, DutyDocDraft, DutyMember } from './draft'
|
||||
import { cellDuty, completeness, replacementNote, saveIssues } from './draft'
|
||||
|
||||
const DUTY_OPTIONS: { value: CellDuty; label: string }[] = [
|
||||
{ value: 'none', label: '无职责' },
|
||||
{ value: 'author', label: '编制' },
|
||||
{ value: 'reviewer', label: '审核' },
|
||||
{ value: 'approver', label: '批准' },
|
||||
]
|
||||
|
||||
function DutySelect({
|
||||
doc, owner, disabled, onChange,
|
||||
}: {
|
||||
doc: DutyDocDraft
|
||||
owner: string
|
||||
disabled: boolean
|
||||
onChange: (duty: CellDuty) => void
|
||||
}) {
|
||||
const current = cellDuty(doc, owner)
|
||||
return (
|
||||
<label className="duty-cell">
|
||||
<select
|
||||
aria-label={`${owner} 在 ${doc.title} 的职责`}
|
||||
value={current}
|
||||
disabled={disabled}
|
||||
onChange={event => {
|
||||
const value = event.target.value
|
||||
if (value === 'none' || value === 'author' || value === 'reviewer' || value === 'approver') onChange(value)
|
||||
}}
|
||||
>
|
||||
{DUTY_OPTIONS.map(option => {
|
||||
const hint = replacementNote(doc, owner, option.value)
|
||||
return <option key={option.value} value={option.value}>{hint ? `${option.label}(${hint})` : option.label}</option>
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function DutyByMemberView({
|
||||
member, members, docs, disabled, onMember, onCell,
|
||||
}: {
|
||||
member: string
|
||||
members: DutyMember[]
|
||||
docs: DutyDocDraft[]
|
||||
disabled: boolean
|
||||
onMember: (owner: string) => void
|
||||
onCell: (docId: string, owner: string, duty: CellDuty) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="setting-row">
|
||||
<label htmlFor="duty-member">成员</label>
|
||||
<select id="duty-member" aria-label="选择成员" value={member} onChange={event => onMember(event.target.value)}>
|
||||
<option value="">选择成员</option>
|
||||
{members.map(person => <option key={person.owner} value={person.owner}>{person.owner}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{!member ? <p className="ad-hint">先选择成员,再为每份文档指定无职责、编制、审核或批准。</p> : (
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>文档</th><th>类型 / 阶段</th><th>职责</th><th>完整性</th></tr></thead>
|
||||
<tbody>
|
||||
{docs.map(doc => {
|
||||
const score = completeness(doc)
|
||||
return (
|
||||
<tr key={doc.docId}>
|
||||
<td>{doc.title}<span className="ad-dim ad-mono"> · {doc.code}</span></td>
|
||||
<td className="ad-dim">{doc.typeKey}{doc.stage ? ` / ${doc.stage}` : ''}</td>
|
||||
<td><DutySelect doc={doc} owner={member} disabled={disabled} onChange={duty => onCell(doc.docId, member, duty)} /></td>
|
||||
<td className="ad-dim">编 {score.authors}/1 · 审 {score.reviewers} · 批 {score.approvers}/1</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{docs.length === 0 && <tr><td colSpan={4} className="ad-dim">没有符合筛选的文档</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DutyByDocumentView({
|
||||
docId, docs, members, disabled, onDoc, onCell,
|
||||
}: {
|
||||
docId: string
|
||||
docs: DutyDocDraft[]
|
||||
members: DutyMember[]
|
||||
disabled: boolean
|
||||
onDoc: (id: string) => void
|
||||
onCell: (docId: string, owner: string, duty: CellDuty) => void
|
||||
}) {
|
||||
const doc = docs.find(item => item.docId === docId)
|
||||
const score = doc ? completeness(doc) : null
|
||||
const issues = doc ? saveIssues(doc) : []
|
||||
return (
|
||||
<div>
|
||||
<div className="setting-row">
|
||||
<label htmlFor="duty-doc">文档</label>
|
||||
<select id="duty-doc" aria-label="选择文档" value={docId} onChange={event => onDoc(event.target.value)}>
|
||||
<option value="">选择整份文档</option>
|
||||
{docs.map(item => <option key={item.docId} value={item.docId}>{item.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{score && <p className="ad-hint">编制 {score.authors}/1、审核 {score.reviewers} 人、批准 {score.approvers}/1、冲突 {score.conflicts}{issues.length > 0 ? `。${issues.join(',')}` : ''}</p>}
|
||||
{!doc ? <p className="ad-hint">先选择整份文档。这里不能选择章节,也没有生成入口。</p> : (
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>成员</th><th>项目身份</th><th>职责</th></tr></thead>
|
||||
<tbody>
|
||||
{members.map(person => (
|
||||
<tr key={person.owner}>
|
||||
<td className="ad-mono">{person.owner}{person.valid ? '' : '(无效)'}</td>
|
||||
<td className="ad-dim">{person.role === 'owner' ? '项目负责人' : '项目成员'}</td>
|
||||
<td><DutySelect doc={doc} owner={person.owner} disabled={disabled || !person.valid} onChange={duty => onCell(doc.docId, person.owner, duty)} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DutyMatrixView({
|
||||
docs, members, disabled, narrow, onCell,
|
||||
}: {
|
||||
docs: DutyDocDraft[]
|
||||
members: DutyMember[]
|
||||
disabled: boolean
|
||||
narrow: boolean
|
||||
onCell: (docId: string, owner: string, duty: CellDuty) => void
|
||||
}) {
|
||||
if (narrow) {
|
||||
return <p className="ad-hint">当前宽度放不下成员×文档矩阵。请使用“按人员”或“按文档”。</p>
|
||||
}
|
||||
return (
|
||||
<div className="duty-matrix-wrap">
|
||||
<table className="ad-table duty-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="duty-sticky">成员</th>
|
||||
{docs.map(doc => {
|
||||
const score = completeness(doc)
|
||||
const ready = score.authors === 1 && score.reviewers >= 1 && score.approvers === 1 && score.conflicts === 0
|
||||
return <th key={doc.docId}>{doc.title}<span className={ready ? 'ad-chip' : 'ad-chip ad-chip-warn'}>{ready ? '三权齐' : '未齐'}</span></th>
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map(person => (
|
||||
<tr key={person.owner}>
|
||||
<td className="duty-sticky ad-mono">{person.owner}</td>
|
||||
{docs.map(doc => (
|
||||
<td key={doc.docId}>
|
||||
<DutySelect doc={doc} owner={person.owner} disabled={disabled || !person.valid} onChange={duty => onCell(doc.docId, person.owner, duty)} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DutyChangePreview({
|
||||
impacts, confirm, onConfirm,
|
||||
}: {
|
||||
impacts: {
|
||||
docId: string
|
||||
title?: string
|
||||
replacedAuthor?: string
|
||||
replacedApprover?: string
|
||||
needsConfirm?: boolean
|
||||
reasons?: string[]
|
||||
}[]
|
||||
confirm: boolean
|
||||
onConfirm: (value: boolean) => void
|
||||
}) {
|
||||
if (impacts.length === 0) return null
|
||||
const needs = impacts.some(item => item.needsConfirm)
|
||||
return (
|
||||
<div className="settings-group">
|
||||
<h4>保存前预览</h4>
|
||||
<ul>
|
||||
{impacts.map(item => (
|
||||
<li key={item.docId}>
|
||||
{item.title || item.docId}
|
||||
{item.replacedAuthor ? `:编制将替换${item.replacedAuthor}` : ''}
|
||||
{item.replacedApprover ? `:批准将替换${item.replacedApprover}` : ''}
|
||||
{item.needsConfirm ? ';在途审签将失效,历史名单保持不变' : ''}
|
||||
{item.reasons && item.reasons.length > 0 ? `;${item.reasons.join(',')}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{needs && (
|
||||
<label>
|
||||
<input type="checkbox" checked={confirm} onChange={event => onConfirm(event.target.checked)} />
|
||||
确认使受影响的在途审签失效
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+86
-5
@@ -3,8 +3,9 @@
|
||||
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
|
||||
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定。
|
||||
// 参数增量(编写中)一到即算工具,解说不用等工具真正开始执行才离开回复区。
|
||||
import type { Message, Part } from './contracts'
|
||||
import type { Message, Part, StreamEvent } from './contracts'
|
||||
import type { LiveState } from './store'
|
||||
import { splitTimeline, type ReplayPart } from './messageparts'
|
||||
|
||||
export type ToolLiveState = 'composing' | 'running' | 'done'
|
||||
|
||||
@@ -12,6 +13,7 @@ export type ToolLiveState = 'composing' | 'running' | 'done'
|
||||
export type LiveItem =
|
||||
| { kind: 'thinking'; text: string; startedAt: number; endedAt: number }
|
||||
| { kind: 'text'; index: number; text: string }
|
||||
| { kind: 'agent'; text: string; agentRun?: string; agentPhase?: 'spawn' | 'progress' | 'result' }
|
||||
| {
|
||||
kind: 'tool'
|
||||
callId?: string
|
||||
@@ -118,13 +120,13 @@ export function applyToolCall(items: LiveItem[], name: string, callId?: string,
|
||||
}
|
||||
|
||||
// applyToolOutput 把执行中的输出追加到对应工具,只保留尾部,避免长日志撑住内存。
|
||||
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string): LiveItem[] {
|
||||
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string, maxChars = OUTPUT_TAIL): LiveItem[] {
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const it = items[i]
|
||||
if (!isTool(it) || it.state === 'done') continue
|
||||
if (callId && it.callId && it.callId !== callId) continue
|
||||
let output = it.output + text
|
||||
if (output.length > OUTPUT_TAIL) output = output.slice(output.length - OUTPUT_TAIL)
|
||||
if (output.length > maxChars) output = output.slice(output.length - maxChars)
|
||||
const out = items.slice()
|
||||
out[i] = { ...it, output }
|
||||
return out
|
||||
@@ -149,6 +151,27 @@ export function applyCheck(items: LiveItem[], passed: boolean, reason: string):
|
||||
return [...items, { kind: 'check', passed, reason }]
|
||||
}
|
||||
|
||||
/** A child streams cumulative previews. Replace that child's latest preview in
|
||||
* place, while preserving real phase changes and the order of other children. */
|
||||
function applyAgentProgress(items: LiveItem[], agentRun: string, note: string): LiveItem[] {
|
||||
const text = note.trim()
|
||||
if (!text) return items
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const previous = items[i]
|
||||
if (previous.kind !== 'agent' || previous.agentRun !== agentRun) continue
|
||||
if (previous.agentPhase !== 'progress') break
|
||||
const value = previous.text.slice('协作进展:'.length)
|
||||
if (value === text || value.startsWith(text)) return items
|
||||
if (text.startsWith(value) && !/^调用\s/.test(text)) {
|
||||
const next = items.slice()
|
||||
next[i] = { ...previous, text: `协作进展:${text}` }
|
||||
return next
|
||||
}
|
||||
break
|
||||
}
|
||||
return [...items, { kind: 'agent', text: `协作进展:${text}`, agentRun, agentPhase: 'progress' }]
|
||||
}
|
||||
|
||||
// applyFinalizing 标记收尾开始:此前的正文都是过程,此后的正文才是给用户的回复。
|
||||
export function applyFinalizing(items: LiveItem[]): LiveItem[] {
|
||||
if (items.some(it => it.kind === 'final')) return items
|
||||
@@ -193,7 +216,9 @@ export function liveToMessage(live: LiveState): Message {
|
||||
parts.push({ type: 'check', process: true, check: { passed: it.passed, reason: it.reason || undefined } })
|
||||
continue
|
||||
}
|
||||
if (it.kind === 'thinking') {
|
||||
if (it.kind === 'agent') {
|
||||
parts.push({ type: 'text', text: it.text, process: true, replaySource: 'agent' } as ReplayPart)
|
||||
} else if (it.kind === 'thinking') {
|
||||
const durationMs = it.endedAt - it.startedAt
|
||||
if (it.text) parts.push({
|
||||
type: 'thinking', text: it.text, process: true,
|
||||
@@ -221,7 +246,63 @@ export function liveToMessage(live: LiveState): Message {
|
||||
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
|
||||
}
|
||||
}
|
||||
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
|
||||
const createdAt = live.startedAt > 0 && Number.isFinite(live.startedAt) ? new Date(live.startedAt).toISOString() : ''
|
||||
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt }
|
||||
}
|
||||
|
||||
/** Rebuild one run's process from its durable ordered events, without the live preview's output-tail limit. */
|
||||
export function runEventsToTimeline(events: StreamEvent[], parentRunId = events[0]?.runId, completeRecord = false): ReplayPart[] {
|
||||
let items: LiveItem[] = []
|
||||
let finalizing = false
|
||||
for (const event of [...events].sort((a, b) => a.seq - b.seq)) {
|
||||
const p = event.payload as Record<string, unknown> | undefined
|
||||
const now = Date.parse(event.ts) || 0
|
||||
if (event.runId !== parentRunId && event.type !== 'agent.progress' && event.type !== 'agent.result') continue
|
||||
switch (event.type) {
|
||||
case 'agent.spawn':
|
||||
if (typeof p?.name === 'string') items.push({ kind: 'agent', text: `协作代理「${p.name}」已开始`,
|
||||
agentRun: String(p.agentRun ?? event.runId ?? ''), agentPhase: 'spawn' })
|
||||
break
|
||||
case 'agent.progress':
|
||||
if (typeof p?.note === 'string') items = applyAgentProgress(items, String(p.agentRun ?? event.runId ?? ''), p.note)
|
||||
break
|
||||
case 'agent.result':
|
||||
items.push({ kind: 'agent', text: `协作结束:${typeof p?.summary === 'string' && p.summary.trim() ? p.summary : String(p?.status ?? '已结束')}`,
|
||||
agentRun: String(p?.agentRun ?? event.runId ?? ''), agentPhase: 'result' })
|
||||
break
|
||||
case 'thinking.delta':
|
||||
if (typeof p?.text === 'string') items = applyThinkingDelta(items, p.text, now)
|
||||
break
|
||||
case 'message.delta':
|
||||
if ((!finalizing || completeRecord) && typeof p?.text === 'string') items = applyTextDelta(items, typeof p.index === 'number' ? p.index : 0, p.text)
|
||||
break
|
||||
case 'tool.call.delta':
|
||||
if (typeof p?.delta === 'string') items = applyToolCallDelta(items, typeof p.index === 'number' ? p.index : 0,
|
||||
p.delta, typeof p.callId === 'string' ? p.callId : undefined, typeof p.name === 'string' ? p.name : undefined, now)
|
||||
break
|
||||
case 'tool.call':
|
||||
items = applyToolCall(items, typeof p?.name === 'string' ? p.name : '工具',
|
||||
typeof p?.callId === 'string' ? p.callId : undefined,
|
||||
typeof p?.arguments === 'string' ? p.arguments : undefined, now)
|
||||
break
|
||||
case 'tool.output':
|
||||
if (typeof p?.text === 'string') items = applyToolOutput(items,
|
||||
typeof p.callId === 'string' ? p.callId : undefined, p.text, Number.POSITIVE_INFINITY)
|
||||
break
|
||||
case 'tool.result':
|
||||
items = applyToolResult(items, typeof p?.callId === 'string' ? p.callId : undefined,
|
||||
typeof p?.status === 'string' ? p.status : 'ok', typeof p?.summary === 'string' ? p.summary : undefined, now)
|
||||
break
|
||||
case 'run.phase':
|
||||
if (p?.phase === 'finalizing') { finalizing = true; items = applyFinalizing(items) }
|
||||
break
|
||||
case 'run.check':
|
||||
items = applyCheck(items, !!p?.passed, typeof p?.reason === 'string' ? p.reason : '')
|
||||
break
|
||||
}
|
||||
}
|
||||
const live: LiveState = { runId: events[0]?.runId ?? '', phase: 'done', items, agentNotes: {}, startedAt: 0 }
|
||||
return completeRecord ? liveToMessage(live).parts : splitTimeline(liveToMessage(live).parts).timeline
|
||||
}
|
||||
|
||||
// livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。
|
||||
|
||||
+75
-10
@@ -1,5 +1,4 @@
|
||||
// 消息切分:技术记录留在过程时间线,正文、错误与文件入口始终直接可读。
|
||||
// 两区各自保留落库顺序,后续工具或终轮检查不再把解说藏入折叠区。
|
||||
// 消息切分:每轮过程按落库顺序留在可回看的时间线,终轮回复直接可读。
|
||||
import type { Part } from './contracts'
|
||||
|
||||
export interface TimelineSplit {
|
||||
@@ -7,20 +6,86 @@ export interface TimelineSplit {
|
||||
final: Part[]
|
||||
}
|
||||
|
||||
/** 最后一个 process 部件之前的技术记录留在时间线,其余为可见内容。
|
||||
* 保留旧 promoteLastText 参数以兼容调用方;解说现在始终可见,不再依赖终态提升。 */
|
||||
export function splitTimeline(parts: Part[], _promoteLastText = false): TimelineSplit {
|
||||
/** Client-only provenance. It is never written to the Core message contract. */
|
||||
export interface ReplayPart extends Part { replaySource?: 'agent' }
|
||||
|
||||
/** 最后一个 process 部件之前的记录属于该轮过程;错误与文件入口始终可见。
|
||||
* 终态没有回复时,只把最后一段过程解说提升为可见回复。 */
|
||||
export function splitTimeline(parts: Part[], promoteLastText = false): TimelineSplit {
|
||||
let last = -1
|
||||
for (let i = 0; i < parts.length; i++) if (parts[i].process) last = i
|
||||
if (last < 0) return { timeline: [], final: parts }
|
||||
// text 是公开解说;thinking、工具参数与结果、终轮检查不提升为正文。
|
||||
const visible = (part: Part) => part.type === 'text' || part.type === 'error' || part.type === 'artifact_ref'
|
||||
const before = parts.slice(0, last + 1)
|
||||
const timeline = before.filter(part => !visible(part))
|
||||
const final = [...before.filter(visible), ...parts.slice(last + 1)]
|
||||
const timeline: Part[] = []
|
||||
const final: Part[] = []
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]
|
||||
if (i > last || part.type === 'error' || part.type === 'artifact_ref') final.push(part)
|
||||
else timeline.push(part)
|
||||
}
|
||||
if (promoteLastText && !final.some(part => part.type === 'text' || part.type === 'error')) {
|
||||
let idx = -1
|
||||
for (let i = timeline.length - 1; i >= 0; i--) {
|
||||
if (timeline[i].type === 'text') { idx = i; break }
|
||||
}
|
||||
if (idx >= 0) final.unshift(...timeline.splice(idx, 1))
|
||||
}
|
||||
return { timeline, final }
|
||||
}
|
||||
|
||||
const normalized = (text?: string) => (text ?? '').replace(/\s+/g, ' ').trim()
|
||||
|
||||
function sameProcessPart(saved: Part, replayed: Part): boolean {
|
||||
if (saved.type !== replayed.type) return false
|
||||
switch (saved.type) {
|
||||
case 'tool_call': return !!saved.toolCall?.id && saved.toolCall.id === replayed.toolCall?.id
|
||||
case 'tool_result': return !!saved.toolResult?.callId && saved.toolResult.callId === replayed.toolResult?.callId
|
||||
case 'check': return saved.check?.passed === replayed.check?.passed && saved.check?.reason === replayed.check?.reason
|
||||
case 'thinking':
|
||||
case 'text': return normalized(saved.text) === normalized(replayed.text)
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the settled message as the content authority; add only event-only records
|
||||
* and full tool output. Event deltas can be missing, while terminal parts can
|
||||
* contain retry notes or parsed inline thinking that the event stream cannot reconstruct. */
|
||||
export function mergeProcessTimeline(saved: Part[], replayed: ReplayPart[], final: Part[]): Part[] {
|
||||
if (replayed.length === 0) return saved
|
||||
const finalTexts = final.filter(part => part.type === 'text').map(part => normalized(part.text)).filter(Boolean)
|
||||
const merged: Part[] = []
|
||||
let cursor = 0
|
||||
let pending: Part[] = []
|
||||
for (const part of replayed) {
|
||||
const text = normalized(part.text)
|
||||
const agentNote = part.replaySource === 'agent'
|
||||
if (part.type === 'text' && !agentNote && ((text && finalTexts.some(value => value.includes(text) || text.includes(value)))
|
||||
|| /<\/?think\b/i.test(part.text ?? ''))) continue
|
||||
let match = -1
|
||||
for (let i = cursor; i < saved.length; i++) {
|
||||
if (sameProcessPart(saved[i], part)) { match = i; break }
|
||||
}
|
||||
if (match < 0) {
|
||||
// A missing delta batch can leave only a prefix in events. The complete
|
||||
// terminal part is authoritative; never render that prefix beside it.
|
||||
if (!agentNote && (part.type === 'text' || part.type === 'thinking') && text && saved.some(item => {
|
||||
if (item.type !== part.type) return false
|
||||
const value = normalized(item.text)
|
||||
return value && (value.includes(text) || text.includes(value))
|
||||
})) continue
|
||||
pending.push(part)
|
||||
continue
|
||||
}
|
||||
merged.push(...saved.slice(cursor, match), ...pending)
|
||||
pending = []
|
||||
const output = (part as Part & { toolOutput?: string }).toolOutput
|
||||
merged.push(output && saved[match].type === 'tool_call'
|
||||
? { ...saved[match], toolOutput: output } as Part : saved[match])
|
||||
cursor = match + 1
|
||||
}
|
||||
merged.push(...saved.slice(cursor), ...pending)
|
||||
return merged
|
||||
}
|
||||
|
||||
/** 同一轮里同名工件只留最新一张,updates 是被折叠掉的次数。 */
|
||||
export function dedupeDeliverables(parts: Part[]): { artifact: NonNullable<Part['artifact']>; updates: number }[] {
|
||||
const order: string[] = []
|
||||
|
||||
+6
-1
@@ -1,17 +1,22 @@
|
||||
// 输入排队(纯数据):回合进行中先入本地队列,终态再逐条发送。
|
||||
export interface WorkReference { cardId: string; resultVersion: number; title: string }
|
||||
|
||||
export interface QueuedMessage {
|
||||
id: string
|
||||
text: string
|
||||
attachmentIds: string[]
|
||||
attachmentNames: string[]
|
||||
taskRef?: WorkReference
|
||||
deliveryError?: string
|
||||
}
|
||||
|
||||
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[]): QueuedMessage {
|
||||
export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[], taskRef?: WorkReference): QueuedMessage {
|
||||
return {
|
||||
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
text,
|
||||
attachmentIds,
|
||||
attachmentNames,
|
||||
...(taskRef ? { taskRef } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
// 浏览器端随机初始密码(管理员未显式给定密码时):
|
||||
// crypto.getRandomValues 拒绝易混淆字符(0/O/1/l/I),保证可抄写。
|
||||
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'
|
||||
|
||||
export function randomPassword(length = 12): string {
|
||||
const buf = new Uint32Array(length)
|
||||
crypto.getRandomValues(buf)
|
||||
let out = ''
|
||||
for (let i = 0; i < length; i++) out += ALPHABET[buf[i] % ALPHABET.length]
|
||||
return out
|
||||
}
|
||||
@@ -3,3 +3,41 @@ export function projectStage(settings?: Record<string, unknown> | null): string
|
||||
const stage = settings?.stage
|
||||
return typeof stage === 'string' && stage.trim() !== '' ? stage.trim() : null
|
||||
}
|
||||
|
||||
/** Service metadata describes readiness; permission is always checked by the server. */
|
||||
export function projectWorkError(project: { settings?: Record<string, unknown> } | undefined): string | null {
|
||||
if (!project) return '项目已不可用,请刷新项目列表或联系项目负责人'
|
||||
const settings = project.settings
|
||||
if (!settings || (!('rdm.state' in settings) && !('rdm.projectId' in settings))) return null
|
||||
switch (settings['rdm.state']) {
|
||||
case 'ready':
|
||||
if (typeof settings['rdm.projectId'] === 'string' && settings['rdm.projectId'].trim()) return null
|
||||
break
|
||||
case 'provisioning': return '项目正在准备,请稍后刷新项目列表再试'
|
||||
case 'failed': return '项目准备失败,请联系项目负责人重试创建'
|
||||
case 'archived': return '项目已归档,请联系项目负责人恢复后再新建对话'
|
||||
case 'closed': return '项目已关闭,请选择其他项目或联系项目负责人'
|
||||
}
|
||||
return '项目尚未绑定,请联系项目负责人完成项目接入'
|
||||
}
|
||||
|
||||
export function projectStateLabel(settings?: Record<string, unknown>): string | null {
|
||||
switch (settings?.['rdm.state']) {
|
||||
case 'provisioning': return '准备中'
|
||||
case 'failed': return '准备失败'
|
||||
case 'unbound': return '未接入'
|
||||
case 'archived': return '已归档'
|
||||
case 'closed': return '已关闭'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
export function projectActionError(error: unknown): string {
|
||||
const raw = String(error)
|
||||
if (/\b401\b/.test(raw)) return '登录已失效,请重新登录后再试'
|
||||
if (/\b403\b/.test(raw)) return '当前账号无法管理此项目,请联系项目负责人'
|
||||
if (/\b404\b/.test(raw)) return '项目已不可用,请刷新项目列表或联系项目负责人'
|
||||
if (/\b409\b/.test(raw)) return '项目名称已被占用或项目状态已变化,请更换名称或刷新后再试'
|
||||
if (/\b503\b/.test(raw)) return '项目服务暂未就绪,请稍后重试或联系项目负责人'
|
||||
return raw.replace(/^Error:\s*/, '')
|
||||
}
|
||||
|
||||
+70
-11
@@ -2,6 +2,36 @@
|
||||
// 命令清单不可用=未就绪,fail-closed)与命令载荷构造。页面组件只消费本模块与
|
||||
// api.rdmCommand,不在组件里散落命令 id / 参数拼写;身份一律经认证会话,载荷不
|
||||
// 携带任何身份字段(G1-5)。
|
||||
import type { Project } from './contracts'
|
||||
import { projectWorkError } from './projectmeta'
|
||||
|
||||
export function rdmCommandProjectId(project: Project): string {
|
||||
const id = project.settings?.['rdm.projectId']
|
||||
if (typeof id !== 'string' || !id.trim()) {
|
||||
throw new Error('项目尚未绑定,请联系项目负责人完成项目接入')
|
||||
}
|
||||
return id.trim()
|
||||
}
|
||||
|
||||
/** Historical project reads remain available; this does not grant read permission. */
|
||||
export function rdmProjectReadError(project: Project | undefined): string | null {
|
||||
const state = project?.settings?.['rdm.state']
|
||||
if (project && (state === 'ready' || state === 'archived' || state === 'closed')) {
|
||||
try { rdmCommandProjectId(project); return null }
|
||||
catch (e) { return String(e).replace(/^Error:\s*/, '') }
|
||||
}
|
||||
return projectWorkError(project) ?? '项目尚未绑定,请联系项目负责人完成项目接入'
|
||||
}
|
||||
|
||||
const PROJECT_READ_COMMANDS = new Set([
|
||||
'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
|
||||
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get',
|
||||
'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan',
|
||||
])
|
||||
|
||||
export function rdmProjectCommandError(project: Project, command: string): string | null {
|
||||
return PROJECT_READ_COMMANDS.has(command) ? rdmProjectReadError(project) : projectWorkError(project)
|
||||
}
|
||||
|
||||
/** 系统管理「RDM 角色权限」分区消费的命令面。 */
|
||||
export const RDM_ROLES_COMMANDS = [
|
||||
@@ -25,14 +55,12 @@ export const RDM_PERM_MATRIX_COMMANDS = [
|
||||
'rdm.project.member.list',
|
||||
] as const
|
||||
|
||||
/** 项目设置「文档职责 · 整文审签」页消费的命令面。 */
|
||||
export const RDM_DOC_REVIEW_COMMANDS = [
|
||||
/** 项目设置「文档职责」页消费的命令面。审签命令留在文档工作区,不作为本页门控。 */
|
||||
export const RDM_DUTY_COMMANDS = [
|
||||
'rdm.duty.matrix',
|
||||
'rdm.duty.preview',
|
||||
'rdm.review.assign.doc',
|
||||
'rdm.review.doc.submit',
|
||||
'rdm.review.doc.approve',
|
||||
'rdm.review.doc.reject',
|
||||
'rdm.review.doc.final',
|
||||
'rdm.review.doc.status',
|
||||
'rdm.review.assign.doc.batch',
|
||||
] as const
|
||||
|
||||
export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined
|
||||
@@ -50,9 +78,9 @@ export const rdmRolesReady = (cmds: CommandListItem[] | null | undefined) =>
|
||||
export const rdmMembersReady = (cmds: CommandListItem[] | null | undefined) =>
|
||||
ready(cmds, RDM_MEMBER_COMMANDS)
|
||||
|
||||
/** 文档职责/整文审签页是否就绪。 */
|
||||
export const rdmDocReviewReady = (cmds: CommandListItem[] | null | undefined) =>
|
||||
ready(cmds, RDM_DOC_REVIEW_COMMANDS)
|
||||
/** 文档职责页是否就绪。命令未齐则 fail-closed。 */
|
||||
export const rdmDutyReady = (cmds: CommandListItem[] | null | undefined) =>
|
||||
ready(cmds, RDM_DUTY_COMMANDS)
|
||||
|
||||
/** 项目权限矩阵页是否就绪。 */
|
||||
export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) =>
|
||||
@@ -76,10 +104,41 @@ export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ role
|
||||
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') =>
|
||||
({ projectId, member, role })
|
||||
export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member })
|
||||
export const memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role })
|
||||
|
||||
export const assignDocDutiesPayload = (
|
||||
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
|
||||
) => ({ projectId, docId, author, reviewers, approver })
|
||||
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean },
|
||||
) => ({
|
||||
projectId, docId, author, reviewers, approver,
|
||||
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
|
||||
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
|
||||
})
|
||||
|
||||
export const dutyMatrixPayload = (
|
||||
projectId: string,
|
||||
query: {
|
||||
view: 'member' | 'document' | 'matrix'
|
||||
memberOwner?: string
|
||||
docId?: string
|
||||
query?: string
|
||||
stage?: string
|
||||
typeKey?: string
|
||||
assignment?: string
|
||||
memberQuery?: string
|
||||
memberCursor?: string
|
||||
docCursor?: string
|
||||
memberLimit?: number
|
||||
docLimit?: number
|
||||
},
|
||||
) => ({ projectId, ...query })
|
||||
|
||||
export const dutyPreviewPayload = (
|
||||
projectId: string,
|
||||
docs: { docId: string; author: string; reviewers: string[]; approver: string; expectedDutyVersion: number; confirmInvalidation: boolean }[],
|
||||
) => ({ projectId, docs })
|
||||
|
||||
export const dutyLegacyPayload = (projectId: string) => ({ projectId })
|
||||
|
||||
export const docSubmitPayload = (projectId: string, docId: string) => ({ projectId, docId })
|
||||
export const docApprovePayload = (projectId: string, docId: string, note?: string) =>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from './recovery'
|
||||
import { humanChangePasswordError, humanRedeemError, requiresCurrentPassword } from './components/LoginPage'
|
||||
|
||||
// 账户链路文案与模式判定(2026-10-14 current_required 加固后前后端契约对齐的
|
||||
// 防漂移锁):改密门必须区分 grant_setup / current_required,恢复提案 409 的
|
||||
// 两种语义必须分开表达。
|
||||
|
||||
describe('requiresCurrentPassword', () => {
|
||||
it('current_required 必须要当前密码,grant_setup 免当前密码', () => {
|
||||
expect(requiresCurrentPassword('current_required')).toBe(true)
|
||||
expect(requiresCurrentPassword('grant_setup')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanChangePasswordError', () => {
|
||||
it('后端 current password incorrect 映射为当前密码不正确', () => {
|
||||
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanRedeemError', () => {
|
||||
it('401/409/429 语义分离', () => {
|
||||
expect(humanRedeemError('409: login key already used')).toContain('已被使用')
|
||||
expect(humanRedeemError('401: invalid or expired login key')).toContain('无效或已过期')
|
||||
expect(humanRedeemError('429: too many failed attempts')).toContain('稍后再试')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryStatusLabel', () => {
|
||||
it('覆盖全部五种提案状态', () => {
|
||||
for (const status of ['pending', 'confirmed', 'completed', 'revoked', 'expired'] as const) {
|
||||
expect(recoveryStatusLabel[status]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryProposalHint', () => {
|
||||
it('pending/confirmed 给出下一步,终态不给', () => {
|
||||
expect(recoveryProposalHint('pending')).toContain('本人确认')
|
||||
expect(recoveryProposalHint('confirmed')).toContain('新密码')
|
||||
expect(recoveryProposalHint('completed')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryProposalErrorMessage', () => {
|
||||
it('OWNER_PROOF_UNAVAILABLE 与活动提案冲突分开表达', () => {
|
||||
expect(recoveryProposalErrorMessage('409: OWNER_PROOF_UNAVAILABLE')).toContain('会话证明')
|
||||
expect(recoveryProposalErrorMessage('409: store: conflict')).toContain('已有进行中的恢复提案')
|
||||
})
|
||||
it('未识别错误剥状态码原样透出', () => {
|
||||
expect(recoveryProposalErrorMessage('500: boom')).toBe('boom')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
// 恢复提案展示与错误文案(纯函数):管理端 UsersPanel 与本人 MyAccountSettings
|
||||
// 共用,vitest 锁定语义防漂移。后端 409 两种含义按响应体区分:
|
||||
// OWNER_PROOF_UNAVAILABLE=无合格会话证明;其余 conflict=已有活动提案。
|
||||
import type { RecoveryProposal } from './api'
|
||||
|
||||
export const recoveryStatusLabel: Record<RecoveryProposal['status'], string> = {
|
||||
pending: '待本人确认',
|
||||
confirmed: '已确认,待兑换设密',
|
||||
completed: '已完成',
|
||||
revoked: '已撤销',
|
||||
expired: '已过期',
|
||||
}
|
||||
|
||||
/** 管理端视角的下一步指引:提案成立后动作在属主侧(设置 → 账号)。 */
|
||||
export function recoveryProposalHint(status: RecoveryProposal['status']): string {
|
||||
if (status === 'pending') return '等待该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现'
|
||||
if (status === 'confirmed') return '该用户已确认;请提醒其尽快在受限会话中完成新密码设置,到期未用需重新生成'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function recoveryProposalErrorMessage(raw: string): string {
|
||||
if (raw.includes('OWNER_PROOF_UNAVAILABLE')) {
|
||||
return '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置'
|
||||
}
|
||||
if (raw.includes('409')) return '该用户已有进行中的恢复提案;请先撤销旧提案或等它完成/过期'
|
||||
if (raw.includes('403')) return '需要管理员身份'
|
||||
if (raw.includes('404')) return '用户不存在或已被移除'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { api } from './api'
|
||||
import type { StreamEvent } from './contracts'
|
||||
|
||||
/** Read the durable record in bounded pages; callers own cancellation and projection. */
|
||||
export async function loadRunEvents(conversationId: string, runId: string, signal: AbortSignal): Promise<StreamEvent[]> {
|
||||
const events: StreamEvent[] = []
|
||||
let after = 0
|
||||
while (true) {
|
||||
const page = await api.runEventsPage(conversationId, runId, after, signal)
|
||||
const rows = page.events ?? []
|
||||
events.push(...rows)
|
||||
if (rows.length < 500) return events
|
||||
const next = rows[rows.length - 1].seq
|
||||
if (next <= after) throw new Error('process event cursor did not advance')
|
||||
after = next
|
||||
}
|
||||
}
|
||||
+1
-21
@@ -1,24 +1,4 @@
|
||||
// 中栏 → 右栏单向滚动同步,以及互定位后的 1.2s 闪烁。
|
||||
// 比例按可滚动范围计算;任一侧没有溢出时不同步,避免把静止栏拽动。
|
||||
|
||||
export interface ScrollBox {
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
}
|
||||
|
||||
export function scrollRatio(box: ScrollBox): number | null {
|
||||
const range = box.scrollHeight - box.clientHeight
|
||||
if (range <= 0) return null
|
||||
return box.scrollTop / range
|
||||
}
|
||||
|
||||
export function scrollTopForRatio(box: Pick<ScrollBox, 'scrollHeight' | 'clientHeight'>, ratio: number): number | null {
|
||||
const range = box.scrollHeight - box.clientHeight
|
||||
if (range <= 0) return null
|
||||
const clamped = Math.min(1, Math.max(0, ratio))
|
||||
return clamped * range
|
||||
}
|
||||
// Explicit message/card location feedback. The middle and work panes scroll independently.
|
||||
|
||||
/** 给已定位的节点加上 mk-flash,1.2s 后摘掉。 */
|
||||
export function flashElement(el: Element | null) {
|
||||
|
||||
+235
-65
@@ -2,15 +2,17 @@
|
||||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { type AdminZoneSection, api, connectEvents } from './api'
|
||||
import { auth } from './auth'
|
||||
import { projectActionError, projectWorkError } from './projectmeta'
|
||||
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 { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage, type WorkReference } from './outbox'
|
||||
import { canFlushOutbox, deriveShellPhase } from './runstate'
|
||||
import { flashElement } from './scrollsync'
|
||||
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
|
||||
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
|
||||
import { rdmAuditReady } from './workspaces'
|
||||
import { rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmPermMatrixReady } from './rdmManagement'
|
||||
import { cockpitReady, rdmAuditReady, rdmExportReady } from './workspaces'
|
||||
import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement'
|
||||
|
||||
type FontSizeChoice = 13 | 14 | 16 | 18
|
||||
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
|
||||
@@ -32,7 +34,8 @@ import type {
|
||||
StreamEvent,
|
||||
SurfaceMeta,
|
||||
SurfaceSnapshot,
|
||||
TodoList
|
||||
TodoList,
|
||||
WorkCardAction
|
||||
} from './contracts'
|
||||
|
||||
export interface ContextObs {
|
||||
@@ -111,6 +114,8 @@ interface AppState {
|
||||
projectCards: ActivityCard[]
|
||||
/** 同一操作的双入口反馈;未知结果必须先对账,不重新发送副作用。 */
|
||||
cardActionStates: Record<string, 'submitting' | 'unknown'>
|
||||
workRecord: { cardId: string; runId: string; conversationId: string; title: string } | null
|
||||
workReference: WorkReference | null
|
||||
inboxScope: 'conv' | 'project'
|
||||
/** runtime.mock:只控制演示指令提示,不改变对话能力。 */
|
||||
runtimeMock: boolean
|
||||
@@ -119,6 +124,9 @@ interface AppState {
|
||||
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
|
||||
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
|
||||
rdmAuditAvailable: boolean
|
||||
rdmCockpitAvailable: boolean
|
||||
/** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */
|
||||
rdmExportAvailable: boolean
|
||||
/** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */
|
||||
rdmRolesAvailable: boolean
|
||||
rdmMembersAvailable: boolean
|
||||
@@ -189,9 +197,11 @@ let state: AppState = {
|
||||
contextObs: {},
|
||||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
serverWorkspaces: [],
|
||||
rdmAuditAvailable: false,
|
||||
rdmCockpitAvailable: false,
|
||||
rdmExportAvailable: false,
|
||||
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
|
||||
adminSections: [],
|
||||
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
||||
@@ -279,6 +289,10 @@ let sse: { close: () => void } | null = null
|
||||
let projectSse: { close: () => void } | null = null
|
||||
let projectSeq = 0
|
||||
let conversationEpoch = 0
|
||||
let identityEpoch = 0
|
||||
let projectsRequest = 0
|
||||
let tasksRequest = 0
|
||||
let meRequest = 0
|
||||
let streamSeq = 0
|
||||
let detailRequest = 0
|
||||
let detailSeq = 0
|
||||
@@ -297,6 +311,7 @@ let cardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
|
||||
let projectCardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
|
||||
const actionInflight = new Set<string>()
|
||||
const actionFeedbackOwners: Record<string, symbol> = {}
|
||||
const workActionExpectations = new Map<string, { actionId: string; payload: WorkCardAction }>()
|
||||
|
||||
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
|
||||
const version = ++detailVersion
|
||||
@@ -357,7 +372,7 @@ function actionFeedback(key: string, value?: 'submitting' | 'unknown', ids: stri
|
||||
if (value ? current[id] === value : !(id in current)) continue
|
||||
states ??= { ...state.cardActionStates }
|
||||
if (value) states[id] = value
|
||||
else { delete states[id]; delete actionFeedbackOwners[id] }
|
||||
else { delete states[id]; delete actionFeedbackOwners[id]; workActionExpectations.delete(id) }
|
||||
}
|
||||
if (states) set({ cardActionStates: states })
|
||||
}
|
||||
@@ -372,13 +387,22 @@ function startActionFeedback(key: string, ids: string[] = []) {
|
||||
function settleCardFeedback(cards: ActivityCard[]) {
|
||||
let states: AppState['cardActionStates'] | undefined
|
||||
for (const card of cards) {
|
||||
if (card.status === 'action_required') continue
|
||||
const expected = workActionExpectations.get(card.id)
|
||||
if (expected) {
|
||||
const decision = card.work?.decision?.state
|
||||
const settled = expected.actionId === 'work.read' ? (card.work?.readVersion ?? 0) >= expected.payload.resultVersion
|
||||
: expected.actionId === 'work.defer' ? decision === 'deferred'
|
||||
: expected.actionId === 'work.reopen' ? decision === 'open' : decision === 'submitted'
|
||||
if (!settled) continue
|
||||
}
|
||||
if (!expected && card.status === 'action_required') continue
|
||||
const source = hitlSource(card)
|
||||
for (const id of [card.id, ...(source ? [`hitl:${source}`] : [])]) {
|
||||
if (!(id in (states ?? state.cardActionStates))) continue
|
||||
states ??= { ...state.cardActionStates }
|
||||
delete states[id]
|
||||
delete actionFeedbackOwners[id]
|
||||
workActionExpectations.delete(id)
|
||||
}
|
||||
}
|
||||
if (states) set({ cardActionStates: states })
|
||||
@@ -398,24 +422,59 @@ export function useStore<T>(selector: (s: AppState) => T): T {
|
||||
|
||||
export const store = {
|
||||
getState: () => state,
|
||||
getIdentityEpoch: () => identityEpoch,
|
||||
|
||||
resetIdentity() {
|
||||
identityEpoch++; projectsRequest++; tasksRequest++; meRequest++
|
||||
sse?.close(); sse = null
|
||||
projectSse?.close(); projectSse = null
|
||||
conversationEpoch++; detailRequest++; cardsRequest++; projectCardsRequest++
|
||||
projectSeq = 0; streamSeq = 0; detailSeq = 0; detailVersion = 0
|
||||
cardVersion = 0; projectCardVersion = 0
|
||||
cardVersions = {}; projectCardVersions = {}; cardPatches = {}; projectCardPatches = {}
|
||||
runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||||
actionInflight.clear()
|
||||
workActionExpectations.clear()
|
||||
for (const key of Object.keys(actionFeedbackOwners)) delete actionFeedbackOwners[key]
|
||||
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
|
||||
convRestoreDone = true
|
||||
persistConvId(null)
|
||||
set({ projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
|
||||
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
|
||||
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
|
||||
rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
|
||||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
|
||||
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
|
||||
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
|
||||
setUI({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
|
||||
settingsOpen: false, changePwOpen: false, trackerRunId: null, workspaceSurfacePin: null,
|
||||
previewExpanded: false, filesView: 'list', showArchived: false })
|
||||
},
|
||||
|
||||
async refreshProjects() {
|
||||
const epoch = identityEpoch
|
||||
const request = ++projectsRequest
|
||||
try {
|
||||
const projects = await api.listProjects()
|
||||
if (epoch !== identityEpoch || request !== projectsRequest) return
|
||||
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
||||
set({ projects, currentProjectId: current?.id ?? null, error: null })
|
||||
if (current) await store.openProject(current.id)
|
||||
} catch (e) {
|
||||
set({ error: String(e) })
|
||||
if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) })
|
||||
}
|
||||
store.refreshTasks()
|
||||
store.refreshMe()
|
||||
if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks()
|
||||
},
|
||||
|
||||
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
|
||||
async refreshTasks(includeArchived = state.ui.showArchived) {
|
||||
const epoch = identityEpoch
|
||||
const request = ++tasksRequest
|
||||
try {
|
||||
const tasks = await api.listTasks(includeArchived)
|
||||
if (epoch !== identityEpoch || request !== tasksRequest) return
|
||||
set({ tasks })
|
||||
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
|
||||
if (!convRestoreDone) {
|
||||
@@ -429,14 +488,35 @@ export const store = {
|
||||
},
|
||||
|
||||
async refreshMe() {
|
||||
try { set({ me: await api.me() }) } catch { /* 身份不可用时侧栏只显示通用入口 */ }
|
||||
const epoch = identityEpoch
|
||||
const request = ++meRequest
|
||||
try {
|
||||
const me = await api.me()
|
||||
if (epoch !== identityEpoch || request !== meRequest) return
|
||||
const changed = state.me !== null && state.me.owner !== me.owner
|
||||
if (changed) store.resetIdentity()
|
||||
set({ me })
|
||||
if (changed) {
|
||||
await store.refreshProjects()
|
||||
void store.refreshProfiles()
|
||||
void store.refreshPlugins()
|
||||
}
|
||||
} catch { /* 身份不可用时侧栏只显示通用入口 */ }
|
||||
},
|
||||
|
||||
async createProject(name: string) {
|
||||
const p = await api.createProject(name)
|
||||
await store.refreshProjects()
|
||||
store.toast(`项目「${p.name}」已创建`)
|
||||
return p
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
const p = await api.createProject(name)
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
await store.refreshProjects()
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
store.toast(`项目「${p.name}」已创建`)
|
||||
return p
|
||||
} catch (e) {
|
||||
if (epoch === identityEpoch) store.toast(`项目创建失败:${projectActionError(e)}`)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
async openProject(projectId: string) {
|
||||
@@ -449,7 +529,7 @@ export const store = {
|
||||
conversationEpoch++; streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||||
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
cards: [], projectCards: [], plan: null, todos: null, surfaces: [], projectSurfaces: [],
|
||||
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
|
||||
artifacts: [], outbox: [], outboxDraining: false, posting: false })
|
||||
setUI({ previewExpanded: false, filesView: 'list' })
|
||||
}
|
||||
@@ -465,28 +545,43 @@ export const store = {
|
||||
/** 新建任务(=会话):默认当前项目上下文;显式 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
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
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 (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
const error = projectWorkError(state.projects.find(p => p.id === pid))
|
||||
if (error) throw new Error(error)
|
||||
if (pid !== state.currentProjectId) await store.openProject(pid)
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
const c = await api.createConversation(pid)
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
await store.refreshTasks()
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
await store.openTask(c.id)
|
||||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||||
} catch (e) {
|
||||
if (epoch === identityEpoch) store.toast(`对话创建失败:${projectActionError(e)}`)
|
||||
throw e
|
||||
}
|
||||
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) {
|
||||
if (title) store.toast('已在当前项目创建任务')
|
||||
await store.createTask()
|
||||
if (title) store.toast('已在当前项目创建任务')
|
||||
},
|
||||
|
||||
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
|
||||
async openTask(convId: string) {
|
||||
const epoch = identityEpoch
|
||||
const t = state.tasks.find(x => x.id === convId)
|
||||
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
|
||||
if (epoch !== identityEpoch) return
|
||||
await store.openConversation(convId)
|
||||
},
|
||||
|
||||
@@ -524,7 +619,7 @@ export const store = {
|
||||
const epoch = ++conversationEpoch
|
||||
streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], workRecord: null, workReference: null, surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||
setUI({ previewExpanded: false })
|
||||
persistConvId(convId)
|
||||
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
|
||||
@@ -637,6 +732,9 @@ export const store = {
|
||||
if (e.runId && liveEventTypes.has(e.type)) observeDetail(runObservations, e.runId, e.seq)
|
||||
const now = eventTime(e)
|
||||
const project = (mutate: (l: LiveState) => void) => touchLive(e.runId ?? '', mutate, now)
|
||||
const belongsToLive = (agentRun: string) => !!state.live && (e.runId === state.live.runId
|
||||
|| Object.prototype.hasOwnProperty.call(state.live.agentNotes, agentRun)
|
||||
|| state.runs.some(run => run.id === agentRun && run.parentRunId === state.live?.runId))
|
||||
switch (e.type) {
|
||||
case 'message.delta': {
|
||||
const p = e.payload as { index: number; text: string }
|
||||
@@ -698,17 +796,17 @@ export const store = {
|
||||
}
|
||||
case 'agent.spawn': {
|
||||
const p = e.payload as { agentRun: string; name: string; task: string }
|
||||
if (state.live) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: `已派生 ${p.name}` } } })
|
||||
if (state.live && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: `已派生 ${p.name}` } } })
|
||||
break
|
||||
}
|
||||
case 'agent.progress': {
|
||||
const p = e.payload as { agentRun: string; note?: string }
|
||||
if (state.live && p.note) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.note } } })
|
||||
if (state.live && p.note && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.note } } })
|
||||
break
|
||||
}
|
||||
case 'agent.result': {
|
||||
const p = e.payload as { agentRun: string; summary?: string }
|
||||
if (state.live) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.summary ?? '任务已结束' } } })
|
||||
if (state.live && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.summary ?? '任务已结束' } } })
|
||||
store.reconcile()
|
||||
break
|
||||
}
|
||||
@@ -809,7 +907,7 @@ export const store = {
|
||||
}
|
||||
},
|
||||
|
||||
async sendMessage(text: string, attachmentIds: string[], requestId?: string): Promise<'ok' | 'busy'> {
|
||||
async sendMessage(text: string, attachmentIds: string[], requestId?: string, taskRef?: WorkReference): Promise<'ok' | 'busy' | 'rejected'> {
|
||||
const convId = state.currentConvId
|
||||
const epoch = conversationEpoch
|
||||
const sameContext = () => state.currentConvId === convId && conversationEpoch === epoch
|
||||
@@ -817,7 +915,7 @@ export const store = {
|
||||
const id = requestId ?? newQueuedMessage(text, attachmentIds, []).id
|
||||
set({ posting: true })
|
||||
try {
|
||||
const out = await api.sendMessage(convId, text, attachmentIds, id)
|
||||
const out = await (taskRef ? api.sendMessage(convId, text, attachmentIds, id, taskRef) : api.sendMessage(convId, text, attachmentIds, id))
|
||||
if (!sameContext()) return 'ok'
|
||||
if (out.command) {
|
||||
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
|
||||
@@ -843,8 +941,8 @@ export const store = {
|
||||
// 竞态保护: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
|
||||
? { runId: started.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: eventTime({ ts: started.createdAt }) }
|
||||
: { ...state.live, startedAt: eventTime({ ts: started.createdAt }, state.live.startedAt) }
|
||||
set({ live, runs, messages })
|
||||
store.connectIfNeeded()
|
||||
}
|
||||
@@ -852,19 +950,22 @@ export const store = {
|
||||
} catch (e) {
|
||||
if (!sameContext()) return 'ok'
|
||||
if (isConversationBusy(e)) {
|
||||
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [] }) })
|
||||
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [], ...(taskRef ? { taskRef } : {}) }) })
|
||||
return 'busy'
|
||||
}
|
||||
store.toast(`发送失败:${e}`)
|
||||
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds,
|
||||
attachmentNames: attachmentIds.map(attachmentId => state.artifacts.find(artifact => artifact.id === attachmentId)?.name ?? '已添加材料'),
|
||||
...(taskRef ? { taskRef } : {}), deliveryError: '发送未成功,内容已保留;可修改后重试或移除' }) })
|
||||
await store.reconcile()
|
||||
return 'ok'
|
||||
return 'rejected'
|
||||
} finally {
|
||||
if (sameContext()) set({ posting: false })
|
||||
}
|
||||
},
|
||||
|
||||
enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[]) {
|
||||
set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames)) })
|
||||
enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[], taskRef?: WorkReference) {
|
||||
set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames, taskRef)) })
|
||||
},
|
||||
|
||||
updateOutbox(id: string, text: string) {
|
||||
@@ -875,6 +976,10 @@ export const store = {
|
||||
set({ outbox: removeQueuedMessage(state.outbox, id) })
|
||||
},
|
||||
|
||||
clearOutboxReference(id: string) {
|
||||
set({ outbox: state.outbox.map(item => item.id === id ? { ...item, taskRef: undefined } : item) })
|
||||
},
|
||||
|
||||
async sendOutboxNow(id: string) {
|
||||
const item = state.outbox.find(q => q.id === id)
|
||||
if (!item) return
|
||||
@@ -885,23 +990,23 @@ export const store = {
|
||||
return
|
||||
}
|
||||
set({ outbox: rest })
|
||||
await store.sendMessage(item.text, item.attachmentIds, deliveryRequestId(item))
|
||||
await store.sendMessage(item.text, item.attachmentIds, deliveryRequestId(item), item.taskRef)
|
||||
},
|
||||
|
||||
async flushOutbox() {
|
||||
if (state.outboxDraining) return
|
||||
if (!canFlushOutbox(shellPhase())) return
|
||||
const next = state.outbox[0]
|
||||
const next = state.outbox.find(item => !item.deliveryError)
|
||||
if (!next) return
|
||||
set({ outbox: state.outbox.slice(1), outboxDraining: true })
|
||||
let result: 'ok' | 'busy' = 'ok'
|
||||
set({ outbox: removeQueuedMessage(state.outbox, next.id), outboxDraining: true })
|
||||
let result: 'ok' | 'busy' | 'rejected' = 'ok'
|
||||
try {
|
||||
result = await store.sendMessage(next.text, next.attachmentIds, deliveryRequestId(next))
|
||||
result = await store.sendMessage(next.text, next.attachmentIds, deliveryRequestId(next), next.taskRef)
|
||||
} finally {
|
||||
set({ outboxDraining: false })
|
||||
}
|
||||
if (result === 'busy') return
|
||||
if (canFlushOutbox(shellPhase()) && state.outbox.length > 0) {
|
||||
if (canFlushOutbox(shellPhase()) && state.outbox.some(item => !item.deliveryError)) {
|
||||
await store.flushOutbox()
|
||||
}
|
||||
},
|
||||
@@ -986,7 +1091,8 @@ export const store = {
|
||||
|
||||
// 卡片动作:确认投影卡(来源 hitl:)路由回 HITL 解析(恢复挂起 run,服务端同步销项);
|
||||
// 其余卡走卡片动作 API,并把服务端持久化的回执消息追加进本地时间线(id 一致,对账不重复)。
|
||||
async cardAction(card: ActivityCard, actionId: string, freeText?: string) {
|
||||
async cardAction(card: ActivityCard, actionId: string, payload?: string | WorkCardAction) {
|
||||
const freeText = typeof payload === 'object' ? payload.freeText : payload
|
||||
const source = hitlSource(card)
|
||||
if (source) {
|
||||
await store.resolveHITL(source, actionId ? { choiceId: actionId } : { freeText })
|
||||
@@ -996,12 +1102,14 @@ export const store = {
|
||||
const convId = state.currentConvId
|
||||
const pid = state.currentProjectId
|
||||
const epoch = conversationEpoch
|
||||
const identity = identityEpoch
|
||||
const startedVersion = card.conversationId === state.currentConvId ? cardVersion : projectCardVersion
|
||||
const sameContext = () => state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
|
||||
const sameContext = () => identity === identityEpoch && state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
|
||||
actionInflight.add(card.id)
|
||||
const feedbackOwner = startActionFeedback(card.id)
|
||||
if (actionId.startsWith('work.') && typeof payload === 'object') workActionExpectations.set(card.id, { actionId, payload })
|
||||
try {
|
||||
const out = await api.cardAction(card.id, actionId, freeText)
|
||||
const out = await api.cardAction(card.id, actionId, payload)
|
||||
if (!sameContext()) return
|
||||
const newer = (card.conversationId === state.currentConvId ? cardVersions[card.id] : projectCardVersions[card.id]) > startedVersion
|
||||
if (!newer && card.conversationId === state.currentConvId) store.upsertCard(out.card)
|
||||
@@ -1010,7 +1118,7 @@ export const store = {
|
||||
const cards = state.projectCards
|
||||
set({ projectCards: cards.some(c => c.id === out.card.id) ? cards.map(c => c.id === out.card.id ? out.card : c) : [out.card, ...cards] })
|
||||
}
|
||||
if (out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) {
|
||||
if (!actionId.startsWith('work.') && out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) {
|
||||
observeDetail(messageObservations, out.receipt.id)
|
||||
set({ messages: [...state.messages, out.receipt as Message] })
|
||||
}
|
||||
@@ -1021,7 +1129,7 @@ export const store = {
|
||||
}
|
||||
if (sameContext()) {
|
||||
const current = [...state.cards, ...state.projectCards].find(c => c.id === card.id)
|
||||
actionFeedback(card.id, actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
|
||||
actionFeedback(card.id, !actionId.startsWith('work.') && actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
|
||||
}
|
||||
} catch (e) {
|
||||
const rejected = /^4\d\d:/.test(String(e instanceof Error ? e.message : e))
|
||||
@@ -1035,57 +1143,82 @@ export const store = {
|
||||
else await store.loadProjectCards()
|
||||
}
|
||||
} finally {
|
||||
actionInflight.delete(card.id)
|
||||
if (identity === identityEpoch) actionInflight.delete(card.id)
|
||||
}
|
||||
},
|
||||
|
||||
async cardPin(card: ActivityCard, pinned: boolean) {
|
||||
try { store.upsertCard((await api.cardPin(card.id, pinned)).card) } catch (e) { store.toast(`置顶失败:${e}`) }
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
const out = await api.cardPin(card.id, pinned)
|
||||
if (epoch === identityEpoch) store.upsertCard(out.card)
|
||||
} catch (e) { if (epoch === identityEpoch) store.toast(`置顶失败:${e}`) }
|
||||
},
|
||||
|
||||
async cardDismiss(card: ActivityCard) {
|
||||
try { store.upsertCard((await api.cardDismiss(card.id)).card) } catch (e) { store.toast(`关闭失败:${e}`) }
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
const out = await api.cardDismiss(card.id)
|
||||
if (epoch === identityEpoch) store.upsertCard(out.card)
|
||||
} catch (e) { if (epoch === identityEpoch) store.toast(`关闭失败:${e}`) }
|
||||
},
|
||||
|
||||
async refreshArtifacts(projectId: string) {
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
const artifacts = await api.listArtifacts(projectId)
|
||||
if (state.currentProjectId !== projectId) return
|
||||
if (epoch !== identityEpoch || state.currentProjectId !== projectId) return
|
||||
set({ artifacts })
|
||||
} catch { /* 项目删除竞态等,静默 */ }
|
||||
},
|
||||
|
||||
async refreshProfiles() {
|
||||
try { set({ profiles: await api.listProfiles() }) } catch { /* ignore */ }
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
const profiles = await api.listProfiles()
|
||||
if (epoch === identityEpoch) set({ profiles })
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
|
||||
async refreshPlugins() {
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
const list = await api.listPlugins() as { panels?: PluginPanel[] }[]
|
||||
if (epoch !== identityEpoch) return
|
||||
const panels = list.flatMap(p => p.panels ?? [])
|
||||
set({ pluginPanels: panels })
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const ws = await api.listWorkspaces()
|
||||
if (epoch !== identityEpoch) return
|
||||
set({ serverWorkspaces: ws ?? [] })
|
||||
} catch { /* ignore */ }
|
||||
// 审计入口能力门控(增量 2):与工作区清单同源刷新——能力撤销(插件卸载/命令停用)后入口随之隐藏。
|
||||
try {
|
||||
const cmds = await api.listCommands()
|
||||
if (epoch !== identityEpoch) return
|
||||
set({
|
||||
rdmAuditAvailable: rdmAuditReady(cmds),
|
||||
rdmCockpitAvailable: cockpitReady(cmds),
|
||||
rdmExportAvailable: rdmExportReady(cmds),
|
||||
rdmRolesAvailable: rdmRolesReady(cmds),
|
||||
rdmMembersAvailable: rdmMembersReady(cmds),
|
||||
rdmDocReviewAvailable: rdmDocReviewReady(cmds),
|
||||
rdmDocReviewAvailable: rdmDutyReady(cmds),
|
||||
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
|
||||
})
|
||||
} catch { set({ rdmAuditAvailable: false }) }
|
||||
} catch {
|
||||
if (epoch !== identityEpoch) return
|
||||
set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
|
||||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false })
|
||||
}
|
||||
try {
|
||||
const sections = await api.adminZoneSections()
|
||||
if (epoch !== identityEpoch) return
|
||||
set({ adminSections: sections ?? [] })
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const settings = await api.getSettings()
|
||||
if (epoch !== identityEpoch) return
|
||||
const runtime = settings.user.runtime
|
||||
const mock = !!runtime && typeof runtime === 'object' && (runtime as { mock?: unknown }).mock === true
|
||||
set({ runtimeMock: mock })
|
||||
@@ -1094,15 +1227,16 @@ export const store = {
|
||||
|
||||
async uploadAttachment(file: File): Promise<string | null> {
|
||||
const pid = state.currentProjectId
|
||||
const epoch = identityEpoch
|
||||
if (!pid) return null
|
||||
try {
|
||||
const up = await api.upload(pid, file)
|
||||
if (state.currentProjectId !== pid) return null
|
||||
if (epoch !== identityEpoch || state.currentProjectId !== pid) return null
|
||||
store.toast(`已收到材料:${up.filename}${up.parsed ? ' · 已读取内容' : ''}`)
|
||||
await store.refreshArtifacts(pid)
|
||||
return up.artifactId
|
||||
return epoch === identityEpoch && state.currentProjectId === pid ? up.artifactId : null
|
||||
} catch (e) {
|
||||
if (state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择')
|
||||
if (epoch === identityEpoch && state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择')
|
||||
return null
|
||||
}
|
||||
},
|
||||
@@ -1140,9 +1274,10 @@ export const store = {
|
||||
|
||||
connectProject() {
|
||||
const pid = store.getState().currentProjectId
|
||||
const epoch = identityEpoch
|
||||
if (!pid) return
|
||||
projectSse?.close()
|
||||
projectSse = connectEvents('', projectSeq, { onEvent: e => { if (state.currentProjectId === pid) store.applyProjectEvent(e) } }, pid)
|
||||
projectSse = connectEvents('', projectSeq, { onEvent: e => { if (epoch === identityEpoch && state.currentProjectId === pid) store.applyProjectEvent(e) } }, pid)
|
||||
},
|
||||
|
||||
applyProjectEvent(e: StreamEvent) {
|
||||
@@ -1183,8 +1318,10 @@ export const store = {
|
||||
},
|
||||
|
||||
async cardToProject(card: ActivityCard) {
|
||||
const epoch = identityEpoch
|
||||
try {
|
||||
const out = await api.cardToProject(card.id)
|
||||
if (epoch !== identityEpoch) return
|
||||
store.upsertCard(out.origin) // 会话视图:原卡 dismissed 留痕
|
||||
const list = store.getState().projectCards
|
||||
const i = list.findIndex(c => c.id === out.card.id)
|
||||
@@ -1192,17 +1329,23 @@ export const store = {
|
||||
store.toast('已留档到项目收件箱')
|
||||
store.setInboxScope('project')
|
||||
} catch (e) {
|
||||
store.toast(`留档失败:${e}`)
|
||||
if (epoch === identityEpoch) store.toast(`留档失败:${e}`)
|
||||
}
|
||||
},
|
||||
|
||||
async tidyCards() {
|
||||
const epoch = identityEpoch
|
||||
// U1:不收纳失败语义卡(failed,或旧 done+danger),避免把失败当完成清掉。
|
||||
const done = state.cards.filter(c => c.status === 'done' && c.tone !== 'danger')
|
||||
for (const c of done) {
|
||||
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
|
||||
if (epoch !== identityEpoch) return
|
||||
try {
|
||||
const out = await api.cardDismiss(c.id)
|
||||
if (epoch !== identityEpoch) return
|
||||
store.upsertCard(out.card)
|
||||
} catch { /* 单卡失败不阻断 */ }
|
||||
}
|
||||
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
|
||||
if (epoch === identityEpoch && done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
|
||||
},
|
||||
closeWorkspace() { setUI({ activeWorkspace: null }) },
|
||||
/** 铃铛:打开右栏并切到待处理。哪一侧有待办就看哪一侧。 */
|
||||
@@ -1213,9 +1356,33 @@ export const store = {
|
||||
setUI({ rightCollapsed: false, rightTab: 'work', inboxFilter: 'todo' })
|
||||
},
|
||||
openInbox(cardId?: string) {
|
||||
setUI({ rightCollapsed: false, rightTab: 'work' })
|
||||
if (state.workRecord) set({ workRecord: null })
|
||||
if (cardId) {
|
||||
const scope = state.cards.some(card => card.id === cardId) ? 'conv'
|
||||
: state.projectCards.some(card => card.id === cardId) ? 'project' : 'conv'
|
||||
set({ inboxScope: scope })
|
||||
}
|
||||
setUI({ rightCollapsed: false, rightTab: 'work', ...(cardId ? { inboxFilter: 'all' as const } : {}) })
|
||||
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
|
||||
},
|
||||
openWorkRecord(card: ActivityCard) {
|
||||
if (!card.sourceRunId || !card.conversationId || card.conversationId !== state.currentConvId || card.projectId !== state.currentProjectId) {
|
||||
store.toast('请先打开这项工作所属的对话,再查看执行记录')
|
||||
return
|
||||
}
|
||||
set({ workRecord: { cardId: card.id, runId: card.sourceRunId, conversationId: card.conversationId, title: card.title } })
|
||||
},
|
||||
closeWorkRecord() { set({ workRecord: null }) },
|
||||
referenceWork(card: ActivityCard) {
|
||||
if (card.projectId !== state.currentProjectId || card.conversationId !== state.currentConvId || !card.work?.result?.trim()) {
|
||||
store.toast('请在这项工作所属的对话中引用结果')
|
||||
return
|
||||
}
|
||||
set({ workReference: { cardId: card.id, resultVersion: card.work.resultVersion, title: card.title } })
|
||||
if (card.work.resultVersion > card.work.readVersion) void store.cardAction(card, 'work.read', { resultVersion: card.work.resultVersion })
|
||||
if (typeof document !== 'undefined') document.querySelector<HTMLTextAreaElement>('.input-row textarea')?.focus()
|
||||
},
|
||||
clearWorkReference() { set({ workReference: null }) },
|
||||
/** 左栏/收起轨的「文件」入口:打开第三栏文件入口(列表或既有预览)。 */
|
||||
openFiles() { setUI({ rightTab: 'files', rightCollapsed: false }) },
|
||||
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
|
||||
@@ -1300,10 +1467,13 @@ export const store = {
|
||||
},
|
||||
}
|
||||
|
||||
auth.onIdentityChange(() => store.resetIdentity())
|
||||
|
||||
function touchLive(_runId: string, mutate: (l: LiveState) => void, now = Date.now()): LiveState | null {
|
||||
if (state.runs.find(r => r.id === _runId)?.kind === 'subagent') return state.live
|
||||
if (state.live && state.live.runId !== _runId && !isTerminal(state.runs.find(r => r.id === state.live?.runId)?.status)) return state.live
|
||||
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: now }
|
||||
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {},
|
||||
startedAt: eventTime({ ts: state.runs.find(run => run.id === _runId)?.createdAt }, now) }
|
||||
const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } }
|
||||
mutate(next)
|
||||
return next
|
||||
|
||||
@@ -194,6 +194,10 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.tl-tools-sum { margin-left: 8px; color: var(--text-dim); }
|
||||
.tl-check { color: var(--text-dim); font-size: var(--fs-ui-sm); margin: 4px 0; }
|
||||
.tl-tools .status-error { margin-left: 8px; color: var(--err-text); }
|
||||
.tl-tool-detail { margin-top: 4px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
.tl-thinking { margin: 8px 0; padding: 8px 12px; border-left: 2px solid var(--border-strong); color: var(--text-dim); }
|
||||
.tl-thinking-label { display: block; margin-bottom: 4px; font-size: var(--fs-ui-sm); font-weight: 600; }
|
||||
.tl-thinking-body { white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--fs-ui-sm); line-height: 1.6; }
|
||||
|
||||
/* 第三栏「文件」入口内的多标签预览(外壳样式见 .right-panel .preview-pane) */
|
||||
.preview-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
@@ -226,6 +230,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.process-collapse .process-collapse-meta { color: var(--text-dim); }
|
||||
.process-collapse .process-collapse-excerpt { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
|
||||
.process-collapse .body { padding: 4px 12px 10px; }
|
||||
.process-load-note { padding: 8px 0; color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
|
||||
/* 流式状态行(呼吸/扫光走 mk-* 类;已工作计时弱色等宽) */
|
||||
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: var(--fs-ui-sm); margin-bottom: 6px; }
|
||||
@@ -627,7 +632,92 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; }
|
||||
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
|
||||
.rdm-audit-coverage { margin-top: 12px; }
|
||||
.rdm-audit-export { margin-top: 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.rdm-audit-export-row { display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
.rdm-audit-export-kind { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.rdm-audit-export-kind .ui-input { min-width: 180px; height: 32px; font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-export-draft { padding: 8px 10px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--text); }
|
||||
.rdm-audit-export-draft p { margin: 0 0 8px; }
|
||||
.rdm-audit-export-result { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.rdm-audit-export-err { font-size: var(--fs-ui-sm); color: var(--err-text); }
|
||||
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
|
||||
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
|
||||
.rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }
|
||||
.duty-matrix-wrap { overflow: auto; }
|
||||
.duty-matrix .duty-sticky { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
|
||||
.duty-matrix thead th { position: sticky; top: 0; background: var(--bg-panel); z-index: 2; }
|
||||
.duty-cell select { font-size: var(--fs-ui-sm); color: var(--text); background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); }
|
||||
|
||||
/* ---- 文档驾驶舱(I2;令牌驱动,词汇沿用 rdm-audit 卡片体系) ---- */
|
||||
.cockpit { display: flex; flex-direction: column; min-height: 320px; }
|
||||
.cockpit-header { display: flex; align-items: center; gap: 10px; padding: 0 0 10px; }
|
||||
.cockpit-project { max-width: 220px; height: 32px; font-size: var(--fs-ui-sm); }
|
||||
.cockpit-filter-chip, .cockpit-refresh { height: 28px; padding: 0 10px; font-size: var(--fs-ui-sm); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); color: var(--text); cursor: pointer; }
|
||||
.cockpit-snap { margin-left: auto; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
|
||||
.cockpit-banner { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); padding: 6px 10px; margin-bottom: 8px; font-size: var(--fs-ui-sm); }
|
||||
.cockpit-banner-err { border-color: var(--border); }
|
||||
.cockpit-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; margin-bottom: 12px; }
|
||||
.cockpit-stat { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; text-align: center; font: inherit; }
|
||||
button.cockpit-stat { cursor: pointer; }
|
||||
.cockpit-stat-active { border-color: var(--text); }
|
||||
.cockpit-stat-unavail { color: var(--text-muted, var(--text)); }
|
||||
.cockpit-stat-num { display: block; font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
|
||||
.cockpit-stat-label { display: block; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
|
||||
.cockpit-columns { display: grid; grid-template-columns: minmax(220px, 300px) 1fr; gap: 14px; }
|
||||
.cockpit-tree { min-height: 0; overflow-y: auto; }
|
||||
.cockpit-tree-row { display: flex; align-items: center; gap: 6px; padding: 3px 4px; border-radius: var(--radius-sm); }
|
||||
.cockpit-tree-row-active { background: var(--bg-panel); }
|
||||
.cockpit-tree-caret { border: none; background: none; cursor: pointer; padding: 0 2px; font-size: var(--fs-ui-sm); color: var(--text); }
|
||||
.cockpit-tree-title { border: none; background: none; padding: 0; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); text-align: left; }
|
||||
.cockpit-badge { font-size: var(--fs-ui-sm); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0 4px; color: var(--text-muted, var(--text)); }
|
||||
.cockpit-tree-time { margin-left: auto; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
|
||||
.cockpit-tree-empty, .cockpit-tree-more { border: none; background: none; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); padding: 2px 4px; }
|
||||
button.cockpit-tree-more { cursor: pointer; text-decoration: underline; }
|
||||
.cockpit-truncated { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); padding: 4px 0; }
|
||||
.cockpit-center { min-height: 0; overflow-y: auto; }
|
||||
.cockpit-doclist { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.cockpit-docrow { display: flex; align-items: baseline; gap: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); padding: 8px 10px; cursor: pointer; font: inherit; text-align: left; }
|
||||
.cockpit-docrow-title { font-size: var(--fs-ui-base, var(--fs-ui-sm)); color: var(--text); }
|
||||
.cockpit-docrow-meta { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
|
||||
.cockpit-changes { display: flex; flex-direction: column; gap: 4px; }
|
||||
.cockpit-changerow { display: flex; justify-content: space-between; gap: 10px; font-size: var(--fs-ui-sm); }
|
||||
.cockpit-changesum { color: var(--text); }
|
||||
.cockpit-changetime { color: var(--text-muted, var(--text)); }
|
||||
.cockpit-nodehead { display: flex; align-items: baseline; gap: 10px; padding-bottom: 8px; }
|
||||
.cockpit-back { border: none; background: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); padding: 0; text-decoration: underline; }
|
||||
.cockpit-nodetitle { font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
|
||||
.cockpit-nodesum { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
|
||||
.cockpit-childtable { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
|
||||
.cockpit-childtable th, .cockpit-childtable td { border-bottom: 1px solid var(--border); padding: 4px 6px; text-align: left; color: var(--text); }
|
||||
@media (max-width: 760px) { .cockpit-columns { grid-template-columns: 1fr; } .cockpit-tree { max-height: 40vh; } }
|
||||
|
||||
/* ---- 驾驶舱 I3:阅读器与右栏(令牌驱动) ---- */
|
||||
.cockpit-columns-3 { grid-template-columns: minmax(200px, 260px) 1fr minmax(240px, 300px); }
|
||||
.cockpit-right { min-height: 0; overflow-y: auto; border-left: 1px solid var(--border); padding-left: 12px; }
|
||||
.cockpit-viewtoggle { display: flex; gap: 0; }
|
||||
.cockpit-viewbtn { height: 28px; padding: 0 10px; font-size: var(--fs-ui-sm); border: 1px solid var(--border); background: var(--bg-panel); color: var(--text); cursor: pointer; }
|
||||
.cockpit-viewbtn-active { background: var(--text); color: var(--bg-panel); }
|
||||
.cockpit-reader-para { white-space: pre-wrap; font-size: var(--fs-ui-base, var(--fs-ui-sm)); line-height: 1.6; color: var(--text); margin: 6px 0; }
|
||||
.cockpit-density-full .cockpit-reader-para { line-height: 1.9; margin: 10px 0; }
|
||||
.cockpit-reader-section { border-top: 1px solid var(--border); padding: 8px 0; }
|
||||
.cockpit-reader-sechead { display: flex; align-items: baseline; gap: 8px; }
|
||||
.cockpit-reader-sectitle { font-weight: 600; color: var(--text); font-size: var(--fs-ui-base, var(--fs-ui-sm)); }
|
||||
.cockpit-versions { font-size: var(--fs-ui-sm); }
|
||||
.cockpit-density-full .cockpit-versions { line-height: 1.7; }
|
||||
.cockpit-versions-toggle { font-size: var(--fs-ui-sm); color: var(--text); text-decoration: underline; cursor: pointer; }
|
||||
.cockpit-versions-head { color: var(--text-muted, var(--text)); padding: 4px 0; }
|
||||
.cockpit-diff { white-space: pre-wrap; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 8px; font-size: var(--fs-ui-sm); }
|
||||
.cockpit-diff-add { color: var(--text); }
|
||||
.cockpit-diff-del { color: var(--text-muted, var(--text)); text-decoration: line-through; }
|
||||
.cockpit-diff-same { color: var(--text-muted, var(--text)); }
|
||||
.cockpit-panel-tabs { display: flex; gap: 4px; padding-bottom: 8px; }
|
||||
.cockpit-panel-tab { border: 1px solid var(--border); background: none; padding: 3px 8px; font-size: var(--fs-ui-sm); color: var(--text); cursor: pointer; border-radius: var(--radius-sm); }
|
||||
.cockpit-panel-tab-active { background: var(--bg-panel); border-color: var(--text); }
|
||||
.cockpit-panel-body { display: flex; flex-direction: column; gap: 6px; }
|
||||
.cockpit-panel-count { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); }
|
||||
.cockpit-panel-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border); padding: 4px 0; }
|
||||
.cockpit-panel-kind { color: var(--text-muted, var(--text)); }
|
||||
.cockpit-panel-label { color: var(--text); }
|
||||
.cockpit-panel-conflict { flex-direction: column; }
|
||||
.cockpit-conflict-side { color: var(--text); }
|
||||
|
||||
@@ -30,15 +30,51 @@ export const RDM_AUDIT_COMMANDS = [
|
||||
'rdm.inspect.history',
|
||||
] as const
|
||||
|
||||
/** 概览页导出区单独探测。不并入上面的七命令就绪门。 */
|
||||
export const RDM_EXPORT_COMMANDS = [
|
||||
'rdm.inspect.export.create',
|
||||
'rdm.inspect.export.status',
|
||||
'rdm.inspect.export.download',
|
||||
] as const
|
||||
|
||||
/** 三条导出命令都启用才显示概览导出区。缺一或清单不可用则隐藏。 */
|
||||
export function rdmExportReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
|
||||
if (!Array.isArray(cmds)) return false
|
||||
return RDM_EXPORT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
|
||||
}
|
||||
|
||||
/** 正式报告被完整性门拒绝时,界面先确认再带 draft 重试。 */
|
||||
export function exportNeedsDraft(message: string): boolean {
|
||||
return /不完整|草稿/.test(message)
|
||||
}
|
||||
|
||||
/** GET /api/commands 清单是否足以支撑审计工作区:七命令齐备且 enabled。清单不可用(未登录/请求失败)视为未就绪。 */
|
||||
export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
|
||||
if (!Array.isArray(cmds)) return false
|
||||
return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
|
||||
}
|
||||
|
||||
/** 文档驾驶舱消费的全部 rdm.cockpit.* 命令(I1 只读面;rollout I2)。 */
|
||||
export const COCKPIT_COMMANDS = [
|
||||
'rdm.cockpit.overview',
|
||||
'rdm.cockpit.tree',
|
||||
'rdm.cockpit.node',
|
||||
'rdm.cockpit.node.sources',
|
||||
'rdm.cockpit.node.history',
|
||||
'rdm.cockpit.node.impacts',
|
||||
'rdm.cockpit.node.conflicts',
|
||||
] as const
|
||||
|
||||
/** 七命令齐备且 enabled 才展示驾驶舱入口(与 rdmAuditReady 同源语义)。 */
|
||||
export function cockpitReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
|
||||
if (!Array.isArray(cmds)) return false
|
||||
return COCKPIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
|
||||
}
|
||||
|
||||
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
|
||||
{ key: 'agents', title: '代理', icon: '🤖' },
|
||||
{ key: 'rdm-audit', title: '追溯审计', icon: '🔍' },
|
||||
{ key: 'rdm-cockpit', title: '文档驾驶舱', icon: '📋' },
|
||||
]
|
||||
|
||||
function shortcutNum(raw?: string): number | null {
|
||||
@@ -52,6 +88,8 @@ function shortcutNum(raw?: string): number | null {
|
||||
interface BuildWorkspaceOptions {
|
||||
/** rdm.inspect.* 命令面探测通过(store.rdmAuditAvailable)。默认 false:能力未知/缺失不展示审计入口。 */
|
||||
rdmAudit?: boolean
|
||||
/** rdm.cockpit.* 命令面探测通过(store.rdmCockpitAvailable)。默认 false:能力未知/缺失不展示驾驶舱入口。 */
|
||||
rdmCockpit?: boolean
|
||||
}
|
||||
|
||||
export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWorkspaceOptions = {}): WorkspaceEntry[] {
|
||||
@@ -83,6 +121,7 @@ export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWork
|
||||
}
|
||||
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
|
||||
if (opts.rdmAudit === true) ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
|
||||
if (opts.rdmCockpit === true) ranked.push({ rank: takeFree(3), entry: BUILTINS[2] })
|
||||
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
|
||||
return ranked
|
||||
.filter(item => item.rank <= 9)
|
||||
|
||||
@@ -69,6 +69,29 @@ describe('conversation work reading', () => {
|
||||
expect(html).not.toContain('data-testid="input-status"')
|
||||
})
|
||||
|
||||
it('renders stable semantic timestamps on user and assistant messages', () => {
|
||||
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理材料' }]), id: 'user', role: 'user' }, reply([{ type: 'text', text: '材料已收到' }])]
|
||||
const html = render()
|
||||
expect(html.match(/<time class="message-timestamp"/g)).toHaveLength(2)
|
||||
expect(html.match(/dateTime="2026-10-12T10:00:00.000Z"/g)).toHaveLength(2)
|
||||
expect(html).toContain('title="2026/10/12')
|
||||
})
|
||||
|
||||
it('announces unread work without inserting its full result into the chat', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.cards = [card({ status: 'done', work: { result: 'background-result-full-text', resultVersion: 2, readVersion: 1 } })]
|
||||
const html = render()
|
||||
expect(html).toContain('1 项工作有新结果')
|
||||
expect(html).not.toContain('background-result-full-text')
|
||||
})
|
||||
|
||||
it('shows an explicit removable result reference without copying its content into the draft', () => {
|
||||
snapshot.state!.workReference = { cardId: 'work', resultVersion: 2, title: '材料整理' }
|
||||
const html = render()
|
||||
expect(html).toContain('基于「材料整理」的第 2 版结果')
|
||||
expect(html).toContain('aria-label="移除工作结果引用"')
|
||||
})
|
||||
|
||||
it('shows the current real work with the auxiliary pane closed', () => {
|
||||
snapshot.state!.cards = [card()]
|
||||
const html = render()
|
||||
@@ -114,25 +137,30 @@ describe('conversation work reading', () => {
|
||||
if (pinned) expect(html).not.toContain('data-card-id="work_active"')
|
||||
})
|
||||
|
||||
it('keeps the newest completed result as a compact anchor with the auxiliary pane open', () => {
|
||||
it('leaves completed results to the visible right work pane', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.cards = completedResults()
|
||||
const html = renderWork()
|
||||
expect(html).toContain('最新整理成果 · 查看摘要')
|
||||
expect(html).not.toContain('最新成果的主要结论')
|
||||
expect(html).not.toContain('先前整理成果')
|
||||
expect(html).toBe('')
|
||||
})
|
||||
|
||||
it('keeps a compact work anchor when the same summary is visible in the right pane', () => {
|
||||
it('keeps the work card in one detail location when the right work pane is visible', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.cards = [card()]
|
||||
const html = render()
|
||||
expect(html).toContain('整理项目材料')
|
||||
expect(html).not.toContain('data-conversation-work')
|
||||
expect(html).not.toContain('核对会议结论')
|
||||
snapshot.state!.ui.rightCollapsed = true
|
||||
expect(render()).toContain('核对会议结论')
|
||||
})
|
||||
|
||||
it('keeps the conversation detail when the right pane is reading files', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.ui.rightTab = 'files'
|
||||
snapshot.state!.cards = [card()]
|
||||
expect(render()).toContain('核对会议结论')
|
||||
})
|
||||
|
||||
it('keeps secondary message operations available through progressive disclosure', () => {
|
||||
snapshot.state!.messages = [reply([{ type: 'text', text: '已经整理好要点' }])]
|
||||
const html = render()
|
||||
@@ -190,6 +218,29 @@ describe('conversation work reading', () => {
|
||||
expect(html).toContain('确认新增')
|
||||
})
|
||||
|
||||
it('shows only a location link for anchored confirmation when the work pane owns its actions', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理资料' }]), role: 'user' }]
|
||||
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
|
||||
title: '新增一份项目草稿', actions: [{ id: 'confirm', label: '确认新增', style: 'primary' }] })]
|
||||
const html = render()
|
||||
expect(html).toContain('新增一份项目草稿')
|
||||
expect(html).toContain('在工作栏处理')
|
||||
expect(html).not.toContain('确认新增')
|
||||
})
|
||||
|
||||
it('keeps a submitted card as a location link when its full body is in the work pane', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.cards = [card({ id: 'result_1', kind: 'result', title: '材料核对结果', status: 'done',
|
||||
body: [{ type: 'markdown', markdown: '材料核对的完整正文' }] })]
|
||||
snapshot.state!.messages = [reply([{ type: 'card', card: { cardId: 'result_1', kind: 'result', data: [] } }])]
|
||||
const html = render()
|
||||
expect(html).toContain('材料核对结果')
|
||||
expect(html).not.toContain('展开全文')
|
||||
snapshot.state!.ui.rightCollapsed = true
|
||||
expect(render()).toContain('展开全文')
|
||||
})
|
||||
|
||||
it('keeps a required confirmation accessible when its anchor is an assistant message', () => {
|
||||
snapshot.state!.messages = [reply([{ type: 'text', text: '准备新增项目草稿' }])]
|
||||
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
|
||||
@@ -197,12 +248,26 @@ describe('conversation work reading', () => {
|
||||
expect(render()).toContain('确认新增')
|
||||
})
|
||||
|
||||
it('does not reveal internal thinking when records are deliberately opened', () => {
|
||||
it('keeps a thinking-only record available for deliberate review', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
|
||||
parts: [{ type: 'thinking', process: true, text: 'private-internal-deliberation' }],
|
||||
live: true, idKey: 'thinking-test', renderPart: () => null,
|
||||
}))
|
||||
expect(html).not.toContain('private-internal-deliberation')
|
||||
expect(html).toContain('private-internal-deliberation')
|
||||
snapshot.state!.messages = [reply([{ type: 'thinking', process: true, text: 'private-internal-deliberation' },
|
||||
{ type: 'text', text: '已完成核对' }])]
|
||||
expect(render()).toContain('执行记录')
|
||||
})
|
||||
|
||||
it('lets readers reopen the full output of a grouped read action', () => {
|
||||
setPersistedOpen('tool-record:read-history:call-read:0', true)
|
||||
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
|
||||
parts: [{ type: 'tool_call', process: true,
|
||||
toolCall: { id: 'call-read', name: 'fs_read_file', arguments: '{"path":"材料.txt"}' },
|
||||
toolOutput: 'full-read-output' } as Message['parts'][number]],
|
||||
live: false, idKey: 'read-history', renderPart: () => null,
|
||||
}))
|
||||
expect(html).toContain('full-read-output')
|
||||
})
|
||||
|
||||
it('blocks repeated HITL choices when the shared action result is unknown', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 设计系统守护测试(父仓 DESIGN.md §11 禁止清单 · 宿主侧,2026-09-30)。
|
||||
// 棘轮基线:现存豁免登记在下方;新增同类违规直接失败。收紧 = 修掉违规后删基线条目。
|
||||
// 范围:src/styles.css 与 src/**/*.tsx。@ui 组件库侧由 Agent-System-UI 的守护测试覆盖。
|
||||
// 范围:src/**/*.css 与 src/**/*.tsx。@ui 组件库侧由 Agent-System-UI 的守护测试覆盖。
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
|
||||
import { join, relative, dirname } from 'node:path'
|
||||
@@ -36,8 +36,14 @@ describe('DESIGN.md 宪法在位', () => {
|
||||
|
||||
// ---- 2. 动效唯一所有者 ----
|
||||
describe('动效唯一所有者', () => {
|
||||
it('styles.css 无 @keyframes(全部走 @ui/motion.css 的 mk-* 类)', () => {
|
||||
expect(stripCssComments(read(join(SRC, 'styles.css')))).not.toContain('@keyframes')
|
||||
it('宿主 CSS 无 @keyframes 或私有 transition(全部走 @ui/motion.css 的 mk-* 类)', () => {
|
||||
for (const path of walk(SRC).filter(p => p.endsWith('.css'))) {
|
||||
const css = stripCssComments(read(path))
|
||||
expect(css, `${relative(SRC, path)} 不得定义 @keyframes`).not.toContain('@keyframes')
|
||||
const transitions = css.split('\n').filter(line => /transition\s*:/.test(line) && !/transition\s*:\s*none\b/.test(line))
|
||||
if (path.endsWith('styles.css')) continue // 历史 transition 由现有棘轮逐步收紧;新分层 CSS 不再扩散。
|
||||
expect(transitions, `${relative(SRC, path)} 动效必须消费 mk-*`).toEqual([])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +59,16 @@ describe('裸色值棘轮', () => {
|
||||
const extra = found.filter(h => !allow.has(h))
|
||||
expect(extra, `新增裸色值 ${[...new Set(extra)].join(', ')}——改用 tokens 令牌;确需豁免更新 HEX_BASELINE_STYLES 并写明理由`).toEqual([])
|
||||
})
|
||||
it('分层组件 CSS 只消费令牌,不派生私有颜色', () => {
|
||||
const componentCss = walk(SRC).filter(p => p.endsWith('.css') && !p.endsWith('styles.css'))
|
||||
const extra: string[] = []
|
||||
for (const path of componentCss) {
|
||||
const css = stripCssComments(read(path))
|
||||
for (const hex of collectHex(css)) extra.push(`${relative(SRC, path)}: ${hex}`)
|
||||
if (/color-mix\s*\(/.test(css)) extra.push(`${relative(SRC, path)}: color-mix`)
|
||||
}
|
||||
expect(extra, '分层组件 CSS 的颜色须由 @ui/tokens.css 提供').toEqual([])
|
||||
})
|
||||
it('组件 TSX 无裸色值', () => {
|
||||
const extra: string[] = []
|
||||
for (const p of walk(SRC).filter(p => /\.(tsx|ts)$/.test(p) && !p.endsWith('.d.ts'))) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
absorbServerDocs, applyCell, cellDuty, completeness, editCell, replacementNote, saveIssues, savePayload,
|
||||
type DutyDocDraft,
|
||||
} from '../src/duties/draft'
|
||||
|
||||
function doc(over: Partial<DutyDocDraft> = {}): DutyDocDraft {
|
||||
return {
|
||||
docId: 'd1', title: '需求', code: 'UR-1', typeKey: 'design', stage: 'BD',
|
||||
dutyVersion: 3, activeRound: 0, legacySectionOverrides: 0, dirty: false,
|
||||
author: 'alice', reviewers: ['bob'], approver: 'carol',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('文档职责草稿', () => {
|
||||
it('单元格由三权反推,三人视图读到同一份角色', () => {
|
||||
const roles = { author: 'alice', reviewers: ['bob', 'dave'], approver: 'carol' }
|
||||
expect(cellDuty(roles, 'alice')).toBe('author')
|
||||
expect(cellDuty(roles, 'bob')).toBe('reviewer')
|
||||
expect(cellDuty(roles, 'dave')).toBe('reviewer')
|
||||
expect(cellDuty(roles, 'carol')).toBe('approver')
|
||||
expect(cellDuty(roles, 'erin')).toBe('none')
|
||||
expect(completeness(roles)).toEqual({ authors: 1, reviewers: 2, approvers: 1, conflicts: 0 })
|
||||
})
|
||||
|
||||
it('替换编制前先记住原编制,并保证一人一责', () => {
|
||||
const next = applyCell({ author: 'alice', reviewers: ['bob'], approver: 'carol' }, 'bob', 'author')
|
||||
expect(next.replacedAuthor).toBe('alice')
|
||||
expect(next.roles).toEqual({ author: 'bob', reviewers: [], approver: 'carol' })
|
||||
expect(replacementNote({ author: 'alice', reviewers: ['bob'], approver: 'carol' }, 'bob', 'author')).toBe('将替换alice')
|
||||
})
|
||||
|
||||
it('清掉唯一编制后允许留在草稿,但禁止保存', () => {
|
||||
const cleared = editCell([doc()], 'd1', 'alice', 'none')
|
||||
expect(cleared[0].author).toBe('')
|
||||
expect(cleared[0].dirty).toBe(true)
|
||||
expect(saveIssues(cleared[0])).toContain('需指定编制人')
|
||||
expect(savePayload(cleared, false).issues.join(' ')).toContain('需指定编制人')
|
||||
})
|
||||
|
||||
it('过期服务端页不覆盖未保存草稿', () => {
|
||||
const dirty = doc({ author: 'erin', dirty: true, dutyVersion: 3 })
|
||||
const absorbed = absorbServerDocs([dirty], [{
|
||||
docId: 'd1', title: '需求', typeKey: 'design', dutyVersion: 4,
|
||||
}], [{ docId: 'd1', owner: 'alice', duty: 'author' }, { docId: 'd1', owner: 'bob', duty: 'reviewer' }, { docId: 'd1', owner: 'carol', duty: 'approver' }])
|
||||
expect(absorbed[0].author).toBe('erin')
|
||||
expect(absorbed[0].dutyVersion).toBe(3)
|
||||
})
|
||||
|
||||
it('写入结果未知时采用服务端版本,不再留着旧草稿重放', () => {
|
||||
const dirty = doc({ author: 'erin', dirty: true, dutyVersion: 3 })
|
||||
const absorbed = absorbServerDocs([dirty], [{
|
||||
docId: 'd1', title: '需求', typeKey: 'design', dutyVersion: 4,
|
||||
}], [
|
||||
{ docId: 'd1', owner: 'alice', duty: 'author' },
|
||||
{ docId: 'd1', owner: 'bob', duty: 'reviewer' },
|
||||
{ docId: 'd1', owner: 'carol', duty: 'approver' },
|
||||
], true)
|
||||
expect(absorbed[0]).toMatchObject({ author: 'alice', dutyVersion: 4, dirty: false })
|
||||
})
|
||||
|
||||
it('保存载荷是每份脏文档的完整目标,超过 20 份被拒绝', () => {
|
||||
const many = Array.from({ length: 21 }, (_, index) => doc({ docId: `d${index}`, dirty: true }))
|
||||
const blocked = savePayload(many, true)
|
||||
expect(blocked.issues).toContain('一次保存最多 20 份文档')
|
||||
const one = savePayload([doc({ dirty: true, activeRound: 2 })], true)
|
||||
expect(one.docs[0]).toMatchObject({
|
||||
docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'carol',
|
||||
expectedDutyVersion: 3, confirmInvalidation: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated,
|
||||
applyCheck, applyFinalizing,
|
||||
hasSettledReply, liveToMessage, livePhaseLabel, showProcessInline, type LiveItem,
|
||||
runEventsToTimeline,
|
||||
} from '../src/live'
|
||||
import type { StreamEvent } from '../src/contracts'
|
||||
import type { LiveState } from '../src/store'
|
||||
|
||||
function live(over: Partial<LiveState> = {}): LiveState {
|
||||
@@ -159,6 +161,61 @@ describe('liveToMessage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reopens the ordered complete process from persisted run events', () => {
|
||||
const output = 'x'.repeat(5000)
|
||||
const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent =>
|
||||
({ seq, type, payload, runId: 'r1', conversationId: 'c1', ts: '2026-10-14T10:00:00Z', v: 1 })
|
||||
const parts = runEventsToTimeline([
|
||||
event(1, 'thinking.delta', { index: 0, text: '先核对' }),
|
||||
event(2, 'message.delta', { index: 0, text: '开始读取材料' }),
|
||||
event(3, 'tool.call', { callId: 'call1', name: 'fs_read_file', arguments: '{"path":"a.txt"}' }),
|
||||
event(4, 'tool.output', { callId: 'call1', text: output }),
|
||||
event(5, 'tool.result', { callId: 'call1', status: 'ok', summary: '读取成功' }),
|
||||
event(6, 'run.phase', { phase: 'finalizing' }),
|
||||
event(7, 'message.delta', { index: 1, text: '最终回复' }),
|
||||
])
|
||||
expect(parts.map(part => part.type)).toEqual(['thinking', 'text', 'tool_call', 'tool_result'])
|
||||
expect((parts[2] as typeof parts[number] & { toolOutput: string }).toolOutput).toBe(output)
|
||||
expect(parts.some(part => part.text === '最终回复')).toBe(false)
|
||||
})
|
||||
|
||||
it('retains collaboration notes from a child after the parent has replied', () => {
|
||||
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'parent'): StreamEvent =>
|
||||
({ seq, type, payload, runId, conversationId: 'c1', ts: '2026-10-14T10:00:00Z', v: 1 })
|
||||
const parts = runEventsToTimeline([
|
||||
event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
|
||||
event(2, 'run.phase', { phase: 'finalizing' }),
|
||||
event(3, 'message.delta', { index: 0, text: '最终回复' }),
|
||||
event(4, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
|
||||
event(5, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
|
||||
], 'parent')
|
||||
expect(parts.map(part => part.text)).toEqual([
|
||||
'协作代理「资料核对」已开始', '协作进展:正在核对附录', '协作结束:附录已核对',
|
||||
])
|
||||
expect(parts.every(part => part.replaySource === 'agent')).toBe(true)
|
||||
})
|
||||
|
||||
it('coalesces cumulative child updates without mixing tasks or erasing tool phases', () => {
|
||||
const notes = [
|
||||
['one', '我来检索'], ['two', '核对第二份材料'], ['one', '我来检索一下材料'],
|
||||
['one', '调用 knowledge_search'], ['one', '根据检索结果,开始整理'],
|
||||
...Array.from({ length: 80 }, () => ['one', '根据检索结果,开始整理完整报告']),
|
||||
]
|
||||
const events: StreamEvent[] = notes.map(([agentRun, note], seq) => ({
|
||||
seq: seq + 1, type: 'agent.progress', payload: { agentRun, note }, runId: agentRun,
|
||||
conversationId: 'c', ts: '2026-10-15T10:00:00Z', v: 1,
|
||||
}))
|
||||
expect(runEventsToTimeline(events, 'parent').map(part => part.text)).toEqual([
|
||||
'协作进展:我来检索一下材料', '协作进展:核对第二份材料',
|
||||
'协作进展:调用 knowledge_search', '协作进展:根据检索结果,开始整理完整报告',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the stable run start time for the temporary assistant message', () => {
|
||||
expect(liveToMessage(live({ startedAt: Date.parse('2026-10-15T10:00:00Z') })).createdAt)
|
||||
.toBe('2026-10-15T10:00:00.000Z')
|
||||
})
|
||||
|
||||
describe('livePhaseLabel', () => {
|
||||
it('names the current activity in Chinese', () => {
|
||||
let items: LiveItem[] = applyThinkingDelta([], '想', 0)
|
||||
|
||||
+54
-19
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../src/messageparts'
|
||||
import { splitTimeline, mergeProcessTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../src/messageparts'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolOutput, liveToMessage, showProcessInline } from '../src/live'
|
||||
import type { Part } from '../src/contracts'
|
||||
|
||||
@@ -8,7 +8,7 @@ function p(partial: Partial<Part> & { type: Part['type'] }): Part {
|
||||
}
|
||||
|
||||
describe('splitTimeline', () => {
|
||||
it('keeps public Markdown before, between and after tools in reading order', () => {
|
||||
it('keeps intermediate narration with its tools in reading order and the reply outside', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', text: '我会先核对**两份可读材料**。', process: true }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'read' } }),
|
||||
@@ -18,12 +18,11 @@ describe('splitTimeline', () => {
|
||||
p({ type: 'text', text: '接下来确认日期冲突。' }),
|
||||
]
|
||||
const split = splitTimeline(parts)
|
||||
expect(split.final.map(part => part.text)).toEqual([
|
||||
'我会先核对**两份可读材料**。',
|
||||
'评审意见未能读取,完整结论尚未生成。',
|
||||
'接下来确认日期冲突。',
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'tool_call'])
|
||||
expect(split.timeline.filter(part => part.type === 'text').map(part => part.text)).toEqual([
|
||||
'我会先核对**两份可读材料**。', '评审意见未能读取,完整结论尚未生成。',
|
||||
])
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['tool_call', 'tool_result', 'tool_call'])
|
||||
expect(split.final.map(part => part.text)).toEqual(['接下来确认日期冲突。'])
|
||||
})
|
||||
|
||||
it('keeps live scope limits visible while execution records are folded by default', () => {
|
||||
@@ -36,9 +35,8 @@ describe('splitTimeline', () => {
|
||||
expect(message.parts[1].process).toBe(true)
|
||||
expect(showProcessInline(live, true)).toBe(false)
|
||||
const split = splitTimeline(message.parts)
|
||||
expect(split.final.map(part => part.text)).toEqual(['**范围限制:**目前仅整理两份材料,评审意见尚未读取。'])
|
||||
expect(split.final.every(part => part.type === 'text')).toBe(true)
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'tool_call'])
|
||||
expect(split.final).toEqual([])
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'text', 'tool_call'])
|
||||
})
|
||||
|
||||
it('keeps finalizing checks and internal thinking out of visible text without duplicating explanations', () => {
|
||||
@@ -52,8 +50,8 @@ describe('splitTimeline', () => {
|
||||
]
|
||||
for (const finished of [false, true]) {
|
||||
const split = splitTimeline(parts, finished)
|
||||
expect(split.final.map(part => part.text)).toEqual(['评审意见尚未读取。', '已整理两份材料,请补充清晰的评审意见。'])
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'tool_call', 'tool_result', 'check'])
|
||||
expect(split.final.map(part => part.text)).toEqual(['已整理两份材料,请补充清晰的评审意见。'])
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['text', 'thinking', 'tool_call', 'tool_result', 'check'])
|
||||
expect(split.final.length + split.timeline.length).toBe(parts.length)
|
||||
}
|
||||
})
|
||||
@@ -69,7 +67,7 @@ describe('splitTimeline', () => {
|
||||
expect(split.timeline.map(x => x.type)).toEqual(['tool_call'])
|
||||
expect(split.final.map(x => x.type)).toEqual(['error', 'artifact_ref', 'text'])
|
||||
})
|
||||
it('keeps only execution records folded when narration precedes the last process part', () => {
|
||||
it('keeps narration beside execution records when it precedes the last process part', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', text: '先找资料', process: true }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
|
||||
@@ -78,8 +76,8 @@ describe('splitTimeline', () => {
|
||||
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
|
||||
]
|
||||
const split = splitTimeline(parts)
|
||||
expect(split.timeline.map(x => x.type)).toEqual(['tool_call', 'tool_result'])
|
||||
expect(split.final.map(x => x.type)).toEqual(['text', 'text', 'artifact_ref'])
|
||||
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', () => {
|
||||
@@ -94,7 +92,7 @@ describe('splitTimeline', () => {
|
||||
expect(split.final.map(x => x.text)).toEqual(['最终'])
|
||||
})
|
||||
|
||||
it('keeps all narrations visible even when a finished run has no final text', () => {
|
||||
it('promotes only the last narration when a finished run has no final text', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', text: '先写一版', process: true }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'run_python' } }),
|
||||
@@ -102,9 +100,9 @@ describe('splitTimeline', () => {
|
||||
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.timeline.map(x => x.type)).toEqual(['tool_call', 'tool_call'])
|
||||
expect(splitTimeline(parts).final).toEqual(split.final)
|
||||
expect(split.final.map(x => x.text)).toEqual(['纪要已经整理好'])
|
||||
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', () => {
|
||||
@@ -115,6 +113,43 @@ describe('splitTimeline', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('enriches saved process without losing retry notes or parsed inline thinking', () => {
|
||||
const saved: Part[] = [
|
||||
p({ type: 'text', process: true, text: '模型在输出前失败,正在重试' }),
|
||||
p({ type: 'thinking', process: true, text: '检查附件边界' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } }),
|
||||
p({ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } }),
|
||||
]
|
||||
const replayed: Part[] = [
|
||||
p({ type: 'text', process: true, text: '<think>检查附件边界</think>' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' }, toolOutput: '完整输出' } as Part),
|
||||
p({ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } }),
|
||||
p({ type: 'text', process: true, text: '最终回复' }),
|
||||
p({ type: 'text', process: true, text: '协作进展:附录已核对' }),
|
||||
]
|
||||
const merged = mergeProcessTimeline(saved, replayed, [p({ type: 'text', text: '最终回复' })])
|
||||
expect(merged.map(part => part.text).filter(Boolean)).toEqual([
|
||||
'模型在输出前失败,正在重试', '检查附件边界', '协作进展:附录已核对',
|
||||
])
|
||||
expect((merged[2] as Part & { toolOutput: string }).toolOutput).toBe('完整输出')
|
||||
expect(merged.filter(part => part.type === 'tool_call')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not repeat a partial event delta beside the complete saved narration', () => {
|
||||
const saved = [p({ type: 'text', process: true, text: '开始读取材料' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } })]
|
||||
const replayed = [p({ type: 'text', process: true, text: '开始读取' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } })]
|
||||
expect(mergeProcessTimeline(saved, replayed, []).map(part => part.text).filter(Boolean)).toEqual(['开始读取材料'])
|
||||
})
|
||||
|
||||
it('keeps a collaboration result even when the final reply contains its short status', () => {
|
||||
const note = p({ type: 'text', process: true, text: '协作结束:已完成', replaySource: 'agent' } as Part)
|
||||
const merged = mergeProcessTimeline([p({ type: 'text', process: true, text: '完成' })], [note],
|
||||
[p({ type: 'text', text: '已完成' })])
|
||||
expect(merged.map(part => part.text)).toEqual(['完成', '协作结束:已完成'])
|
||||
})
|
||||
|
||||
describe('artifact ids in reply text', () => {
|
||||
it('extracts unique ids in order', () => {
|
||||
expect(artifactIdsIn('见 art_0123456789ab 和 `art_abcdefabcdef`,再提 art_0123456789ab'))
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
import { store } from '../src/store'
|
||||
import type { ActivityCard, Conversation, Project } from '../src/contracts'
|
||||
import { assignDocDutiesPayload, memberAddPayload } from '../src/rdmManagement'
|
||||
|
||||
const project = (id: string, settings?: Project['settings']): Project => ({
|
||||
id, name: id, settings, createdAt: '', updatedAt: '',
|
||||
})
|
||||
const ready = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' })
|
||||
const task = (id: string): Conversation => ({ id, projectId: 'host-1', title: id, createdAt: '', updatedAt: '' })
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(ok => { resolve = ok })
|
||||
return { promise, resolve }
|
||||
}
|
||||
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
|
||||
let projects: Project[]
|
||||
let tasks: Conversation[]
|
||||
let requests: { path: string; init?: RequestInit }[]
|
||||
let route: (path: string, init?: RequestInit) => Promise<Response>
|
||||
|
||||
beforeEach(async () => {
|
||||
projects = []; tasks = []; requests = []
|
||||
const saved = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { saved.set(key, value) },
|
||||
removeItem: (key: string) => { saved.delete(key) } })
|
||||
route = async (path, init) => {
|
||||
if (path === '/api/projects' && !init?.method) return response(projects)
|
||||
if (path.startsWith('/api/conversations') && !init?.method) return response(tasks)
|
||||
if (path === '/api/me') return response({ owner: 'alice', name: 'Alice', auth: true })
|
||||
if (path.endsWith('/artifacts')) return response([])
|
||||
if (path.endsWith('/cards')) return response({ cards: [] })
|
||||
if (path.endsWith('/surfaces')) return response([])
|
||||
if (['/api/profiles', '/api/plugins', '/api/workspaces', '/api/commands', '/api/admin-zone/sections'].includes(path)) return response([])
|
||||
if (path === '/api/settings') return response({ schema: [], defaults: {}, user: {}, project: {} })
|
||||
if (path.startsWith('/api/commands/')) return response({ payload: { ok: true } })
|
||||
return response({})
|
||||
}
|
||||
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
|
||||
requests.push({ path, init })
|
||||
return route(path, init)
|
||||
})
|
||||
vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
|
||||
auth.beginSession()
|
||||
await store.refreshProjects()
|
||||
await store.refreshTasks()
|
||||
store.setUI({ lastProjectId: null })
|
||||
requests = []
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
describe('project command identity', () => {
|
||||
it.each(['archived', 'closed'])('preserves canonical member, permission and duty reads for a %s project', async state => {
|
||||
const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state })
|
||||
const commands = ['rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix',
|
||||
'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get',
|
||||
'rdm.duty.matrix', 'rdm.duty.preview', 'rdm.duty.legacy.scan']
|
||||
for (const command of commands) await api.rdmProjectCommand(historical, command, { docId: 'doc-2' })
|
||||
expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([
|
||||
{ path: '/api/commands/rdm.project.member.list', params: { projectId: 'domain-7', docId: 'doc-2' } },
|
||||
{ path: '/api/commands/rdm.perm.check', params: { projectId: 'domain-7', docId: 'doc-2' } },
|
||||
{ path: '/api/commands/rdm.perm.matrix', params: { projectId: 'domain-7', docId: 'doc-2' } },
|
||||
{ path: '/api/commands/rdm.review.doc.status', params: { projectId: 'domain-7', docId: 'doc-2' } },
|
||||
{ path: '/api/commands/rdm.duty.catalog', params: { projectId: 'domain-7', docId: 'doc-2' } },
|
||||
{ path: '/api/commands/rdm.duty.get', params: { projectId: 'domain-7', docId: 'doc-2' } },
|
||||
{ path: '/api/commands/rdm.duty.matrix', params: { projectId: 'domain-7', docId: 'doc-2' } },
|
||||
{ path: '/api/commands/rdm.duty.preview', params: { projectId: 'domain-7', docId: 'doc-2' } },
|
||||
{ path: '/api/commands/rdm.duty.legacy.scan', params: { projectId: 'domain-7', docId: 'doc-2' } },
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['archived', 'closed'])('still blocks project and document mutations for a %s project', async state => {
|
||||
const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state })
|
||||
for (const command of ['rdm.project.member.add', 'rdm.project.member.remove', 'rdm.project.member.setRole',
|
||||
'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', 'rdm.duty.legacy.apply',
|
||||
'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final']) {
|
||||
await expect(api.rdmProjectCommand(historical, command)).rejects.toThrow()
|
||||
}
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['rdm.perm.matrix', 'rdm.duty.catalog', 'rdm.duty.get', 'rdm.project.member.setRole'])('uses the same canonical boundary for %s', async command => {
|
||||
await api.rdmProjectCommand(ready, command, { projectId: 'another-host', member: 'bob' })
|
||||
expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([
|
||||
{ path: `/api/commands/${command}`, params: { projectId: 'domain-7', member: 'bob' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('sends member and duty commands to the canonical domain project from service metadata', async () => {
|
||||
await api.rdmProjectCommand(ready, 'rdm.project.member.add', memberAddPayload(ready.id, 'bob'))
|
||||
await api.rdmProjectCommand(ready, 'rdm.review.assign.doc', assignDocDutiesPayload(ready.id, 'doc-2', 'alice', ['bob'], 'carol'))
|
||||
expect(requests.map(r => JSON.parse(String(r.init?.body)).params)).toEqual([
|
||||
{ projectId: 'domain-7', member: 'bob', role: 'member' },
|
||||
{ projectId: 'domain-7', docId: 'doc-2', author: 'alice', reviewers: ['bob'], approver: 'carol' },
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
project('host-1', { 'rdm.state': 'unbound' }),
|
||||
project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'failed' }),
|
||||
project('host-1', { 'rdm.projectId': 'domain-7' }),
|
||||
project('host-1'),
|
||||
])('does not invent a domain ID or send commands for an unready binding: %j', async p => {
|
||||
await expect(api.rdmProjectCommand(p, 'rdm.project.member.list')).rejects.toThrow()
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('project work and mutation failures', () => {
|
||||
it('keeps private history readable for an archived project while refusing new work', async () => {
|
||||
projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'archived' })]
|
||||
tasks = [task('private-history')]
|
||||
const previous = route
|
||||
route = async (path, init) => {
|
||||
if (path === '/api/conversations/private-history') return response({ conversation: tasks[0],
|
||||
messages: [{ id: 'own-message', conversationId: 'private-history', role: 'user', parts: [{ type: 'text', text: 'My history' }], createdAt: '' }],
|
||||
runs: [], activeHitl: [], lastSeq: 0 })
|
||||
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
|
||||
return previous(path, init)
|
||||
}
|
||||
await store.refreshProjects()
|
||||
await store.openTask('private-history')
|
||||
await expect(store.createTask()).rejects.toThrow()
|
||||
expect(store.getState().currentConvId).toBe('private-history')
|
||||
expect(store.getState().messages.map(m => m.id)).toEqual(['own-message'])
|
||||
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
||||
})
|
||||
|
||||
it.each([ready, project('host-1')])('creates private work with the host ID in ready RDM and ordinary base mode: %j', async p => {
|
||||
projects = [p]
|
||||
await store.refreshProjects()
|
||||
const previous = route
|
||||
route = async (path, init) => {
|
||||
if (path === '/api/projects/host-1/conversations' && init?.method === 'POST') {
|
||||
tasks = [task('new-private')]
|
||||
return response(tasks[0])
|
||||
}
|
||||
if (path === '/api/conversations/new-private') {
|
||||
return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 })
|
||||
}
|
||||
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
|
||||
return previous(path, init)
|
||||
}
|
||||
await store.createTask()
|
||||
expect(store.getState().currentConvId).toBe('new-private')
|
||||
expect(requests.filter(r => r.init?.method === 'POST').map(r => r.path)).toEqual(['/api/projects/host-1/conversations'])
|
||||
})
|
||||
|
||||
it.each(['provisioning', 'failed', 'unbound', 'archived', 'closed'])('does not create a fallback project or conversation for a selected %s project', async state => {
|
||||
projects = [project('host-1', { 'rdm.state': state }), project('host-2')]
|
||||
await store.refreshProjects()
|
||||
requests = []
|
||||
await expect(store.createTask()).rejects.toThrow()
|
||||
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
||||
expect(store.getState().currentProjectId).toBe('host-1')
|
||||
expect(store.getState().toast).toMatch(/项目.*(稍后|负责人|恢复|关闭)/)
|
||||
})
|
||||
|
||||
it('does not bypass a selected project absent from the current list', async () => {
|
||||
await store.openProject('revoked-project')
|
||||
requests = []
|
||||
await expect(store.createTask()).rejects.toThrow()
|
||||
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['delete', 'rename'])('rejects a denied %s instead of resolving success', async action => {
|
||||
route = async () => response({ error: 'project administration required' }, 403)
|
||||
const mutation = action === 'delete' ? api.deleteProject('host-1') : api.renameProject('host-1', 'renamed')
|
||||
await expect(mutation).rejects.toThrow(/403/)
|
||||
})
|
||||
|
||||
it('retains selection and gives an actionable message when project creation is rejected', async () => {
|
||||
projects = [ready]
|
||||
await store.refreshProjects()
|
||||
route = async () => response({ error: 'name reserved' }, 409)
|
||||
await expect(store.createProject('Taken')).rejects.toThrow()
|
||||
expect(store.getState().currentProjectId).toBe('host-1')
|
||||
expect(store.getState().toast).toMatch(/名称.*(其他|更换)/)
|
||||
expect(store.getState().toast).not.toContain('已创建')
|
||||
})
|
||||
|
||||
it('checks a denied settings save instead of resolving success', async () => {
|
||||
route = async () => response({ error: 'denied' }, 403)
|
||||
await expect(api.putProjectSettings('host-1', {})).rejects.toThrow(/403/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('identity-scoped state', () => {
|
||||
it('resets state on a successful cookie login, without relying on a component callback', async () => {
|
||||
projects = [ready]; tasks = [task('alice-private')]
|
||||
await store.refreshProjects(); await store.refreshTasks()
|
||||
route = async () => response({ owner: 'bob', name: 'Bob', isAdmin: false, mustChangePassword: false, passwordSet: true, sessionScope: 'normal' })
|
||||
await api.login('bob', 'password-for-test')
|
||||
expect(store.getState().projects).toEqual([])
|
||||
expect(store.getState().tasks).toEqual([])
|
||||
})
|
||||
|
||||
it('clears old state as soon as logout starts, even while the server response is pending', async () => {
|
||||
projects = [ready]; tasks = [task('alice-private')]
|
||||
await store.refreshProjects(); await store.refreshTasks()
|
||||
const pending = deferred<Response>()
|
||||
route = async () => pending.promise
|
||||
const logout = api.logout()
|
||||
expect(store.getState().projects).toEqual([])
|
||||
expect(store.getState().tasks).toEqual([])
|
||||
pending.resolve(response({ ok: true }))
|
||||
await logout
|
||||
})
|
||||
|
||||
it('clears the previous account when the authenticated owner changes during identity refresh', async () => {
|
||||
await store.refreshMe()
|
||||
projects = [ready]; tasks = [task('alice-private')]
|
||||
await store.refreshProjects(); await store.refreshTasks()
|
||||
const previous = route
|
||||
projects = []; tasks = []
|
||||
route = (path, init) => path === '/api/me' ? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true })) : previous(path, init)
|
||||
await store.refreshMe()
|
||||
expect(store.getState()).toMatchObject({ me: { owner: 'bob' }, projects: [], tasks: [], currentProjectId: null, currentConvId: null })
|
||||
})
|
||||
|
||||
it('clears projects, private tasks, previews and persisted selections on a new session', async () => {
|
||||
projects = [ready]; tasks = [task('alice-private')]
|
||||
await store.refreshProjects(); await store.refreshTasks()
|
||||
store.openSurface('surface-alice')
|
||||
store.setUI({ projectSettingsId: 'host-1', adminZoneOpen: true, activeWorkspace: 'private-workspace' })
|
||||
localStorage.setItem('as.conv', 'alice-private')
|
||||
auth.beginSession()
|
||||
expect(store.getState()).toMatchObject({ projects: [], tasks: [], me: null, currentProjectId: null, currentConvId: null, artifacts: [], previewTabs: {}, previewActive: {} })
|
||||
expect(store.getState().ui).toMatchObject({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null })
|
||||
expect(localStorage.getItem('as.conv')).toBeNull()
|
||||
expect(JSON.parse(localStorage.getItem('as.ui') ?? '{}').lastProjectId).toBeNull()
|
||||
})
|
||||
|
||||
it('drops old project responses after switching accounts even when IDs are shared', async () => {
|
||||
const old = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/projects' ? old.promise : previous(path, init)
|
||||
const pending = store.refreshProjects()
|
||||
auth.beginSession()
|
||||
projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' })]
|
||||
route = previous
|
||||
await store.refreshProjects()
|
||||
old.resolve(response([project('alice-only')]))
|
||||
await pending
|
||||
expect(store.getState().projects.map(p => p.id)).toEqual(['host-1'])
|
||||
expect(store.getState().currentProjectId).toBe('host-1')
|
||||
})
|
||||
|
||||
it('drops an old private task list after logout and a new session', async () => {
|
||||
const old = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/conversations' ? old.promise : previous(path, init)
|
||||
const pending = store.refreshTasks()
|
||||
auth.logout()
|
||||
auth.beginSession()
|
||||
tasks = [task('bob-private')]
|
||||
route = previous
|
||||
await store.refreshTasks()
|
||||
old.resolve(response([task('alice-private')]))
|
||||
await pending
|
||||
expect(store.getState().tasks.map(t => t.id)).toEqual(['bob-private'])
|
||||
})
|
||||
|
||||
it('drops an old artifact response when the next account selects the same project', async () => {
|
||||
await store.openProject('host-1')
|
||||
const old = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path.endsWith('/artifacts') ? old.promise : previous(path, init)
|
||||
const pending = store.refreshArtifacts('host-1')
|
||||
auth.beginSession()
|
||||
route = previous
|
||||
await store.openProject('host-1')
|
||||
old.resolve(response([{ id: 'alice-artifact', projectId: 'host-1', owner: 'alice', name: 'Alice private', kind: 'file', size: 10, createdAt: '', updatedAt: '' }]))
|
||||
await pending
|
||||
expect(store.getState().artifacts).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['action', 'pin', 'dismiss', 'to-project', 'tidy'] as const)('ignores a late project card %s from the previous account on the same shared project', async operation => {
|
||||
projects = [ready]
|
||||
await store.refreshProjects()
|
||||
const old = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path.startsWith('/api/cards/') ? old.promise : previous(path, init)
|
||||
const card: ActivityCard = { id: 'alice-card', projectId: 'host-1', kind: 'progress', status: 'active', title: 'Alice private work', createdAt: '', updatedAt: '' }
|
||||
if (operation === 'tidy') store.upsertCard({ ...card, status: 'done' })
|
||||
const pending = operation === 'action' ? store.cardAction(card, 'finish')
|
||||
: operation === 'pin' ? store.cardPin(card, true)
|
||||
: operation === 'dismiss' ? store.cardDismiss(card)
|
||||
: operation === 'tidy' ? store.tidyCards() : store.cardToProject(card)
|
||||
auth.beginSession()
|
||||
route = previous
|
||||
await store.refreshProjects()
|
||||
old.resolve(response({ card: { ...card, status: 'done' }, origin: { ...card, status: 'dismissed' } }))
|
||||
await pending
|
||||
expect(store.getState().projectCards).toEqual([])
|
||||
expect(store.getState().cards).toEqual([])
|
||||
})
|
||||
|
||||
it('does not reopen a previous account conversation after its project load finishes late', async () => {
|
||||
projects = [ready]; tasks = [task('alice-private')]
|
||||
await store.refreshProjects()
|
||||
await store.openProject('another-project')
|
||||
const old = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/projects/host-1/artifacts' ? old.promise : previous(path, init)
|
||||
const pending = store.openTask('alice-private')
|
||||
auth.beginSession()
|
||||
tasks = []
|
||||
route = previous
|
||||
await store.refreshProjects()
|
||||
old.resolve(response([]))
|
||||
await pending
|
||||
expect(store.getState().currentConvId).toBeNull()
|
||||
expect(requests.map(r => r.path)).not.toContain('/api/conversations/alice-private')
|
||||
})
|
||||
|
||||
it('does not resolve old task creation as success after a new login', async () => {
|
||||
projects = [ready]
|
||||
await store.refreshProjects()
|
||||
const old = deferred<Response>()
|
||||
const previous = route
|
||||
route = (path, init) => path === '/api/projects/host-1/conversations' ? old.promise : previous(path, init)
|
||||
const pending = store.createTask()
|
||||
auth.beginSession()
|
||||
route = previous
|
||||
await store.refreshProjects()
|
||||
old.resolve(response(task('alice-created')))
|
||||
await expect(pending).rejects.toThrow(/登录状态/)
|
||||
expect(store.getState().currentConvId).toBeNull()
|
||||
})
|
||||
|
||||
it('does not return a previous account attachment ID when artifact refresh finishes after login', async () => {
|
||||
projects = [ready]
|
||||
await store.refreshProjects()
|
||||
const old = deferred<Response>()
|
||||
const started = deferred<void>()
|
||||
const previous = route
|
||||
route = (path, init) => {
|
||||
if (path.endsWith('/uploads')) return Promise.resolve(response({ artifactId: 'alice-upload', filename: 'alice.txt', size: 3 }))
|
||||
if (path.endsWith('/artifacts')) { started.resolve(); return old.promise }
|
||||
return previous(path, init)
|
||||
}
|
||||
const pending = store.uploadAttachment(new File(['abc'], 'alice.txt'))
|
||||
await started.promise
|
||||
auth.beginSession()
|
||||
route = previous
|
||||
await store.refreshProjects()
|
||||
old.resolve(response([]))
|
||||
expect(await pending).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('export capability identity', () => {
|
||||
it('drops the old identity export capability before any new command refresh', async () => {
|
||||
const previous = route
|
||||
route = async (path, init) => path === '/api/commands'
|
||||
? response(['rdm.inspect.export.create', 'rdm.inspect.export.status', 'rdm.inspect.export.download']
|
||||
.map(id => ({ id, enabled: true }))) : previous(path, init)
|
||||
await store.refreshPlugins()
|
||||
expect(store.getState().rdmExportAvailable).toBe(true)
|
||||
auth.beginSession()
|
||||
expect(store.getState().rdmExportAvailable).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,8 @@
|
||||
//(未登录/请求失败/null)一律视为未就绪(fail-closed),普通基座不出现 RDM 页面。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
RDM_ROLES_COMMANDS, RDM_MEMBER_COMMANDS, RDM_DOC_REVIEW_COMMANDS, ROLE_ACTIONS,
|
||||
rdmRolesReady, rdmMembersReady, rdmDocReviewReady,
|
||||
RDM_ROLES_COMMANDS, RDM_MEMBER_COMMANDS, RDM_DUTY_COMMANDS, ROLE_ACTIONS,
|
||||
rdmRolesReady, rdmMembersReady, rdmDutyReady, dutyMatrixPayload, dutyPreviewPayload,
|
||||
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
|
||||
memberAddPayload, memberRemovePayload, assignDocDutiesPayload,
|
||||
docSubmitPayload, docApprovePayload, docRejectPayload, docFinalPayload, docStatusPayload,
|
||||
@@ -19,19 +19,19 @@ describe('RDM 页面能力门控', () => {
|
||||
it('命令齐备且 enabled 才就绪', () => {
|
||||
expect(rdmRolesReady(all(RDM_ROLES_COMMANDS))).toBe(true)
|
||||
expect(rdmMembersReady(all(RDM_MEMBER_COMMANDS))).toBe(true)
|
||||
expect(rdmDocReviewReady(all(RDM_DOC_REVIEW_COMMANDS))).toBe(true)
|
||||
expect(rdmDutyReady(all(RDM_DUTY_COMMANDS))).toBe(true)
|
||||
})
|
||||
it('缺一命令即未就绪', () => {
|
||||
const drop = (ids: readonly string[], skip: string) => ids.filter(i => i !== skip).map(id => cmd(id))
|
||||
expect(rdmRolesReady(drop(RDM_ROLES_COMMANDS, 'rdm.role.list'))).toBe(false)
|
||||
expect(rdmMembersReady(drop(RDM_MEMBER_COMMANDS, 'rdm.project.member.add'))).toBe(false)
|
||||
expect(rdmDocReviewReady(drop(RDM_DOC_REVIEW_COMMANDS, 'rdm.review.doc.final'))).toBe(false)
|
||||
expect(rdmDutyReady(drop(RDM_DUTY_COMMANDS, 'rdm.duty.matrix'))).toBe(false)
|
||||
})
|
||||
it('disabled / null / 非数组 / 请求失败均未就绪(fail-closed)', () => {
|
||||
expect(rdmRolesReady(RDM_ROLES_COMMANDS.map(id => cmd(id, false)))).toBe(false)
|
||||
expect(rdmRolesReady(null)).toBe(false)
|
||||
expect(rdmMembersReady(undefined)).toBe(false)
|
||||
expect(rdmDocReviewReady('not-array' as unknown as never)).toBe(false)
|
||||
expect(rdmDutyReady('not-array' as unknown as never)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,6 +68,9 @@ describe('命令载荷构造(身份只经认证会话——载荷不含任何
|
||||
expect(docFinalPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
|
||||
expect(docFinalPayload('p1', 'd1', 3)).toEqual({ projectId: 'p1', docId: 'd1', round: 3 })
|
||||
expect(docStatusPayload('p1', 'd1')).toEqual({ projectId: 'p1', docId: 'd1' })
|
||||
expect(dutyMatrixPayload('p1', { view: 'matrix', docLimit: 10 })).toEqual({ projectId: 'p1', view: 'matrix', docLimit: 10 })
|
||||
expect(dutyPreviewPayload('p1', [{ docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', expectedDutyVersion: 2, confirmInvalidation: true }]))
|
||||
.toEqual({ projectId: 'p1', docs: [{ docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', expectedDutyVersion: 2, confirmInvalidation: true }] })
|
||||
})
|
||||
it('审阅人 CSV 解析:去空白、去空项、保序去重', () => {
|
||||
expect(parseReviewers('bob, carol,,bob')).toEqual(['bob', 'carol'])
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { loadRunEvents } from '../src/runEvents'
|
||||
import type { StreamEvent } from '../src/contracts'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
function event(seq: number): StreamEvent {
|
||||
return { seq, type: 'message.delta', runId: 'run', conversationId: 'conversation', ts: '2026-10-15T07:00:00Z', v: 1, payload: { index: 0, text: `已保存输出${seq}` } }
|
||||
}
|
||||
|
||||
it('reads a complete saved record across the 500-event boundary in order', async () => {
|
||||
const query = vi.spyOn(api, 'runEventsPage')
|
||||
.mockResolvedValueOnce({ events: Array.from({ length: 500 }, (_, i) => event(i + 1)) })
|
||||
.mockResolvedValueOnce({ events: [event(501), event(502)] })
|
||||
const controller = new AbortController()
|
||||
const result = await loadRunEvents('conversation', 'run', controller.signal)
|
||||
expect(result.map(item => item.seq)).toEqual(Array.from({ length: 502 }, (_, i) => i + 1))
|
||||
expect(query).toHaveBeenNthCalledWith(1, 'conversation', 'run', 0, controller.signal)
|
||||
expect(query).toHaveBeenNthCalledWith(2, 'conversation', 'run', 500, controller.signal)
|
||||
})
|
||||
|
||||
it('stops an invalid nonadvancing record cursor instead of fetching forever', async () => {
|
||||
const query = vi.spyOn(api, 'runEventsPage').mockResolvedValue({ events: Array.from({ length: 500 }, (_, i) => event(i + 1)) })
|
||||
await expect(loadRunEvents('conversation', 'run', new AbortController().signal)).rejects.toThrow('cursor did not advance')
|
||||
expect(query).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { scrollRatio, scrollTopForRatio } from '../src/scrollsync'
|
||||
|
||||
describe('scrollsync', () => {
|
||||
it('没有溢出时不同步', () => {
|
||||
expect(scrollRatio({ scrollTop: 0, scrollHeight: 100, clientHeight: 100 })).toBeNull()
|
||||
expect(scrollTopForRatio({ scrollHeight: 80, clientHeight: 80 }, 0.5)).toBeNull()
|
||||
})
|
||||
|
||||
it('按可滚动范围的比例映射,并夹在 0 到 1', () => {
|
||||
expect(scrollRatio({ scrollTop: 50, scrollHeight: 200, clientHeight: 100 })).toBe(0.5)
|
||||
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, 0.5)).toBe(100)
|
||||
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, 2)).toBe(200)
|
||||
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, -1)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('versioned work request bodies', () => {
|
||||
it('preserves multiple selections and free input on the card action endpoint', async () => {
|
||||
const request = vi.spyOn(auth, 'fetch').mockResolvedValue(new Response(JSON.stringify({ card: { id: 'task' } })))
|
||||
await api.cardAction('task', 'work.submit', { resultVersion: 3, selectedIds: ['summary', 'parameters'], freeText: '请说明依据' })
|
||||
const [url, options] = request.mock.calls[0]
|
||||
expect(url).toBe('/api/cards/task/actions/work.submit')
|
||||
expect(JSON.parse(String(options?.body))).toEqual({ resultVersion: 3, selectedIds: ['summary', 'parameters'], freeText: '请说明依据' })
|
||||
})
|
||||
|
||||
it('sends a result identity separately from the user message and discards client display metadata', async () => {
|
||||
const request = vi.spyOn(auth, 'fetch').mockResolvedValue(new Response(JSON.stringify({ message: { id: 'reply' } })))
|
||||
const reference = { cardId: 'task', resultVersion: 3, title: '说明书整理', result: 'must-not-be-client-context' }
|
||||
await api.sendMessage('conversation', '比较这些重点', [], 'request-1', reference)
|
||||
expect(JSON.parse(String(request.mock.calls[0][1]?.body))).toEqual({
|
||||
text: '比较这些重点', attachmentIds: [], requestId: 'request-1', taskRef: { cardId: 'task', resultVersion: 3 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -43,7 +43,42 @@ beforeEach(async () => {
|
||||
})
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
it('preserves rejected referenced input without retrying it or blocking later queued chat', async () => {
|
||||
const reference = { cardId: 'saved-result', resultVersion: 1, title: '整理结果' }
|
||||
const submit = vi.spyOn(api, 'sendMessage').mockRejectedValueOnce(new Error('400: reference no longer valid'))
|
||||
.mockResolvedValue({ message: { id: 'accepted', conversationId: 'a', role: 'user', parts: [], createdAt: '' } })
|
||||
expect(await store.sendMessage('请解释重点', ['saved-upload'], 'retry-safe-id', reference)).toBe('rejected')
|
||||
const failed = store.getState().outbox[0]
|
||||
expect(failed).toMatchObject({ id: 'retry-safe-id', text: '请解释重点', attachmentIds: ['saved-upload'], taskRef: reference })
|
||||
expect(failed.deliveryError).toBeTruthy()
|
||||
await store.flushOutbox()
|
||||
expect(submit).toHaveBeenCalledTimes(1)
|
||||
store.enqueueOutbox('先讨论其他事情', [], [])
|
||||
await store.flushOutbox()
|
||||
expect(submit).toHaveBeenCalledTimes(2)
|
||||
expect(store.getState().outbox).toHaveLength(1)
|
||||
store.clearOutboxReference(failed.id)
|
||||
await store.sendOutboxNow(failed.id)
|
||||
expect(submit).toHaveBeenLastCalledWith('a', '请解释重点', ['saved-upload'], 'retry-safe-id')
|
||||
expect(store.getState().outbox).toEqual([])
|
||||
})
|
||||
|
||||
describe('work projection ordering and context', () => {
|
||||
it('keeps an earlier background task out of a newer foreground process', async () => {
|
||||
const current: Run = { id: 'new-topic', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
|
||||
const child: Run = { ...current, id: 'old-child', kind: 'subagent', parentRunId: 'old-topic' }
|
||||
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [current, child]))
|
||||
await store.openConversation('a')
|
||||
store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在回答新的问题' }, current.id))
|
||||
store.applyEvent(event(2, 'agent.progress', { agentRun: child.id, note: '旧材料仍在整理' }, child.id))
|
||||
expect(store.getState().live?.agentNotes[child.id]).toBeUndefined()
|
||||
store.applyEvent(event(3, 'agent.spawn', { agentRun: 'new-child', name: '新任务', task: '核对新问题' }, current.id))
|
||||
store.applyEvent(event(4, 'agent.progress', { agentRun: 'new-child', note: '核对当前问题' }, 'new-child'))
|
||||
expect(store.getState().live?.agentNotes['new-child']).toBe('核对当前问题')
|
||||
store.applyEvent(event(5, 'agent.result', { agentRun: child.id, summary: '旧材料已整理' }, child.id))
|
||||
expect(store.getState().live?.runId).toBe(current.id)
|
||||
expect(store.getState().live?.agentNotes[child.id]).toBeUndefined()
|
||||
})
|
||||
it('settles foreground completion and queued input when background events overtake its detail response', async () => {
|
||||
vi.useFakeTimers()
|
||||
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
|
||||
@@ -248,6 +283,70 @@ describe('work projection ordering and context', () => {
|
||||
})
|
||||
|
||||
describe('shared action feedback', () => {
|
||||
it('sends a versioned optional choice once and keeps it out of the conversation', async () => {
|
||||
const task = { ...card('work-choice', 'done'), work: { resultVersion: 2, readVersion: 0,
|
||||
result: '完整材料结果', decision: { mode: 'multiple' as const, state: 'open' as const } } }
|
||||
store.upsertCard(task)
|
||||
const response = deferred<Awaited<ReturnType<typeof api.cardAction>>>()
|
||||
const action = vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
|
||||
const payload = { resultVersion: 2, selectedIds: ['summary', 'facts'], freeText: '请保留来源' }
|
||||
const pending = store.cardAction(task, 'work.submit', payload)
|
||||
await store.cardAction(task, 'work.submit', payload)
|
||||
expect(action).toHaveBeenCalledOnce()
|
||||
expect(action).toHaveBeenCalledWith(task.id, 'work.submit', payload)
|
||||
response.resolve({ card: { ...task, work: { ...task.work, decision: { ...task.work.decision, state: 'submitted' } } },
|
||||
receipt: { id: 'unexpected-work-receipt', conversationId: 'a', role: 'assistant', parts: [], createdAt: '' } })
|
||||
await pending
|
||||
expect(store.getState().messages).toEqual([])
|
||||
expect(store.getState().cardActionStates[task.id]).toBeUndefined()
|
||||
expect(store.getState().cards.find(c => c.id === task.id)?.work?.decision?.state).toBe('submitted')
|
||||
})
|
||||
|
||||
it('does not unlock an uncertain optional submission merely because its result is done', async () => {
|
||||
const task = { ...card('work-unknown', 'done'), work: { resultVersion: 1, readVersion: 0,
|
||||
result: '结果', decision: { mode: 'single' as const, state: 'open' as const } } }
|
||||
store.upsertCard(task)
|
||||
vi.spyOn(api, 'cardAction').mockRejectedValue(new TypeError('connection lost'))
|
||||
vi.mocked(api.listCards).mockResolvedValue([task])
|
||||
await store.cardAction(task, 'work.submit', { resultVersion: 1, selectedIds: ['summary'] })
|
||||
expect(store.getState().cardActionStates[task.id]).toBe('unknown')
|
||||
store.upsertCard({ ...task, work: { ...task.work, decision: { ...task.work.decision, state: 'submitted' } } })
|
||||
expect(store.getState().cardActionStates[task.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('settles successful metadata actions while an ordinary card still needs action', async () => {
|
||||
const task = { ...card('work-read', 'action_required'), work: { resultVersion: 1, readVersion: 0 } }
|
||||
store.upsertCard(task)
|
||||
vi.spyOn(api, 'cardAction').mockResolvedValue({ card: { ...task, work: { ...task.work, readVersion: 1 } } })
|
||||
await store.cardAction(task, 'work.read', { resultVersion: 1 })
|
||||
expect(store.getState().cardActionStates[task.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retains an explicit result reference across a queued send without adding it to chat text', async () => {
|
||||
const task = { ...card('work-ref', 'done'), work: { result: '完整结果', resultVersion: 3, readVersion: 3 } }
|
||||
store.upsertCard(task)
|
||||
store.referenceWork(task)
|
||||
const reference = store.getState().workReference!
|
||||
store.enqueueOutbox('比较重点', [], [], reference)
|
||||
store.clearWorkReference()
|
||||
expect(store.getState().outbox[0].taskRef).toEqual({ cardId: 'work-ref', resultVersion: 3, title: '整理材料' })
|
||||
const send = vi.spyOn(api, 'sendMessage').mockResolvedValue({ message: { id: 'queued-ref', conversationId: 'a', role: 'user', parts: [], createdAt: '' } })
|
||||
await store.flushOutbox()
|
||||
expect(send).toHaveBeenCalledWith('a', '比较重点', [], expect.any(String), reference)
|
||||
expect(store.getState().workReference).toBeNull()
|
||||
})
|
||||
|
||||
it('clears record selection and result reference when navigating to another conversation', async () => {
|
||||
const task = { ...card('work-record', 'done'), sourceRunId: 'child', work: { result: '完整结果', resultVersion: 1, readVersion: 1 } }
|
||||
store.upsertCard(task)
|
||||
store.openWorkRecord(task)
|
||||
store.referenceWork(task)
|
||||
expect(store.getState().workRecord?.runId).toBe('child')
|
||||
await store.openConversation('b')
|
||||
expect(store.getState().workRecord).toBeNull()
|
||||
expect(store.getState().workReference).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['card', 'hitl'] as const)('clears its definite rejected %s action after A to B navigation', async kind => {
|
||||
const confirm = { ...card(`reject-${kind}`, 'action_required'), kind: 'confirm' as const,
|
||||
...(kind === 'hitl' ? { body: [{ type: 'kv' as const, kv: [['来源', `hitl:reject-${kind}`]] as [string, string][] }] } : {}) }
|
||||
|
||||
+26
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWorkspaceEntries, rdmAuditReady, RDM_AUDIT_COMMANDS } from '../src/workspaces'
|
||||
import { buildWorkspaceEntries, rdmAuditReady, rdmExportReady, exportNeedsDraft, RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } from '../src/workspaces'
|
||||
|
||||
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置「代理」。
|
||||
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
|
||||
@@ -72,4 +72,29 @@ describe('rdmAuditReady', () => {
|
||||
expect(rdmAuditReady(null)).toBe(false)
|
||||
expect(rdmAuditReady(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('导出命令不参与七命令就绪门', () => {
|
||||
expect(rdmAuditReady(all)).toBe(true)
|
||||
const withExportOff = [
|
||||
...all,
|
||||
{ id: 'rdm.inspect.export.create', enabled: false },
|
||||
]
|
||||
expect(rdmAuditReady(withExportOff)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rdmExportReady', () => {
|
||||
const audit = RDM_AUDIT_COMMANDS.map(id => ({ id, enabled: true }))
|
||||
const exp = RDM_EXPORT_COMMANDS.map(id => ({ id, enabled: true }))
|
||||
|
||||
it('三条导出命令启用才显示导出区', () => {
|
||||
expect(rdmExportReady([...audit, ...exp])).toBe(true)
|
||||
expect(rdmExportReady(audit)).toBe(false)
|
||||
expect(rdmExportReady(exp.filter(c => c.id !== 'rdm.inspect.export.download'))).toBe(false)
|
||||
})
|
||||
|
||||
it('正式报告不完整时才要求草稿确认', () => {
|
||||
expect(exportNeedsDraft('正式报告不完整(合规缺口)——传 draft:true 生成草稿')).toBe(true)
|
||||
expect(exportNeedsDraft('项目不存在')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user