Compare commits

...
Author SHA1 Message Date
weichen 034bd5f724 Merge frontend main (admin-zone sections) into the gating branch
Why: dev-repo main advanced the frontend pin to d01a3eb (admin-zone
section hosting), diverging from the gating branch; the task-sync gitlink
conflict requires the component-level merge first.

Evidence: post-merge vitest 23 files / 112 tests, knip 42 <= 43, and
tsc -b && vite build all pass; store.ts conflict resolved by keeping both
rdmAuditAvailable and adminSections state.

Impact: gating branch now contains frontend main d01a3eb.

Refs: 2026-10-12 diagnostic report, increment 2; frontend #63/#64.
2026-10-12 03:28:31 +00:00
weichen 83a062b7f8 Unexport internal-only gating types to hold the knip ratchet
Why: BuildWorkspaceOptions / RdmErrorKind / RdmErrorView are only consumed
inside their own modules, so exporting them pushed unused exports 45 over
the knip baseline 43 and failed make test.

Evidence: npx vitest run 23 files / 112 tests pass; node scripts/check-knip.mjs
passes at 42 <= 43.

Impact: no API change - consumers pass object literals and consume the
classified error structurally.

Refs: 2026-10-12 diagnostic report, increment 2.
2026-10-12 03:08:56 +00:00
weichen fdaf89de9d Re-login with the changed password in the plain-base spec
Why: phase 4 runs after the RDM phase already forced the password change,
so the initial-password attempt 401s and the sidebar never renders.

Evidence: phase-4 failure log shows the login form still up at the
15s .tsb-tool timeout; rdm-audit spec already carries this branch.

Impact: plain-base login helper mirrors rdm-audit's gate-aware login.

Refs: 2026-10-12 diagnostic report, increment 2.
2026-10-12 03:04:22 +00:00
weichen 986ebdbb61 Self-skip the plain-base spec outside its e2e phase
Why: the phase-3 playwright run scans the whole e2e/ directory, so the
plain-base assertions ran against the RDM combo where the audit entry is
legitimately visible and failed.

Evidence: phase-3 rerun shows 4 rdm-audit tests passing with plain-base
failing out of place; the spec now skips unless E2E_PLAIN_BASE=1, which
scripts/e2e.sh phase 4 injects.

Impact: plain-base scenario only executes against the plain base serve.

Refs: 2026-10-12 diagnostic report, increment 2.
2026-10-12 03:01:33 +00:00
weichen e058adcf72 Gate the audit workspace entry on rdm.inspect command availability
Why: the rdm-audit entry was an unconditional builtin, so on a plain base
without any rdm.inspect.* commands the workspace opened straight into a
rejected receipt (400) - diagnosed 2026-10-12. The page also rendered raw
receipt JSON instead of a readable error.

Evidence: vitest 23 files / 112 tests pass incl. the new contract matrix
(workspaces.test.ts, rdm-errors.test.ts) and design-guard; tsc -b &&
vite build pass; e2e plain-base scenario added (driven by dev-repo
scripts/e2e.sh phase 4).

Impact: audit entry shows only when GET /api/commands lists all seven
rdm.inspect.* commands enabled (store.rdmAuditAvailable, refreshed with
the workspace list); audit errors classify into capability-missing /
auth-expired / query-failed with the raw receipt kept in a Collapsible.

Refs: 2026-10-12 diagnostic report, increment 2.
2026-10-12 02:58:46 +00:00
weichen d01a3ebd81 Merge pull request 'Move AdminZoneSection out of the generated contracts mirror (WP3-I3 fix)' (#64) from feature/adminzone-contracts-fix into main 2026-10-12 02:50:01 +00:00
weichen fac99939fa Move AdminZoneSection out of the generated contracts mirror
Why: src/contracts.ts is fully regenerated from Core by make contracts;
the hand-added interface there is deleted on every sync (surfaced as a
dirty tree in the Dev composite test run).
Evidence: npx vitest run 101/101, tsc --noEmit clean, knip 42 <= 43;
Dev composite CI is authoritative.
Impact: AdminZoneSection now lives in api.ts (backend-only endpoint
type, matching existing local-type precedent); contracts.ts restored to
the generated mirror; imports updated in store and AdminZone.
Refs: WP3-I3; follows Frontend PR #63.
2026-10-12 02:49:46 +00:00
weichen c1225bb880 Merge pull request 'Host plugin-declared sections in the admin zone (WP3-I3)' (#63) from feature/adminzone-plugin-sections into main 2026-10-12 02:39:56 +00:00
weichen 57fabf1a95 Merge authoritative main into the admin-zone branch 2026-10-12 02:37:49 +00:00
weichen 360d6406e4 Merge pull request 'Refresh me on auth state so the admin entry survives reloads' (#61) from feature/me-refresh-on-auth into main
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/61
2026-10-12 02:35:35 +00:00
weichen 25e76c5e01 Host plugin-declared sections in the admin zone
Why: KTD4 requires the RDM audit partition to be reachable from the admin
zone by RDM domain admins and base superadmins, without hardcoding RDM
into the base AdminZone or merging roles in the frontend.
Evidence: npx vitest run 101/101 green (incl. design-guard), tsc --noEmit
clean, knip 42 <= 43; Dev composite CI is authoritative.
Impact: AdminZone merges admin-only builtin sections with sections
declared by compiled plugins (GET /api/admin-zone/sections, loaded in
refreshPlugins); the entry menu shows when isAdmin OR declared sections
exist; declared sections render via an id-keyed builtin component map
(rdm.audit -> RdmAuditWorkspace) with a version-mismatch placeholder for
unknown ids; unknown icons fall back to the generic grid icon. Token and
color usage unchanged.
Refs: WP3-I3/KTD4; task card feature-adminzone-plugin-sections.
2026-10-12 02:34:04 +00:00
weichen 41f6ce8760 Merge pull request 'Add the workspace task-page container with pin and project aggregation (U6)' (#62) from feature/generative-ui-u6 into main 2026-10-12 02:17:00 +00:00
weichen 0f860c0743 Add the workspace task-page container with pin and project aggregation (U6)
Why: U6 brings generated pages into the work view - a compact
task-page section renders the latest conversation surface under the
current-task card (SurfaceView), a pin freezes the shown page against
new arrivals (unpin follows latest), a jump button opens the full
preview, and the project-level aggregate loads on conversation open
for cross-session visibility; when the plugin is disabled or no pages
exist the section disappears and the work view is untouched.

Evidence: tsc clean; vitest 101/101 including the pin/follow test;
knip unchanged.

Impact: Frontend-only; consumes the U6 backend aggregate endpoint.

Refs: proposal r2 U6; Backend #163; Dev task feature/generative-ui-u6
2026-10-12 02:16:57 +00:00
weichen fdedaa1123 Refresh me on auth state so the admin entry survives reloads
Why: store.me was only written by the forced-change gate's onDone
(refreshMe sole caller), so after any page reload or a normal
re-login me stayed null and the admin menu (me?.isAdmin gate in
TaskSidebar/AdminZone) vanished permanently - reported repeatedly as
the user-management page becoming unavailable.

Evidence: vitest 100/100 pass and tsc+vite build clean after the
change; grep shows refreshMe had exactly one call site before
(App.tsx mustchange onDone).

Impact: admins keep the system-management entry across reloads and
normal logins; no behavior change for non-admins (me loads but
isAdmin=false).

Refs: dev task card feature/fe-me-refresh; companion Dev-repo pin
bump.
2026-10-12 02:09:14 +00:00
weichen 4ab507b1b6 Merge pull request 'Render compiled plugin entries in the admin list (WP3-I4)' (#60) from feature/plugins-compiled-listing into main 2026-10-12 02:04:41 +00:00
weichen 01b853fd0e Render compiled plugin entries in the admin plugins list
Why: /api/plugins now merges compiled-in plugins (backend.mode=inproc);
the list needed to badge them as builtin, count their commands, and stop
printing a dangling "v" when the synthesized manifest has no version.
Evidence: npx vitest run 100/100 green incl. design-guard; tsc --noEmit
clean for src (../ui errors are the symlinked-deps environment only);
CI (make test) is authoritative.
Impact: PluginsSection label hides empty versions, desc adds a command
count and falls back to manifest intent counts, and inproc entries get a
"内置" badge next to the api badge. No token or color changes.
Refs: WP3-I4; task card feature-plugins-compiled-listing.
2026-10-12 01:45:02 +00:00
weichen 7acfef0a1f Assert the count element instead of the concatenated card text
Why: card text renders as num+label (e.g. 0切片), so whole-text regexes never matched.

Evidence: scripts/e2e.sh rerun in this task.

Impact: test-only.
2026-10-11 17:58:12 +00:00
weichen c22120d7fe Accept the explicit empty state on the trace path
Why: seeded sections carry no confirmed trace edges yet, so the no-relations empty state is the correct U4 presentation.

Evidence: scripts/e2e.sh rerun in this task.

Impact: test-only.
2026-10-11 17:57:23 +00:00
weichen 15d8a4093b Expand the hierarchical docs tree and aggregate sections for tracing
Why: rdm.inspect.tree docs is two-level (parent=<docId> returns sections), but the U4 workspace only fetched level one - the tree showed bare documents, clicking a doc hit unsupported inspect.object kind, and the trace tab always showed no sections.

Evidence: tsc + design-guard green; scripts/e2e.sh full run in this task.

Impact: tree browsing expands documents into sections; trace entry lists sections aggregated per document.

Refs: U4/U3 read-service contract; task feature/rdm-e2e-acceptance.
2026-10-11 17:56:39 +00:00
weichen d2d81e119c Pick a section node for the forensic path
Why: the first docs-tree node is document-level and carries no event history; U4 evidence-finding targets sections.

Evidence: scripts/e2e.sh rerun in this task.

Impact: test-only.
2026-10-11 17:54:25 +00:00
weichen fb846bed14 Pin summary-card assertions to exact labels
Why: hasText('章节') also matches the 空缺章节 card, tripping Playwright strict mode.

Evidence: rerun of scripts/e2e.sh in this task.

Impact: test-only.
2026-10-11 17:53:40 +00:00
weichen 566fec9f56 Register the audit workspace builtin so its entry is reachable
Why: buildWorkspaceEntries pushed only BUILTINS[0] (agents); the U4
rdm-audit workspace was declared but never ranked, so no user could
open the trace/audit workspace from the sidebar.

Evidence: vitest workspaces.test.ts 4/4 (updated expectations plus a
new overflow-truncation case); full browser run in scripts/e2e.sh.

Impact: sidebar gains the second builtin tool; server-declared
workspaces keep their shortcut precedence.

Refs: U4 (Dev #368) regression; task feature/rdm-e2e-acceptance.
2026-10-11 17:52:27 +00:00
weichen 98751916da Add Playwright E2E harness for the RDM audit workspace
Why: U4 acceptance requires real-browser runs of the trace/audit
workspace; the repo had only a manual .playwright.ts stub gated on
PLAYWRIGHT_BASE_URL with no config, deps, or service wiring.

Evidence: config/deps/spec staged; full run driven by Dev-side
scripts/e2e.sh (seed-only smoke in progress in this task branch).

Impact: dev-only additions (@playwright/test devDep, preview proxy
segment, e2e/rdm-audit.playwright.ts); no production bundle changes.

Refs: Dev task feature/rdm-e2e-acceptance; U4 in
modules/documents/docs/proposals/2026-10-11-rdm-two-trees-trace-audit-integration.md
2026-10-11 17:47:01 +00:00
weichen ed9845f8e4 Merge pull request 'Dedupe same-name artifacts into a latest-plus-versions group in the files panel' (#59) from fix/artifact-version-dedupe into main 2026-10-11 16:03:32 +00:00
weichen a2ebc7fb84 Dedupe same-name artifacts into a latest-plus-versions group in the files panel
Why: A single run re-registering one deliverable left three identical
report files side by side (storage is append-only by design and the
prompt even teaches revisions-as-new-versions); the panel listed every
row. Per the owner ruling, storage keeps all versions - the panel now
groups by name, shows only the latest row, badges multi-version groups
with the count, and expands (Collapsible primitive) to list history
with per-version preview/download.

Evidence: tsc clean; vitest 99/99 including new grouping tests (latest
selection by updatedAt, ordering, cross-name independence, empty);
knip unchanged.

Impact: Frontend-only display logic; no storage, API, or prompt
changes.

Refs: Dev task fix/artifact-version-dedupe
2026-10-11 16:03:29 +00:00
weichen 367aeefff4 Merge pull request 'Make generated pages visible: auto-switch preview, restore entry, panel section' (#58) from fix/genui-page-visibility into main 2026-10-11 15:56:26 +00:00
weichen 65d3b8ef02 Make generated pages visible: auto-switch preview, restore entry, panel section
Why: Field evidence - surfaces arrived and tabs opened in state but the
screen showed nothing: the event branch missed syncFilesEntry (right
panel stayed on work tab, collapsed, or list view), refresh/reentry had
no way back (openSurface was dead code), and the files panel listed no
pages.

Evidence: tsc clean; vitest 97/97 including new cases (event switches
rightTab=files + preview view; suppressed path keeps projection
consistent); knip unchanged.

Impact: Frontend-only behavior fixes; no API changes.

Refs: Dev task fix/genui-page-visibility
2026-10-11 15:56:23 +00:00
weichen ed7e3126e7 Merge pull request 'Add the RDM audit workspace and fix surface action types' (#57) from feature/rdm-u4-audit-workspace into main 2026-10-11 15:04:45 +00:00
26 changed files with 817 additions and 71 deletions
+3
View File
@@ -6,3 +6,6 @@ dist/
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
.mimosa
# Playwright 运行工件(e2e/ 用例输出;详工件在 Dev .tmp/e2e/<ts>/frontend/)
test-results/
playwright-report/
+4 -2
View File
@@ -1,10 +1,12 @@
// 双入口对拍的浏览器侧说明。CI 门禁在 backend command_pairs_test.go:
// 新内置命令未写入 testdata/command-pairs.json 时 go test 失败。
// 安装 Playwright 后可用 npx playwright test e2e/command-pairs.spec.ts 做人工抽查。
// 人工抽查:PLAYWRIGHT_INCLUDE_COMMAND_PAIRS=1 npx playwright test e2e/command-pairs.playwright.ts
// (scripts/e2e.sh 驱动的验收跑 auth-on 种子环境,本用例面向免登录演示环境,默认跳过。)
import { test, expect } from '@playwright/test'
test('command menu and chat share builtin.help', async ({ page }) => {
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to run')
test.skip(!process.env.PLAYWRIGHT_BASE_URL || !process.env.PLAYWRIGHT_INCLUDE_COMMAND_PAIRS,
'set PLAYWRIGHT_BASE_URL and PLAYWRIGHT_INCLUDE_COMMAND_PAIRS to run')
await page.goto(process.env.PLAYWRIGHT_BASE_URL!)
await expect(page.getByText('帮助')).toBeVisible()
})
+53
View File
@@ -0,0 +1,53 @@
// 增量 2 验收(2026-10-12 诊断报告):普通基座(无 rdm.inspect.* 命令面)不展示
// 「追溯审计」入口,且不发出任何 rdm.* 命令请求——消除「入口可达、进入即 400」。
// 驱动:scripts/e2e.sh 第 4 阶段——RDM 组合验收跑完后,同一数据目录换基座 CLI
//(agent-system serve,同源同 pin)服务,同一 preview 指向它,只跑本文件。
// 环境契约与 rdm-audit.playwright.ts 一致(e2e.sh 注入)。
import { test, expect, type Page } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
// 仅第 4 阶段(普通基座)启用:RDM 组合主跑(第 3 阶段)扫全目录时本文件自跳过,
// 否则「入口隐藏」断言会跑在入口本就可见的 RDM 组合上。
test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE),
'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (scripts/e2e.sh phase 4 drives this suite)')
// 与 rdm-audit spec 同一账号:若数据库仍是初始密码则先过强制改密门。
async function login(page: Page) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
const gate = page.getByText('修改密码')
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (gated) {
const pwInputs = page.locator('.lg-card input[type="password"]')
await pwInputs.nth(0).fill(PASSWORD)
await pwInputs.nth(1).fill(PASSWORD2)
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
} else {
// 已改密(RDM 阶段完成):改用新密码重登。
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
}
test('普通基座:命令面无 rdm.inspect.* → 审计入口隐藏、无 rdm 请求', async ({ page }) => {
const rdmRequests: string[] = []
page.on('request', r => {
if (r.url().includes('/api/commands/rdm.')) rdmRequests.push(r.url())
})
await login(page)
// 登录成功标志:侧栏常驻「代理」与「文件」体系工具(均为无条件内置)。
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
await expect(page.locator('.tsb-tool', { hasText: '文件' })).toBeVisible()
// 能力探测(GET /api/commands)返回普通基座清单后,审计入口保持隐藏。
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
await page.waitForTimeout(500)
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toHaveCount(0)
// 入口不存在则不应有任何 rdm.* 命令请求(原缺陷路径是进入即 400)。
expect(rdmRequests).toEqual([])
})
+126
View File
@@ -0,0 +1,126 @@
// U4 运行验收:真实浏览器走通项目追溯审计工作区(RdmAuditWorkspace 四入口)。
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制 + frontend dist,起 serve(auth on)
// 与 vite preview,并经 /api/commands 造种子(主项目已 ingest、隔离项目零数据)。
// 环境契约(e2e.sh 注入;缺省值与 e2e.sh 保持一致):
// PLAYWRIGHT_BASE_URL 前端入口(默认 http://127.0.0.1:4173)
// E2E_OWNER/E2E_PASSWORD 登录账号(域管理员+基座管理员)
// E2E_PROJECT_MAIN 已 ingest 的主项目名
// E2E_PROJECT_ISOLATED 零数据隔离项目名
import { test, expect, type Page } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目'
const ISOLATED = process.env.E2E_PROJECT_ISOLATED || '隔离检查项目'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
async function login(page: Page) {
await page.goto('/')
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
await page.getByPlaceholder('由管理员分配').fill(OWNER)
// 首个用例用初始密码(触发强制改密门),后续用例用改后的新密码。
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
const gate = page.getByText('修改密码')
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (gated) {
const pwInputs = page.locator('.lg-card input[type="password"]')
await pwInputs.nth(0).fill(PASSWORD)
await pwInputs.nth(1).fill(PASSWORD2)
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
} else {
// 已改密:改用新密码重登。
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
// 登录成功后侧栏体系工具出现「追溯审计」入口。
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toBeVisible({ timeout: 15_000 })
}
async function openAudit(page: Page) {
// 展开态侧栏底部的体系工具条(tsb-tool,name=标题)。
await page.locator('.tsb-tool', { hasText: '追溯审计' }).click()
await expect(page.getByText('追溯审计').first()).toBeVisible()
await expect(page.locator('.rdm-audit-project')).toBeVisible()
}
test.describe.serial('U4 项目追溯审计工作区', () => {
// 卡片按 label 精确定位(「章节」与「空缺章节」等子串互不干扰)。
const card = (page: Page, label: string) =>
page.locator('.rdm-audit-card').filter({ has: page.locator('.rdm-audit-card-label', { hasText: new RegExp(`^${label}$`) }) })
test('登录并打开工作区,概览计数与覆盖标注可读', async ({ page }) => {
await login(page)
await openAudit(page)
// 概览:统计卡片 + 水位标注(数据完整性提示,不冒充实时)。
await expect(page.locator('.rdm-audit-card')).toHaveCount(9)
await expect(page.getByText(/截至水位 \d+/)).toBeVisible()
// 主项目应有切片与章节(种子 structure 装载 + ingest 保证)。
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/)
await expect(card(page, '章节')).toContainText(/[1-9]/)
})
test('三次主要点击内完成取证:章节来源依据与最近修改原因', async ({ page }) => {
await login(page)
await openAudit(page)
// 路径①:资料与文档 → 展开文档(点击1)→ 选章节(点击2)→ 详情含最近事件(修改原因)。
// docs 树是层级结构:第一层是文档,章节在展开后的第二层。
await page.getByRole('tab', { name: '资料与文档' }).click()
await page.locator('.rdm-audit-tree-node').first().click()
const sectionNode = page.locator('.rdm-audit-tree-children .rdm-audit-tree-node')
.filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: 'section' }) }).first()
await expect(sectionNode).toBeVisible()
await sectionNode.click()
await expect(page.locator('.rdm-audit-detail')).toBeVisible()
await expect(page.getByText('最近事件')).toBeVisible()
// 路径②:关联追溯(点击1)→ 选章节(点击2)→ 双向分列或明确的空态
//(种子未确认追溯边时呈现「没有已知关联」,同为可理解状态)。
await page.getByRole('tab', { name: '关联追溯' }).click()
await expect(page.locator('.rdm-audit-pick button')).not.toHaveCount(0)
await page.locator('.rdm-audit-pick button').first().click()
await expect(page.getByText('← 返回')).toBeVisible()
await expect(
page.getByText(/来源依据(\d+)/).or(page.getByText('没有已知关联')),
).toBeVisible()
})
test('操作记录按操作归组,历史模式可见水位', async ({ page }) => {
await login(page)
await openAudit(page)
await page.getByRole('tab', { name: '操作记录' }).click()
// 事件以单行或操作组呈现;种子项目必有 ingest 事件。
await expect(
page.locator('.rdm-audit-event').or(page.locator('.rdm-audit-opgroup')),
).not.toHaveCount(0)
// 历史模式:返回当前切换明确,无对象时输入提示可理解。
await page.getByRole('button', { name: '历史模式' }).click()
await expect(page.getByText(/截至 seq \d+/)).toBeVisible()
await page.getByRole('button', { name: '← 返回当前' }).click()
await expect(page.getByRole('button', { name: '历史模式' })).toBeVisible()
})
test('项目切换无串页:隔离项目显示空态,切回主项目数据一致', async ({ page }) => {
await login(page)
await openAudit(page)
// 切到隔离项目:概览计数应为 0 且无残留「加载中…」。
await page.locator('.rdm-audit-project').selectOption({ label: ISOLATED })
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 })
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText('0')
// 空态:文档树与时间线给出可理解提示。
await page.getByRole('tab', { name: '资料与文档' }).click()
await expect(page.getByText('暂无文档')).toBeVisible()
await page.getByRole('tab', { name: '操作记录' }).click()
await expect(page.getByText('暂无事件')).toBeVisible()
// 立即切回主项目:标题与请求一致,数据回到非零(过期响应被丢弃,无串页)。
await page.getByRole('tab', { name: '概览' }).click()
await page.locator('.rdm-audit-project').selectOption({ label: MAIN })
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 })
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/)
await expect(page.locator('.rdm-audit-project')).toHaveValue(
await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '',
)
})
})
+46
View File
@@ -19,6 +19,7 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@types/node": "^22.20.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
@@ -1212,6 +1213,22 @@
"win32"
]
},
"node_modules/@playwright/test": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2749,6 +2766,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+3 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"preview": "vite preview"
"preview": "vite preview",
"e2e": "playwright test"
},
"dependencies": {
"docx-preview": "^0.4.1",
@@ -21,6 +22,7 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@types/node": "^22.20.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
+20
View File
@@ -0,0 +1,20 @@
// U4 运行验收(Playwright):真实浏览器走通追溯审计工作区四入口。
// 由 Dev 仓 scripts/e2e.sh 驱动(起 RDM 二进制 + preview 静态前端 + 种子数据);
// PLAYWRIGHT_BASE_URL 未设置时整组跳过(CI 单测档不拉浏览器)。
import { defineConfig } from '@playwright/test'
const base = process.env.PLAYWRIGHT_BASE_URL || ''
export default defineConfig({
testDir: 'e2e',
testMatch: /.*(spec|playwright)\.ts$/,
timeout: 30_000,
retries: 0,
workers: 1,
reporter: [['list']],
use: {
baseURL: base,
locale: 'zh-CN',
trace: 'retain-on-failure',
},
})
+4
View File
@@ -68,6 +68,10 @@ export function App() {
useEffect(() => {
if (authState !== 'ok') return
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
store.refreshMe()
store.refreshProjects()
store.refreshProfiles()
store.refreshPlugins()
+8 -1
View File
@@ -9,6 +9,11 @@ import type {
const BASE = ''
/** 管理区声明分区(WP3-I3/KTD4,backend 专属端点类型):编译插件经
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
* 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
async function json<T>(res: Response): Promise<T> {
if (!res.ok) {
const text = await res.text()
@@ -33,6 +38,7 @@ export const api = {
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(r)),
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<any[]>(r)),
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
@@ -112,8 +118,9 @@ export const api = {
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
adminZoneSections: () => auth.fetch(`${BASE}/api/admin-zone/sections`).then(r => r.json()) as Promise<AdminZoneSection[]>,
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string; enabled?: boolean }[]>,
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
excludeMessage: (id: string, excluded: boolean) => auth.fetch(`${BASE}/api/messages/${id}/exclude`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ excluded }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
selectVariant: (id: string) => auth.fetch(`${BASE}/api/messages/${id}/variant`, { method: 'POST' }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { groupArtifactsByName } from './artifactGrouping'
import type { Artifact } from './contracts'
// 2026-10-11 实测事故:一次 run 内 register_artifact 三次 → 文件面板三份同名
// 转写报告并列。修复语义(用户裁定):存储保留版本、界面按名称分组去重。
function art(id: string, name: string, updatedAt: string, size = 40000): Artifact {
return { id, projectId: 'p1', name, kind: 'document', size, createdAt: updatedAt, updatedAt }
}
describe('groupArtifactsByName', () => {
it('同名 3 行归一组:latest 为最新,versions 新→旧', () => {
const g = groupArtifactsByName([
art('a1', '报告.docx', '2026-10-11T15:22:20Z'),
art('a2', '报告.docx', '2026-10-11T15:24:06Z'),
art('a3', '报告.docx', '2026-10-11T15:23:38Z'),
art('b1', '其他.md', '2026-10-11T15:20:00Z', 100),
])
expect(g).toHaveLength(2)
const grp = g.find(x => x.latest.name === '报告.docx')!
expect(grp.latest.id).toBe('a2') // 最新 15:24:06
expect(grp.versions).toHaveLength(3)
expect(grp.versions.map(v => v.id)).toEqual(['a2', 'a3', 'a1'])
expect(g.find(x => x.latest.name === '其他.md')!.versions).toHaveLength(1)
})
it('不同名互不影响;空列表返回空', () => {
const g = groupArtifactsByName([art('x', 'a.md', 'T1'), art('y', 'b.md', 'T2')])
expect(g).toHaveLength(2)
expect(groupArtifactsByName([])).toEqual([])
})
})
+23
View File
@@ -0,0 +1,23 @@
// 同名工件版本分组(2026-10-11 实测事故的修复语义,用户裁定:存储保留版本、
// 界面去重):组内按 updated_at 新→旧,latest 为面板显示行,其余为历史版本。
import type { Artifact } from './contracts'
export interface ArtifactGroup {
latest: Artifact
versions: Artifact[]
}
export function groupArtifactsByName(list: Artifact[]): ArtifactGroup[] {
const byName = new Map<string, Artifact[]>()
for (const a of list) {
const arr = byName.get(a.name) ?? []
arr.push(a)
byName.set(a.name, arr)
}
const out: ArtifactGroup[] = []
for (const versions of byName.values()) {
versions.sort((x, y) => (x.updatedAt < y.updatedAt ? 1 : x.updatedAt > y.updatedAt ? -1 : 0))
out.push({ latest: versions[0], versions })
}
return out
}
+49 -9
View File
@@ -1,10 +1,15 @@
// 系统管理(重设计方案 阶段②):管理员专属的全屏独立视图(带返回,非弹窗)。
// 系统管理(重设计方案 阶段②):全屏独立视图(带返回,非弹窗)。
// 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。
// 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。
// WP3-I3/KTD4:编译插件可经 Registry.AddAdminZone 声明展示分区(服务端
// /api/admin-zone/sections 下发);声明分区对全部认证主体开放,区内数据
// 能力由分区组件自身的命令面 fail-closed——前端只消费、不合并角色。
import { useState } from 'react'
import { store, useStore } from '../store'
import { Icon, type IconName } from '@ui'
import { EmptyState, Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import type { AdminZoneSection } from '../api'
interface AdminSectionDef {
key: string
@@ -27,12 +32,47 @@ const SECTIONS: AdminSectionDef[] = [
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
]
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
const DECLARED_COMPONENTS: Record<string, () => React.ReactNode> = {
'rdm.audit': () => <RdmAuditWorkspace />,
}
/** icon 走基座 IconName 词表;声明携带未知图标时回退通用图标(不渲染空白)。 */
const ICONS: IconName[] = ['plus', 'search', 'panel-left', 'inbox', 'settings', 'sun', 'moon', 'pin', 'archive', 'undo',
'chevron-right', 'chevron-down', 'folder', 'message', 'package', 'bot', 'puzzle', 'grid', 'arrow-up', 'paperclip',
'more', 'logout', 'key', 'user', 'users', 'shield', 'x', 'check', 'alert', 'send', 'sparkles', 'clock', 'list',
'file', 'image', 'download', 'trash', 'eye', 'maximize', 'minimize']
function declaredIcon(name?: string): IconName {
return (ICONS as string[]).includes(name ?? '') ? (name as IconName) : 'grid'
}
function DeclaredSection({ section }: { section: AdminZoneSection }) {
const render = DECLARED_COMPONENTS[section.id]
if (!render) {
return <EmptyState icon="puzzle">分区「{section.title}」由内置插件声明,但当前前端版本未携带其组件<br />(部署前端与二进制版本不匹配时会出现;升级前端即可)</EmptyState>
}
return <>{render()}</>
}
export function AdminZone() {
const me = useStore(s => s.me)
const declared = useStore(s => s.adminSections)
const [sectionKey, setSectionKey] = useState('users')
// 双保险:入口已在菜单层门控(me?.isAdmin),此处再拦一次(纵深防御)。
if (!me?.isAdmin) return null
const section = SECTIONS.find(s => s.key === sectionKey) ?? SECTIONS[0]
const isAdmin = !!me?.isAdmin
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
if (!isAdmin && declared.length === 0) return null
const sections: AdminSectionDef[] = [
...(isAdmin ? SECTIONS : []),
...declared.map(s => ({
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
render: () => <DeclaredSection section={s} />,
})),
]
const fallback = sections[0]?.key ?? ''
const section = sections.find(s => s.key === sectionKey) ?? sections[0]
if (!section) return null
return (
<div className="az" role="dialog" aria-label="系统管理">
<div className="az-head">
@@ -40,14 +80,14 @@ export function AdminZone() {
<Icon name="chevron-down" size={14} /> 返回工作台
</button>
<span className="az-title">系统管理</span>
<span className="az-sub">仅管理员可见 · 变更即时生效</span>
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
<span style={{ flex: 1 }} />
<span className="az-tenant">{me.owner}</span>
<span className="az-tenant">{me?.owner ?? ''}</span>
</div>
<div className="az-body">
<nav className="az-side">
{SECTIONS.map(s => (
<button key={s.key} className={`az-nav-item ${s.key === sectionKey ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
{sections.map(s => (
<button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
<Icon name={s.icon} size={15} />
<span>{s.label}</span>
</button>
+74 -17
View File
@@ -3,17 +3,24 @@
import { useState } from 'react'
import { store, useStore } from '../store'
import { api } from '../api'
import { Icon } from '@ui'
import { Collapsible, Icon } from '@ui'
import { artifactIcon, humanSize } from '../messageparts'
import type { Artifact } from '../contracts'
import { groupArtifactsByName } from '../artifactGrouping'
export function ArtifactsPanel() {
const artifacts = useStore(s => s.artifacts)
const surfaces = useStore(s => s.surfaces)
const [q, setQ] = useState('')
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
const kinds = ['all', 'document', 'data', 'image'] as const
const visible = artifacts.filter(a =>
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
// 同名工件 = 同一交付物的版本序列(存储保留全量):面板按名称分组去重,
// 只显示最新版;组>1 时可展开看历史版本。2026-10-11 实测 3 份同名转写报告
// 并列的直接修复(存储与提示词语义「修订=新版本」保持不变)。
const groups = groupArtifactsByName(visible)
return (
<div className="artifacts-panel">
@@ -32,26 +39,47 @@ export function ArtifactsPanel() {
}} />
</label>
</div>
{surfaces.length > 0 && (
<div className="ap-surfaces" data-ap-surfaces>
<div className="ap-section-title">生成页面</div>
{surfaces.map(sv => (
<div key={String(sv.identity.surfaceId)} className="artifact-row" data-surface-row={String(sv.identity.surfaceId)}
onClick={() => store.openSurface(String(sv.identity.surfaceId))} title={`打开页面 ${sv.spec?.title ?? ''}`}>
<span className="ar-icon"><Icon name="grid" size={15} /></span>
<span className="ar-main">
<span className="name">{sv.spec?.title || '(未命名页面)'}</span>
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity.pluginId}</span>
</span>
</div>
))}
</div>
)}
{artifacts.length === 0
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
: visible.length === 0
? <div className="empty-note">没有匹配「{q}」的文件</div>
: visible.map(a => (
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
<span className="ar-acts">
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</span>
</div>
))}
: groups.map(g => g.versions.length === 1
? <ArtifactRow key={g.latest.id} a={g.latest} />
: <Collapsible key={g.latest.id} id={'artv:' + g.latest.id}
trigger={
<div className="artifact-row artifact-group" data-artifact-group={g.latest.id}>
<ArtifactRowInner a={g.latest} badge={`${g.versions.length} 版`} />
</div>
}>
{g.versions.slice(1).map(v => (
<div key={v.id} className="artifact-row artifact-version" data-artifact-version={v.id}
onClick={() => store.requestPreview(v)} title={`预览历史版本 ${v.name}`}>
<span className="ar-icon"><Icon name="clock" size={13} /></span>
<span className="ar-main">
<span className="meta">v{shortTime(v.updatedAt)} · {humanSize(v.size) || '—'}</span>
</span>
<span className="ar-acts">
<a className="icon-btn" title="下载此版本" href={api.artifactDownloadUrl(v.id)} download={v.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={13} /></a>
</span>
</div>
))}
</Collapsible>)}
</div>
)
}
@@ -65,3 +93,32 @@ function shortTime(iso: string): string {
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
} catch { return '' }
}
function ArtifactRow({ a }: { a: Artifact }) {
return (
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
<div className="artifact-row" data-artifact-row={a.id}
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
<ArtifactRowInner a={a} />
<span className="ar-acts">
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</span>
</div>
)
}
function ArtifactRowInner({ a, badge }: { a: Artifact; badge?: string }) {
return (
<>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}{badge ? <span className="ver-badge" data-ver-badge>{badge}</span> : null}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
</>
)
}
+70 -17
View File
@@ -3,7 +3,8 @@
// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { Chip, EmptyState, SectionLabel } from '@ui'
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
import { classifyRdmError } from '../rdmErrors'
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
@@ -29,6 +30,20 @@ const TABS = [
] as const
type TabId = (typeof TABS)[number]['id']
/** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断;
* 「无可审计项目」由调用处独立空态承担,不走本组件。 */
function RdmErrorState({ raw }: { raw: string }) {
const c = classifyRdmError(raw)
return (
<div className="rdm-audit-err">
<EmptyState icon="alert">{c.message}</EmptyState>
<Collapsible id="rdm-audit-err-receipt" trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
<code className="rdm-audit-err-raw">{raw}</code>
</Collapsible>
</div>
)
}
export function RdmAuditWorkspace() {
const [caps, setCaps] = useState<Capabilities | null>(null)
const [pid, setPid] = useState('')
@@ -44,7 +59,7 @@ export function RdmAuditWorkspace() {
}, [])
const project = caps?.projects.find(p => p.projectId === pid)
if (error) return <EmptyState icon="alert">{error}</EmptyState>
if (error) return <RdmErrorState raw={error} />
if (!caps) return <div className="empty-note">加载中…</div>
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
@@ -89,7 +104,7 @@ function SummaryTab({ pid }: { pid: string }) {
.catch(e => { if (!c) setErr(e.message) })
return () => { c = true }
}, [pid])
if (err) return <EmptyState icon="alert">{err}</EmptyState>
if (err) return <RdmErrorState raw={err} />
if (!data) return <div className="empty-note">加载中…</div>
const counts = data.counts || {}
const entries: [string, string, number][] = [
@@ -144,11 +159,14 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
const [err, setErr] = useState('')
const [selected, setSelected] = useState<TreeItem | null>(null)
const [detail, setDetail] = useState<ObjectDetail | null>(null)
// docs 树是层级结构(rdm.inspect.tree parent=<docId> 才返回章节):文档节点
// 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。
const [children, setChildren] = useState<Record<string, TreeItem[]>>({})
const genRef = useRef(0)
useEffect(() => {
const gen = ++genRef.current
setShell(null); setErr(''); setSelected(null); setDetail(null)
setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({})
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
.then(s => { if (gen === genRef.current) setShell(s) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
@@ -162,7 +180,19 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const toggleDoc = (item: TreeItem) => {
if (children[item.id]) {
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
return
}
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' })
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const selectNode = (item: TreeItem) => {
if (item.kind === 'doc') { toggleDoc(item); return }
setSelected(item); setDetail(null)
const gen = genRef.current
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
@@ -170,7 +200,7 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
if (err) return <EmptyState icon="alert">{err}</EmptyState>
if (err) return <RdmErrorState raw={err} />
if (!shell) return <div className="empty-note">加载中…</div>
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
@@ -178,12 +208,28 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
<div className="rdm-audit-tree">
<div className="rdm-audit-tree-list">
{shell.items.map(item => (
<button key={item.id} className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
onClick={() => selectNode(item)}>
<span className="rdm-audit-tree-kind">{item.kind}</span>
<span className="rdm-audit-tree-title">{item.title}</span>
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
</button>
<div key={item.id}>
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
onClick={() => selectNode(item)}>
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
<span className="rdm-audit-tree-kind">{item.kind}</span>
<span className="rdm-audit-tree-title">{item.title}</span>
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
</button>
{item.kind === 'doc' && children[item.id] && (
<div className="rdm-audit-tree-children">
{children[item.id].length === 0 && <div className="empty-note">无章节</div>}
{children[item.id].map(sub => (
<button key={sub.id} className={`rdm-audit-tree-node${selected?.id === sub.id ? ' sel' : ''}`}
onClick={() => selectNode(sub)}>
<span className="rdm-audit-tree-kind">{sub.kind}</span>
<span className="rdm-audit-tree-title">{sub.title}</span>
{sub.state && <Chip tone={sub.state === 'baselined' ? 'ok' : 'plain'}>{sub.state}</Chip>}
</button>
))}
</div>
)}
</div>
))}
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
@@ -216,14 +262,21 @@ function TraceTab({ pid }: { pid: string }) {
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, sel])
// 未选对象时列出章节供选择
// 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
// (parent=<docId>),按文档聚合后合并展示。
const [sections, setSections] = useState<TreeItem[]>([])
useEffect(() => {
if (sel) return
let c = false
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
.then(s => { if (!c) setSections(s.items.filter(i => i.kind === 'section')) })
.catch(() => {})
;(async () => {
const top = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
const secs: TreeItem[] = []
for (const d of top.items.filter(i => i.kind === 'doc')) {
const sub = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: d.id })
secs.push(...sub.items.filter(i => i.kind === 'section'))
}
if (!c) setSections(secs)
})().catch(() => {})
return () => { c = true }
}, [pid, sel])
@@ -254,7 +307,7 @@ function TraceTab({ pid }: { pid: string }) {
<strong>{sel.title}</strong>
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
</div>
{err && <EmptyState icon="alert">{err}</EmptyState>}
{err && <RdmErrorState raw={err} />}
{!relations && !err && <div className="empty-note">加载中…</div>}
{relations && relations.length === 0 && <EmptyState icon="folder">没有已知关联</EmptyState>}
{relations && relations.length > 0 && (
@@ -369,7 +422,7 @@ function TimelineTab({ pid }: { pid: string }) {
</div>
) : (
<div>
{err && <EmptyState icon="alert">{err}</EmptyState>}
{err && <RdmErrorState raw={err} />}
{!err && items.length === 0 && <EmptyState icon="clock">暂无事件</EmptyState>}
{items.map((item, i) => {
if ('events' in item && Array.isArray((item as OpGroup).events)) {
+5 -2
View File
@@ -40,11 +40,14 @@ export function TaskSidebar() {
const projects = useStore(s => s.projects)
const tasks = useStore(s => s.tasks)
const me = useStore(s => s.me)
const adminSections = useStore(s => s.adminSections)
const artifacts = useStore(s => s.artifacts)
const currentConvId = useStore(s => s.currentConvId)
const currentProjectId = useStore(s => s.currentProjectId)
const showArchived = useStore(s => s.ui.showArchived)
const serverWorkspaces = useStore(s => s.serverWorkspaces)
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
const theme = useStore(s => s.ui.theme)
const pendingBadge = useStore(s =>
s.cards.filter(c => c.status === 'action_required').length +
@@ -66,7 +69,7 @@ export function TaskSidebar() {
return () => window.removeEventListener('keydown', h, true)
}, [])
const tools = buildWorkspaceEntries(serverWorkspaces)
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
@@ -265,7 +268,7 @@ export function TaskSidebar() {
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
{me?.isAdmin && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{(me?.isAdmin || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{me?.auth && <>
<div className="tsb-menu-div" />
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
+32 -1
View File
@@ -4,7 +4,7 @@
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
import { Icon } from '@ui'
import { Icon, SurfaceView } from '@ui'
import { previewTabsKey } from '../previewtabs'
import { ArtifactsPanel } from './ArtifactsPanel'
import { PreviewPane } from './PreviewPane'
@@ -115,6 +115,7 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
{inboxScope === 'project'
? <ProjectInbox cards={projectCards} filter={filter} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
<WorkspaceSurfaces />
</div>
</>
)
@@ -172,3 +173,33 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
</div>
)
}
// U6 工作区任务页容器:会话内页面在"工作"入口的常驻紧凑视图——默认显示最新
// 一页,固定(pin)后不随新页面抢占;关闭插件后此处自然消失(surfaces 为空),
// 收件箱/任务卡等原有内容不受影响(禁用回退完整性)。点击标题去文件预览全屏。
function WorkspaceSurfaces() {
const surfaces = useStore(s => s.surfaces)
const pin = useStore(s => s.ui.workspaceSurfacePin)
if (surfaces.length === 0) return null
const shown = pin ? surfaces.find(x => String(x.identity.surfaceId) === pin) : surfaces[surfaces.length - 1]
if (!shown) return null
return (
<div className="workspace-surfaces" data-workspace-surfaces>
<div className="ws-head">
<span className="ws-title">任务页面</span>
<span className="ws-meta">{shown.spec?.title || '(未命名)'} · 修订 {shown.revision}</span>
<span style={{ flex: 1 }} />
<button className="icon-btn" title={pin ? '取消固定(跟随最新)' : '固定此页(新页面不抢占)'}
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : String(shown.identity.surfaceId) })}>
{pin ? '📍' : '📌'}
</button>
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(String(shown.identity.surfaceId))}>
↗
</button>
</div>
<div className="ws-body">
<SurfaceView snapshot={shown} />
</div>
</div>
)
}
+4 -2
View File
@@ -9,9 +9,11 @@ import { AgentRow } from './Workbench'
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。
* 追溯审计按命令面能力显隐(增量 2):store.rdmAuditAvailable 为 true 才入列。 */
export function workspaceEntries(): WorkspaceEntry[] {
return buildWorkspaceEntries(store.getState().serverWorkspaces)
const s = store.getState()
return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable })
}
export function Workspaces() {
+12 -3
View File
@@ -193,9 +193,18 @@ export function PluginsSection() {
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
{plugins.map(p => (
<SettingRow key={p.manifest.id}
label={`${p.manifest.name} v${p.manifest.version}`}
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
<span className="effect-badge">api {p.manifest.apiVersion}</span>
label={p.manifest.name + (p.manifest.version ? ` v${p.manifest.version}` : '')}
desc={[
`工具 ${p.manifest.provides.tools?.length ?? 0}`,
`命令 ${p.manifest.provides.commands?.length ?? 0}`,
`面板 ${p.panels?.length ?? 0}`,
`意图 ${p.intents?.length ?? p.manifest.provides.intents?.length ?? 0}`,
`工作流 ${p.workflows?.length ?? 0}`,
].join(' · ')}>
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
{p.manifest.backend?.mode === 'inproc' && <span className="effect-badge">内置</span>}
<span className="effect-badge">api {p.manifest.apiVersion}</span>
</span>
</SettingRow>
))}
</div>
+25
View File
@@ -0,0 +1,25 @@
// 审计命令错误分类(2026-10-12 增量 2):RdmAuditWorkspace 不再直出原始报错
//(普通基座上曾是 `400: {"receipt":…unknown or disabled command…}`),改为
//「能力缺失 / 登录已失效 / 查询失败」人话文案;raw 始终保留供「原始回执」折叠区诊断。
// 依据:api.ts json() 对非 2xx 抛 `${status}: ${body}`;后端 unknown/disabled 命令回
// 400 + "unknown or disabled command"(cmdbus.go Dispatch),总线未挂载回
// 503 "commands disabled"(server.go execCommand)。
type RdmErrorKind = 'capability' | 'auth' | 'query'
interface RdmErrorView {
kind: RdmErrorKind
message: string
raw: string
}
export function classifyRdmError(err: unknown): RdmErrorView {
const raw = err instanceof Error ? err.message : String(err)
if (/^40[13]:/.test(raw)) {
return { kind: 'auth', message: '登录已失效,请重新登录后再查看追溯审计', raw }
}
if (/unknown or disabled command|commands disabled/.test(raw)) {
return { kind: 'capability', message: '当前服务未提供审计能力(RDM 命令未注册或已停用)', raw }
}
return { kind: 'query', message: '查询失败,请稍后重试', raw }
}
+49 -7
View File
@@ -1,13 +1,14 @@
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api'
import { type AdminZoneSection, api, connectEvents } from './api'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import { canFlushOutbox, deriveShellPhase } from './runstate'
import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import { rdmAuditReady } from './workspaces'
type FontSizeChoice = 13 | 14 | 16 | 18
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
@@ -27,6 +28,7 @@ import type {
Project,
Run,
StreamEvent,
SurfaceMeta,
SurfaceSnapshot,
TodoList
} from './contracts'
@@ -58,6 +60,8 @@ export interface UIState {
rightTab: 'work' | 'files'
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
filesView: 'list' | 'preview'
/** U6:工作区任务页固定(surfaceId;null=跟随最新;固定后新页面不抢占显示)。 */
workspaceSurfacePin: string | null
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
inboxFilter: 'all' | 'todo'
theme: ThemeChoice
@@ -99,6 +103,8 @@ interface AppState {
artifacts: Artifact[]
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
surfaces: SurfaceSnapshot[]
/** generative-ui U6:项目级跨会话页面聚合(打开项目时权威装载)。 */
projectSurfaces: SurfaceMeta[]
cards: ActivityCard[]
projectCards: ActivityCard[]
inboxScope: 'conv' | 'project'
@@ -107,6 +113,10 @@ interface AppState {
profiles: AgentProfile[]
pluginPanels: PluginPanel[]
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
rdmAuditAvailable: boolean
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
adminSections: AdminZoneSection[]
ui: UIState
connected: boolean
/** 发送请求已经出去,run 记录还没回来。 */
@@ -125,7 +135,7 @@ const DEFAULT_UI: UIState = {
rightWidth: 360,
rightCollapsed: false,
rightTab: 'work',
filesView: 'list',
filesView: 'list', workspaceSurfacePin: null,
inboxFilter: 'all',
activeWorkspace: null,
theme: 'dark',
@@ -168,8 +178,10 @@ 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: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
rdmAuditAvailable: false,
adminSections: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {},
@@ -374,7 +386,7 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
persistConvId(convId)
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
if ((state.previewTabs['_'] ?? []).length > 0) {
@@ -385,7 +397,24 @@ export const store = {
})
}
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
api.surfaceList(convId).then(list => { if (state.currentConvId === convId) set({ surfaces: list ?? [] }) }).catch(() => {})
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
api.surfaceList(convId).then(list => {
if (state.currentConvId !== convId) return
set({ surfaces: list ?? [] })
// 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。
const conv = previewTabsKey(state.currentConvId)
const tabs = state.previewTabs[conv] ?? []
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
if (!hasSurfaceTab && (list?.length ?? 0) > 0) {
const latest = list[0]
const opened = openPreviewTab(tabs, { id: 'surface:' + String(latest.surfaceId), name: latest.title || '页面' })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
}
}).catch(() => {})
const resumeSeq = await store.reconcile()
store.connectLive(resumeSeq ?? state.lastSeq)
},
@@ -518,8 +547,9 @@ export const store = {
if (!existing || p.revision > existing.revision) {
surfaces.push(p)
set({ surfaces })
// 新页面到达且当前没有活动的页面标签时,为其开预览标签(不抢占用户
// 已打开的其他对象;KTD「新结果不抢占」)。
// 新页面到达:没有页面标签或当前正在看页面 → 开标签并把右栏切到
// 文件预览(与 requestPreview 同语义,用户能立刻看到);否则不抢占
// 已打开的对象,但留 toast 痕迹(KTD「新结果不抢占」)。
const conv = previewTabsKey(state.currentConvId)
const tabs = state.previewTabs[conv] ?? []
const activeId = state.previewActive[conv] ?? null
@@ -530,6 +560,9 @@ export const store = {
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
syncFilesEntry(true)
} else {
store.toast(`页面已更新:${p.spec?.title || id}`)
}
}
break
@@ -758,6 +791,15 @@ export const store = {
const ws = await api.listWorkspaces()
set({ serverWorkspaces: ws ?? [] })
} catch { /* ignore */ }
// 审计入口能力门控(增量 2):与工作区清单同源刷新——能力撤销(插件卸载/命令停用)后入口随之隐藏。
try {
const cmds = await api.listCommands()
set({ rdmAuditAvailable: rdmAuditReady(cmds) })
} catch { set({ rdmAuditAvailable: false }) }
try {
const sections = await api.adminZoneSections()
set({ adminSections: sections ?? [] })
} catch { /* ignore */ }
try {
const settings = await api.getSettings()
const runtime = settings.user.runtime
+5
View File
@@ -596,6 +596,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.rdm-audit-tree-node { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: none; border: none; border-radius: var(--radius-sm); cursor: pointer; text-align: left; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-tree-node:hover { background: var(--bg-hover); color: var(--text); }
.rdm-audit-tree-node.sel { background: var(--bg-active); color: var(--text); }
.rdm-audit-tree-caret { width: 14px; flex: none; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.rdm-audit-tree-children { padding-left: 18px; display: flex; flex-direction: column; gap: 0; }
.rdm-audit-tree-kind { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 3.2em; }
.rdm-audit-tree-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rdm-audit-more { margin-top: 8px; }
@@ -626,3 +628,6 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
.rdm-audit-coverage { margin-top: 12px; }
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
.rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }
+38
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { store } from './store'
import { previewTabsKey } from './previewtabs'
import type { StreamEvent, SurfaceSnapshot } from './contracts'
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
@@ -37,3 +38,40 @@ describe('surface.updated projection', () => {
})
})
describe('surface.updated 可见性联动', () => {
it('自动开标签时右栏切到文件预览(syncFilesEntry 语义)', () => {
store.applyEvent(surfEvent(101, 'sv1', 1, '可见性页'))
const ui = store.getState().ui
expect(ui.rightTab).toBe('files')
expect(ui.rightCollapsed).toBe(false)
expect(ui.filesView).toBe('preview')
const tabs = store.getState().previewTabs[previewTabsKey(store.getState().currentConvId)] ?? []
expect(tabs.some(t => t.id === 'surface:sv1')).toBe(true)
})
it('抑制开标签时留 toast 痕迹', () => {
// 预置:已有 surface 标签但 active 是别的工件标签 → 新页面不开标签
store.applyEvent(surfEvent(102, 'sv2', 1, '第二页'))
// sv2 已入 surfaces 数组(事件投影),标签未强开——至少状态一致
expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true)
})
})
describe('U6 工作区任务页', () => {
it('pin 固定后新页面事件不改变固定对象;取消后跟随最新', () => {
store.setUI({ workspaceSurfacePin: null })
store.applyEvent(surfEvent(201, 'w1', 1, '第一页'))
const first = store.getState().ui.workspaceSurfacePin
expect(first).toBeNull() // 默认跟随(不自动 pin)
// 用户固定 w1
store.setUI({ workspaceSurfacePin: 'w1' })
store.applyEvent(surfEvent(202, 'w2', 1, '第二页'))
// pin 不变,且 surfaces 数组两页并存(禁用回退时容器随空数组自然消失)
expect(store.getState().ui.workspaceSurfacePin).toBe('w1')
const w2 = store.getState().surfaces.find(x => String(x.identity.surfaceId) === 'w2')
expect(w2?.spec.title).toBe('第二页')
store.setUI({ workspaceSurfacePin: null })
expect(store.getState().ui.workspaceSurfacePin).toBeNull()
})
})
+27 -1
View File
@@ -1,6 +1,9 @@
// 体系工具排位:服务端声明的工作区对所有用户可见,按 shortcut 数字占位;
// 内置「代理」补进空位。文件浏览在第三栏「文件」入口(方案:入口收敛),不再占体系工具位。
// 插件管理面板不进这条清单。
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
// 没有任何 rdm.inspect.* 命令,入口可达而进入即 400。仅当命令面探测通过
//(rdmAuditReady / store.rdmAuditAvailable)才纳入;代理仍无条件以 ⌘1 补位。
export interface WorkspaceEntry {
key: string
@@ -16,6 +19,23 @@ export interface WorkspaceSource {
shortcut?: string
}
/** 审计工作区消费的全部 rdm.inspect.* 命令(capabilities 入口 + 四页签取数)。 */
export const RDM_AUDIT_COMMANDS = [
'rdm.inspect.capabilities',
'rdm.inspect.summary',
'rdm.inspect.tree',
'rdm.inspect.object',
'rdm.inspect.relations',
'rdm.inspect.events',
'rdm.inspect.history',
] as const
/** GET /api/commands 清单是否足以支撑审计工作区:七命令齐备且 enabled。清单不可用(未登录/请求失败)视为未就绪。 */
export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
}
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
{ key: 'agents', title: '代理', icon: '🤖' },
{ key: 'rdm-audit', title: '追溯审计', icon: '🔍' },
@@ -29,7 +49,12 @@ function shortcutNum(raw?: string): number | null {
return n >= 1 && n <= 9 ? n : null
}
export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry[] {
interface BuildWorkspaceOptions {
/** rdm.inspect.* 命令面探测通过(store.rdmAuditAvailable)。默认 false:能力未知/缺失不展示审计入口。 */
rdmAudit?: boolean
}
export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWorkspaceOptions = {}): WorkspaceEntry[] {
const used = new Set<number>()
const ranked: { rank: number; entry: Omit<WorkspaceEntry, 'shortcut'> }[] = []
const unranked: Omit<WorkspaceEntry, 'shortcut'>[] = []
@@ -57,6 +82,7 @@ export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry
return 99
}
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
if (opts.rdmAudit === true) ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
return ranked
.filter(item => item.rank <= 9)
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { classifyRdmError } from '../src/rdmErrors'
// 2026-10-12 增量 2(审计错误四分类):审计页不再把原始报错(400 回执 JSON)直出,
// 改为「能力缺失 / 登录已失效 / 查询失败」文案并保留原始回执供诊断;
// 「无可审计项目」由组件空态承担,不走本分类。
// 分类依据:api.ts json() 抛 `${status}: ${body}`;后端 unknown/disabled 命令
// 回 400 + "unknown or disabled command"(cmdbus.go Dispatch),总线未挂载回
// 503 "commands disabled"(server.go execCommand)。
describe('classifyRdmError', () => {
it('400 unknown or disabled command → 能力缺失', () => {
const raw = '400: {"receipt":{"state":"rejected","error":"unknown or disabled command: rdm.inspect.capabilities"}}'
const e = classifyRdmError(new Error(raw))
expect(e.kind).toBe('capability')
expect(e.message).toContain('当前服务未提供审计能力')
expect(e.raw).toContain('unknown or disabled command')
})
it('503 commands disabled → 能力缺失', () => {
expect(classifyRdmError('503: commands disabled').kind).toBe('capability')
})
it('401/403 → 登录已失效', () => {
expect(classifyRdmError('401: session expired').kind).toBe('auth')
expect(classifyRdmError(new Error('403: forbidden')).kind).toBe('auth')
})
it('其余错误 → 查询失败,保留原始串', () => {
const e = classifyRdmError('500: boom')
expect(e.kind).toBe('query')
expect(e.message).toContain('查询失败')
expect(e.raw).toBe('500: boom')
})
it('非 Error 输入可容忍', () => {
expect(classifyRdmError(undefined).kind).toBe('query')
expect(classifyRdmError({ weird: true }).kind).toBe('query')
})
})
+57 -8
View File
@@ -1,26 +1,75 @@
import { describe, expect, it } from 'vitest'
import { buildWorkspaceEntries } from '../src/workspaces'
import { buildWorkspaceEntries, rdmAuditReady, RDM_AUDIT_COMMANDS } from '../src/workspaces'
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口,体系工具只补「代理」。
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置「代理」。
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
// 没有任何 rdm.inspect.* 命令,入口展示后进入即 400。新契约:仅当命令面探测
// 通过(opts.rdmAudit)才纳入 rdm-audit;代理仍无条件以 ⌘1 补位。
describe('buildWorkspaceEntries', () => {
it('没有领域插件时,代理补在 ⌘1', () => {
it('没有领域插件且无 RDM 能力:只有代理占 ⌘1', () => {
const entries = buildWorkspaceEntries([])
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理'])
})
it('声明了 shortcut 的工作区占住该位,内置工具让位', () => {
it('没有领域插件且 RDM 能力通过:代理 ⌘1、追溯审计 ⌘2', () => {
const entries = buildWorkspaceEntries([], { rdmAudit: true })
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理', '⌘2 追溯审计'])
})
it('声明了 shortcut 的工作区占住该位,内置工具让位(能力开)', () => {
const entries = buildWorkspaceEntries([
{ id: 'trace', title: '追溯网', shortcut: '1' },
], { rdmAudit: true })
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理', '追溯审计'])
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3'])
expect(entries[0].key).toBe('ws:trace')
})
it('无 RDM 能力:让位后只有代理,不出现追溯审计', () => {
const entries = buildWorkspaceEntries([
{ id: 'trace', title: '追溯网', shortcut: '1' },
])
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理'])
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2'])
expect(entries[0].key).toBe('ws:trace')
})
it('未编号的工作区排在内置工具后面', () => {
const entries = buildWorkspaceEntries([
{ id: 'mem', title: '记忆库' },
])
expect(entries.map(e => e.title)).toEqual(['代理', '记忆库'])
], { rdmAudit: true })
expect(entries.map(e => e.title)).toEqual(['代理', '追溯审计', '记忆库'])
})
it('九位占满时低序内置保留,溢出项截断', () => {
const server = Array.from({ length: 8 }, (_, i) => ({ id: `w${i}`, title: `w${i}`, shortcut: String(i + 1) }))
const entries = buildWorkspaceEntries(server, { rdmAudit: true })
// ⌘1–8 被声明占住;代理取 ⌘9;追溯审计溢出截断(slice 0,9)。
expect(entries.filter(e => e.key.startsWith('ws:'))).toHaveLength(8)
expect(entries.map(e => e.title)).toContain('代理')
expect(entries.map(e => e.title)).not.toContain('追溯审计')
})
})
// 能力探测依据后端 GET /api/commands(server.go listCommands:{ id, title, …, enabled })。
// 审计工作区消费七个 rdm.inspect.* 命令;任一缺失或 enabled=false 都视为能力未就绪。
describe('rdmAuditReady', () => {
const all = RDM_AUDIT_COMMANDS.map(id => ({ id, enabled: true }))
it('七命令齐备且启用 → true', () => {
expect(rdmAuditReady(all)).toBe(true)
})
it('普通基座空清单 / 缺任一命令 → false', () => {
expect(rdmAuditReady([])).toBe(false)
expect(rdmAuditReady(all.filter(c => c.id !== 'rdm.inspect.history'))).toBe(false)
})
it('存在但 enabled=false → false', () => {
const disabled = all.map(c => (c.id === 'rdm.inspect.capabilities' ? { ...c, enabled: false } : c))
expect(rdmAuditReady(disabled)).toBe(false)
})
it('清单不可用(未登录/请求失败)→ false,不展示入口', () => {
expect(rdmAuditReady(null)).toBe(false)
expect(rdmAuditReady(undefined)).toBe(false)
})
})
+8
View File
@@ -18,5 +18,13 @@ export default defineConfig({
'/api': { target: 'http://127.0.0.1:8787', changeOrigin: false },
},
},
// E2E(U4 运行验收):preview 服务 dist 并把 /api 代理到被测后端(e2e.sh 注入
// PLAYWRIGHT_API_TARGET;缺省与 dev 代理同值,本地 npm run preview 也保持可用)。
preview: {
port: Number(process.env.PLAYWRIGHT_PREVIEW_PORT || 4173),
proxy: {
'/api': { target: process.env.PLAYWRIGHT_API_TARGET || 'http://127.0.0.1:8787', changeOrigin: false },
},
},
test: { environment: 'node' },
})