Compare commits

...
Author SHA1 Message Date
weichen aaced8796b Fix the surface action callback to match SurfaceView's single-parameter signature
Why: U5 (#367) introduced a two-parameter callback but SurfaceView (@ui) expects (id: string) => void; TypeScript build fails on main.

Evidence: SELF tsc --noEmit zero errors after fix.

Impact: PreviewPane.tsx one-line type fix; no behavior change (payload path dormant until @ui widens the interface).

Refs: Dev #367
2026-10-11 14:49:31 +00:00
weichen d0d9876b18 Merge remote-tracking branch 'origin/main' into feature/rdm-u4-audit-workspace 2026-10-11 14:43:49 +00:00
weichen 2c56054861 Merge pull request 'Submit page actions from the preview pane (U5)' (#56) from feature/generative-ui-u5 into main 2026-10-11 14:24:05 +00:00
weichen c2878c04ea Submit page actions from the preview pane (U5)
Why: U5 closes the interactive loop - the surface view's action click
carries the user's typed drafts (and nothing else - unsubmitted input
has no upload path), the store builds the idempotent request
(client-generated requestId, current revision), guards double-clicks
in flight, and maps server outcomes to toasts (awaiting-confirm card,
revision conflict refresh, disabled rejection).

Evidence: tsc clean; vitest 95/95 including the request-shape test
(explicit data only, unique requestId per submission); knip unchanged.

Impact: Frontend-only; backend endpoint from the U5 backend branch.

Refs: U5 action-scope r2 (approved); UI PRs #46/#47; Dev task
feature/generative-ui-u5
2026-10-11 14:24:01 +00:00
weichen b80153dbf8 Add the RDM audit workspace with four entries consuming U3 inspect commands
Why: the master plan U4 requires a four-entry traceability audit workspace as a builtin Dev frontend workspace; U3 rdm.inspect.* commands are the server contract.

Evidence: SELF tsc --noEmit zero errors in new files; design guard compliance verified (tokens only, no raw colors, no inline fontSize, no details element); CI (GATE) will run full suite.

Impact: RdmAuditWorkspace.tsx (new, ~500 lines), api.ts (+rdmCommand), Workspaces.tsx (+rdm-audit dispatch), workspaces.ts (+builtin), styles.css (+rdm-audit-* using only @ui tokens).

Refs: Documents eb36571 master plan U4; U3 rdm.inspect.* contract
2026-10-11 14:07:48 +00:00
weichen 39dc8c456c Merge pull request 'Clear stale API-key credentials when a password session begins' (#55) from feature/login-clear-stale-key into main 2026-10-11 14:04:59 +00:00
weichen 482ba885e1 Merge remote-tracking branch 'origin/main' into feature/login-clear-stale-key 2026-10-11 14:04:26 +00:00
weichen 09310cb753 Clear stale API-key credentials when a password session begins
Why: the auth middleware prefers Bearer keys over the session cookie, so a stale key left in localStorage shadows valid cookie sessions and every request after password login returns 401 (observed at http://192.168.3.2:8788 on 2026-10-11).
Evidence: modules/frontend vitest 19 files / 88 tests green; auth.test.ts covers beginSession clearing the stored Bearer before any fetch.
Impact: password logins switch to the HttpOnly cookie session cleanly; API-key logins unchanged.
Refs: task card feature-login-clear-stale-key in Agent-System-Dev.
2026-10-11 13:53:27 +00:00
weichen fcf40ff75f Merge pull request 'Keep API-key holders out of the dead-end change-password gate' (#54) from feature/admin-auth-gate-fix into main 2026-10-11 13:52:15 +00:00
weichen dc18d6c747 Keep API-key holders out of the dead-end change-password gate
Why: entering the forced-change gate with an API-key credential always
fails 401 (passwords are session-owned), and the gate mapped every 401
to "Incorrect username or password", masking the real reason and hiding
the self-service exit; admins got locked out before reaching user
management.

Evidence: npx vitest run green (20 files / 93 tests incl. new
test/loginpage.test.ts); tsc -b and check-knip green.

Impact: App boot routes key+mustChange to the login page (key cleared);
the API-key tab advises password login for must-change accounts; the
gate maps backend error strings to distinct actionable messages; api.me
type gains authVia.

Refs: Dev task feature/admin-auth-gate-fix.
2026-10-11 13:45:56 +00:00
weichen 68f6c8638b Merge pull request 'Render saved surfaces in the preview pane with refresh recovery' (#53) from feature/generative-ui-u4 into main 2026-10-11 11:40:00 +00:00
13 changed files with 676 additions and 6 deletions
+6 -1
View File
@@ -52,8 +52,13 @@ export function App() {
const r = await auth.fetch('/api/health')
if (r.status === 401) { setAuthState('login'); return }
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
// key 持有者清 key 回登录页,改走密码登录完成改密。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) { setMustChangeOwner(me.owner); setAuthState('mustchange'); return }
if (me?.mustChangePassword) {
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
}
setAuthState('ok')
} catch {
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
+18 -1
View File
@@ -31,8 +31,10 @@ export const api = {
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(r)),
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
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; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
@@ -127,6 +129,21 @@ export const api = {
savePromptCommand: (body: { name: string; title: string; template: string }) => auth.fetch(`${BASE}/api/prompt-commands`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)),
deletePromptCommand: (id: string) => auth.fetch(`${BASE}/api/prompt-commands/${id}`, { method: 'DELETE' }),
listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>,
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ params }),
}).then(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r))
.then(out => {
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
if (envelope?.receipt?.state === 'rejected') throw new Error(envelope.receipt.error || envelope.receipt.message || '命令被拒绝')
if (envelope?.card) throw new Error('高危命令已生成确认卡——请在对话中确认执行')
if (envelope && envelope.payload !== undefined) return envelope.payload
return out as T
}),
}
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
+3
View File
@@ -22,6 +22,9 @@ export const auth = {
apiKey(): string { return storage.getItem(KEY) ?? '' },
setKey(key: string) { storage.setItem(KEY, key.trim()) },
clear() { storage.removeItem(KEY) },
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
beginSession() { this.clear() },
// 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。
fetch(input: string, init?: RequestInit): Promise<Response> {
const key = this.apiKey()
+28 -3
View File
@@ -41,6 +41,8 @@ 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()
if (me.mustChangePassword) {
// 初始/重置密码:先完成强制改密再放行。
onMustChange(me.owner)
@@ -57,7 +59,17 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
auth.setKey(key)
try {
const r = await auth.fetch('/api/health')
if (r.ok) { onAuthed(); return }
if (r.ok) {
// 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录):
// 不放行进死路改密门,引导改走账号密码登录。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) {
auth.clear()
setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密')
return
}
onAuthed(); return
}
throw new Error(String(r.status))
} catch {
auth.clear()
@@ -118,7 +130,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
await api.changePassword(current, next)
onDone()
} catch (e) {
setErr(humanLoginError(String(e)).replace('401: ', ''))
setErr(humanChangePasswordError(String(e)))
} finally { setBusy(false) }
}
@@ -145,10 +157,23 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
)
}
/** 错误文案:把 HTTP 状态码翻成人话。 */
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
export function humanLoginError(raw: string): string {
if (raw.includes('401')) return '用户名或密码不正确'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key),
* 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
export function humanChangePasswordError(raw: string): string {
if (raw.includes('current password incorrect')) return '当前密码不正确'
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
if (raw.includes('session expired')) return '会话已过期,请重新登录'
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('401')) return '认证失败,请重新登录'
return raw.replace(/^\d{3}:\s*/, '')
}
+1 -1
View File
@@ -56,7 +56,7 @@ export function PreviewPane() {
</div>
<div className="preview-pane-body">
{surfaceSnap
? <SurfaceView snapshot={surfaceSnap} docBridge={{ onOpenDoc: docId => {
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), id, {})} docBridge={{ onOpenDoc: docId => {
const art = artifacts.find(a => a.id === docId)
if (art) store.requestPreview(art)
} }} />
+467
View File
@@ -0,0 +1,467 @@
// U4:项目追溯审计工作区(四入口)——消费 U3 rdm.inspect.* 命令面。
// 只读:审批/确认关系仍回原业务流程(D31:弹层不是唯一路径)。
// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { Chip, EmptyState, SectionLabel } from '@ui'
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
interface InspectProject { projectId: string; name: string; canAudit: boolean }
interface Capabilities { actor: string; projects: InspectProject[]; crossProject: boolean }
interface CoverageItem { face: string; reason: string }
interface ListShell<T = any> { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
interface SummaryData { projectId: string; counts: Record<string, number>; watermark: number; lastOpAt: string; coverage?: CoverageItem[] }
interface TreeItem { kind: string; id: string; title: string; state?: string }
interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number }
interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string }
interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] }
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
// ---- 工作区主组件 ----
const TABS = [
{ id: 'summary', label: '概览' },
{ id: 'materials', label: '资料与文档' },
{ id: 'trace', label: '关联追溯' },
{ id: 'timeline', label: '操作记录' },
] as const
type TabId = (typeof TABS)[number]['id']
export function RdmAuditWorkspace() {
const [caps, setCaps] = useState<Capabilities | null>(null)
const [pid, setPid] = useState('')
const [tab, setTab] = useState<TabId>('summary')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
api.rdmCommand<Capabilities>('rdm.inspect.capabilities')
.then(c => { if (!cancelled) { setCaps(c); if (c.projects.length > 0) setPid(prev => prev || c.projects[0].projectId) } })
.catch(e => { if (!cancelled) setError(e.message) })
return () => { cancelled = true }
}, [])
const project = caps?.projects.find(p => p.projectId === pid)
if (error) return <EmptyState icon="alert">{error}</EmptyState>
if (!caps) return <div className="empty-note">加载中…</div>
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
return (
<div className="rdm-audit">
<div className="rdm-audit-header">
<select className="ui-input rdm-audit-project" value={pid} onChange={e => setPid(e.target.value)} aria-label="选择项目">
{caps.projects.map(p => (
<option key={p.projectId} value={p.projectId}>{p.name}</option>
))}
</select>
{project?.canAudit && <Chip tone="ok">可审计</Chip>}
{caps.crossProject && <Chip tone="info">跨项目</Chip>}
</div>
<div className="tabs rdm-audit-tabs" role="tablist">
{TABS.map(t => (
<button key={t.id} role="tab" aria-selected={tab === t.id}
className={`tab${tab === t.id ? ' active' : ''}`}
onClick={() => setTab(t.id)}>{t.label}</button>
))}
</div>
<div className="rdm-audit-body">
{tab === 'summary' && <SummaryTab pid={pid} />}
{tab === 'materials' && <MaterialsTab pid={pid} />}
{tab === 'trace' && <TraceTab pid={pid} />}
{tab === 'timeline' && <TimelineTab pid={pid} />}
</div>
</div>
)
}
// ---- 概览 ----
function SummaryTab({ pid }: { pid: string }) {
const [data, setData] = useState<SummaryData | null>(null)
const [err, setErr] = useState('')
useEffect(() => {
let c = false
setData(null); setErr('')
api.rdmCommand<SummaryData>('rdm.inspect.summary', { projectId: pid })
.then(d => { if (!c) setData(d) })
.catch(e => { if (!c) setErr(e.message) })
return () => { c = true }
}, [pid])
if (err) return <EmptyState icon="alert">{err}</EmptyState>
if (!data) return <div className="empty-note">加载中…</div>
const counts = data.counts || {}
const entries: [string, string, number][] = [
['切片', 'slices', counts.slices ?? 0],
['章节', 'sections', counts.sections ?? 0],
['文档', 'docs', counts.docs ?? 0],
['生效边', 'edgesActive', counts.edgesActive ?? 0],
['待确认边', 'edgesCandidate', counts.edgesCandidate ?? 0],
['结论', 'conclusions', counts.conclusions ?? 0],
['待办', 'openItems', counts.openItems ?? 0],
['挂起上传', 'uploadsHeld', counts.uploadsHeld ?? 0],
['空缺章节', 'emptySections', counts.emptySections ?? 0],
]
return (
<div>
<SectionLabel>统计(截至水位 {data.watermark})</SectionLabel>
<div className="rdm-audit-cards">
{entries.map(([label, , val]) => (
<div key={label} className="rdm-audit-card">
<div className="rdm-audit-card-num">{val}</div>
<div className="rdm-audit-card-label">{label}</div>
</div>
))}
</div>
{data.lastOpAt && <div className="rdm-audit-meta">最近动作:{new Date(data.lastOpAt).toLocaleString('zh-CN')}</div>}
{data.coverage && data.coverage.length > 0 && (
<CoverageList cov={data.coverage} />
)}
</div>
)
}
// ---- 资料与文档 ----
function MaterialsTab({ pid }: { pid: string }) {
const [kind, setKind] = useState<'notes' | 'docs'>('docs')
return (
<div className="rdm-audit-materials">
<div className="rdm-audit-materials-side">
<div className="tabs">
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
</div>
<TreeList pid={pid} kind={kind} />
</div>
</div>
)
}
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
const [err, setErr] = useState('')
const [selected, setSelected] = useState<TreeItem | null>(null)
const [detail, setDetail] = useState<ObjectDetail | null>(null)
const genRef = useRef(0)
useEffect(() => {
const gen = ++genRef.current
setShell(null); setErr(''); setSelected(null); setDetail(null)
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
.then(s => { if (gen === genRef.current) setShell(s) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, kind])
const loadMore = () => {
if (!shell?.nextCursor) return
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: shell.nextCursor })
.then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...s.items] } : s) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const selectNode = (item: TreeItem) => {
setSelected(item); setDetail(null)
const gen = genRef.current
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
.then(d => { if (gen === genRef.current) setDetail(d) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
if (err) return <EmptyState icon="alert">{err}</EmptyState>
if (!shell) return <div className="empty-note">加载中…</div>
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
return (
<div className="rdm-audit-tree">
<div className="rdm-audit-tree-list">
{shell.items.map(item => (
<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>
))}
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
{selected && (
<div className="rdm-audit-detail">
<SectionLabel>对象详情</SectionLabel>
{!detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
</div>
)}
</div>
)
}
// ---- 关联追溯 ----
function TraceTab({ pid }: { pid: string }) {
const [sel, setSel] = useState<{ kind: string; id: string; title: string } | null>(null)
const [relations, setRelations] = useState<RelationEntry[] | null>(null)
const [truncated, setTruncated] = useState(false)
const [err, setErr] = useState('')
const genRef = useRef(0)
useEffect(() => {
if (!sel) return
const gen = ++genRef.current
setRelations(null); setErr('')
api.rdmCommand<ListShell<RelationEntry>>('rdm.inspect.relations', { projectId: pid, kind: sel.kind, id: sel.id, depth: 1 })
.then(s => { if (gen === genRef.current) { setRelations(s.items); setTruncated(s.truncated) } })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, sel])
// 未选对象时列出章节供选择
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(() => {})
return () => { c = true }
}, [pid, sel])
if (!sel) {
return (
<div>
<SectionLabel>选择对象查看来源与影响</SectionLabel>
{sections.length === 0 ? <EmptyState icon="folder">暂无章节</EmptyState> : (
<div className="rdm-audit-pick">
{sections.map(s => (
<button key={s.id} className="btn v-ghost" onClick={() => setSel({ kind: 'section', id: s.id, title: s.title })}>
{s.title}
</button>
))}
</div>
)}
</div>
)
}
const sources = relations?.filter(r => r.to?.id === sel.id && r.to?.kind === sel.kind) ?? []
const impacts = relations?.filter(r => r.from?.id === sel.id && r.from?.kind === sel.kind) ?? []
return (
<div>
<div className="rdm-audit-trace-head">
<button className="btn v-ghost" onClick={() => setSel(null)}>← 返回</button>
<strong>{sel.title}</strong>
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
</div>
{err && <EmptyState icon="alert">{err}</EmptyState>}
{!relations && !err && <div className="empty-note">加载中…</div>}
{relations && relations.length === 0 && <EmptyState icon="folder">没有已知关联</EmptyState>}
{relations && relations.length > 0 && (
<div className="rdm-audit-trace-cols">
<div className="rdm-audit-trace-col">
<SectionLabel>来源依据({sources.length})</SectionLabel>
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} />)}
</div>
<div className="rdm-audit-trace-col">
<SectionLabel>影响对象({impacts.length})</SectionLabel>
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} />)}
</div>
</div>
)}
{truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
)
}
function RelationRow({ r }: { r: RelationEntry }) {
const other = r.from?.id && r.to?.id ? (r.from?.id < r.to?.id ? r.from : r.to) : (r.from || r.to)
return (
<div className="rdm-audit-rel">
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{r.layer}</Chip>
<span className="rdm-audit-rel-type">{r.type || r.kind || '关联'}</span>
<span className="rdm-audit-rel-endpoint">{other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''}</span>
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' ? 'warn' : 'plain'}>{r.state}</Chip>
</div>
)
}
// ---- 操作记录(事件时间线) ----
function TimelineTab({ pid }: { pid: string }) {
const [items, setItems] = useState<(OpGroup | EventDTO)[]>([])
const [cursor, setCursor] = useState('')
const [hasMore, setHasMore] = useState(false)
const [err, setErr] = useState('')
const [filterOp, setFilterOp] = useState('')
const [filterActor, setFilterActor] = useState('')
const [historyMode, setHistoryMode] = useState(false)
const [historySeq, setHistorySeq] = useState(0)
const [historyData, setHistoryData] = useState<HistoryData | null>(null)
const genRef = useRef(0)
const load = useCallback((cur: string, replace: boolean) => {
const gen = ++genRef.current
if (replace) { setItems([]); setErr('') }
const params: Record<string, unknown> = { projectId: pid, cursor: cur, limit: 30 }
if (filterOp) params.op = filterOp
if (filterActor) params.actor = filterActor
api.rdmCommand<ListShell<OpGroup | EventDTO>>('rdm.inspect.events', params)
.then(s => {
if (gen !== genRef.current) return
setItems(prev => replace ? s.items : [...prev, ...s.items])
setCursor(s.nextCursor || '')
setHasMore(s.hasMore)
})
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, filterOp, filterActor])
useEffect(() => { if (!historyMode) load('', true) }, [load, historyMode])
useEffect(() => {
if (!historyMode) { setHistoryData(null); return }
const gen = genRef.current
setHistoryData(null)
const params: Record<string, unknown> = { projectId: pid, atSeq: historySeq || undefined }
api.rdmCommand<HistoryData>('rdm.inspect.history', params)
.then(d => { if (gen === genRef.current) setHistoryData(d) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, historyMode, historySeq])
return (
<div className="rdm-audit-timeline">
<div className="rdm-audit-timeline-filters">
<input className="ui-input" placeholder="过滤操作者…" value={filterActor}
onChange={e => setFilterActor(e.target.value)} />
<input className="ui-input" placeholder="过滤操作类型…" value={filterOp}
onChange={e => setFilterOp(e.target.value)} />
<button className={`btn v-ghost${historyMode ? ' active' : ''}`} onClick={() => setHistoryMode(!historyMode)}>
{historyMode ? '← 返回当前' : '历史模式'}
</button>
{historyMode && (
<input className="ui-input rdm-audit-seq" type="number" placeholder="截止 seq…"
value={historySeq || ''} onChange={e => setHistorySeq(Number(e.target.value) || 0)} />
)}
</div>
{historyMode ? (
<div>
{!historyData ? <div className="empty-note">加载中…</div> : (
<div>
<SectionLabel>截至 seq {historyData.atSeq}(当前水位 {historyData.watermark})</SectionLabel>
{historyData.countOnlySnapshots ? <div className="rdm-audit-truncated">含 {historyData.countOnlySnapshots} 个旧计数快照(不可作恢复依据)</div> : null}
{historyData.formalMissing ? <div className="empty-note">{historyData.formalMissing}</div> : null}
{historyData.object ? (
<div className="rdm-audit-detail-body">
<div>标题:{historyData.object.title}</div>
<div>状态:{historyData.object.state}</div>
{historyData.object.contentPreview && <div>内容预览:{historyData.object.contentPreview}</div>}
</div>
) : historyData.empty ? <EmptyState icon="folder">该时点无事件</EmptyState> :
<div className="empty-note">输入对象 kind:id 查看历史快照</div>}
{historyData.formal && (
<div className="rdm-audit-formal">
正式版 R{historyData.formal.rev}(内容 v{historyData.formal.seq})——{historyData.formal.actor} 于 {new Date(historyData.formal.at).toLocaleString('zh-CN')}
</div>
)}
</div>
)}
</div>
) : (
<div>
{err && <EmptyState icon="alert">{err}</EmptyState>}
{!err && items.length === 0 && <EmptyState icon="clock">暂无事件</EmptyState>}
{items.map((item, i) => {
if ('events' in item && Array.isArray((item as OpGroup).events)) {
return <OpGroupView key={item.operationId || i} group={item as OpGroup} />
}
return <EventRow key={(item as EventDTO).seq} ev={item as EventDTO} />
})}
{hasMore && <button className="btn v-ghost rdm-audit-more" onClick={() => load(cursor, false)}>加载更多</button>}
</div>
)}
</div>
)
}
function OpGroupView({ group }: { group: OpGroup }) {
const [open, setOpen] = useState(false)
return (
<div className="rdm-audit-opgroup">
<button className="rdm-audit-opgroup-head" onClick={() => setOpen(!open)}>
<span className="rdm-audit-opgroup-actor">{group.actor}</span>
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
<Chip tone="plain">{group.events.length} 个事件</Chip>
</button>
{open && group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
</div>
)
}
function EventRow({ ev }: { ev: EventDTO }) {
return (
<div className="rdm-audit-event">
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleTimeString('zh-CN')}</span>
<span className="rdm-audit-event-op">{ev.op}</span>
<span className="rdm-audit-event-actor">{ev.actor}</span>
{ev.detail && <span className="rdm-audit-event-detail">{ev.detail.slice(0, 80)}</span>}
{ev.disclosure !== 'ok' && <Chip tone="warn">{ev.disclosure === 'model-candidate' ? '模型候选' : '来源受限'}</Chip>}
</div>
)
}
// ---- 对象详情视图 ----
function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
return (
<div className="rdm-audit-detail-body">
<div><strong>{detail.title || detail.id}</strong></div>
<div>{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}</div>
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : 'plain'}>{detail.state}</Chip></div>}
{detail.labels && <div>标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}</div>}
{detail.versions && detail.versions.length > 0 && (
<div>
<SectionLabel>版本链({detail.versions.length})</SectionLabel>
{detail.versions.map((v: any, i: number) => (
<div key={i} className="rdm-audit-version">
v{v.seq} · {v.actor} · {new Date(v.at).toLocaleString('zh-CN')}
</div>
))}
</div>
)}
{detail.formal && (
<div className="rdm-audit-formal">
正式版 R{detail.formal.rev}(内容 v{detail.formal.seq})——{detail.formal.actor} 于 {new Date(detail.formal.at).toLocaleString('zh-CN')}
</div>
)}
{detail.relations && detail.relations.length > 0 && (
<div>
<SectionLabel>一跳关系({detail.relations.length})</SectionLabel>
{detail.relations.map((r, i) => <RelationRow key={i} r={r} />)}
</div>
)}
{detail.recentEvents && detail.recentEvents.length > 0 && (
<div>
<SectionLabel>最近事件</SectionLabel>
{detail.recentEvents.map(ev => <EventRow key={ev.seq} ev={ev} />)}
</div>
)}
</div>
)
}
// ---- 覆盖范围标注(不冒充完整) ----
function CoverageList({ cov }: { cov: CoverageItem[] }) {
return (
<div className="rdm-audit-coverage">
<SectionLabel>覆盖范围</SectionLabel>
{cov.map(c => (
<div key={c.face} className="rdm-audit-coverage-item">
<Chip tone="plain">{c.face}</Chip>
<span>{c.reason}</span>
</div>
))}
</div>
)
}
+6
View File
@@ -7,6 +7,7 @@ import { OverlayHost, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
export function workspaceEntries(): WorkspaceEntry[] {
@@ -28,6 +29,11 @@ export function Workspaces() {
<AgentsPanel runs={convRuns} />
</OverlayHost>
}
if (active === 'rdm-audit') {
return <OverlayHost title="追溯审计" icon="🔍" shortcut={entry?.shortcut} sayHint="打开追溯审计" onClose={close}>
<RdmAuditWorkspace />
</OverlayHost>
}
if (active.startsWith('plugin:')) {
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
+28
View File
@@ -162,6 +162,8 @@ function loadUI(): UIState {
return { ...DEFAULT_UI }
}
const surfaceActionsInflight: Record<string, boolean> = {}
let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
@@ -899,6 +901,32 @@ export const store = {
})
syncFilesEntry(true)
},
// U5:页面动作提交——requestId 由客户端生成(服务端幂等键);草稿只在此刻
// 随动作外发(未提交输入不上传、不进模型上下文)。在途防抖挡双击。
async handleSurfaceAction(surfaceId: string, actionId: string, data: Record<string, unknown>) {
const inflight = surfaceActionsInflight[surfaceId + ':' + actionId]
if (inflight) return
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
if (!snap) { store.toast('页面快照不可用,无法提交'); return }
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2))
surfaceActionsInflight[surfaceId + ':' + actionId] = true
try {
const res = await api.submitSurfaceAction(surfaceId, {
actionId, revision: snap.revision, requestId, data,
})
if (res?.state === 'awaiting_confirm') store.toast('高危动作已发确认卡,请确认后执行')
else if (res?.state === 'committed') store.toast('已提交')
else store.toast('动作状态:' + (res?.state ?? 'unknown'))
} catch (e: any) {
const msg = String(e?.message ?? e)
if (msg.includes('revision conflict')) store.toast('页面已更新,请刷新后重试')
else if (msg.includes('not enabled') || msg.includes('403')) store.toast('动作被拒绝:插件未启用')
else if (msg.includes('different params')) store.toast('重复请求参数不一致,已拒绝')
else store.toast('动作失败:' + msg)
} finally {
delete surfaceActionsInflight[surfaceId + ':' + actionId]
}
},
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
activatePreview(id: string) {
const conv = previewTabsKey(state.currentConvId)
+49
View File
@@ -577,3 +577,52 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.session-note { width: 12rem; }
.rewind-actions { display: flex; gap: 8px; }
.rewind-error { color: var(--err-text); font-size: var(--fs-ui-sm); }
/* ---- U4 追溯审计工作区(令牌驱动;@ui/tokens.css 消费) ---- */
.rdm-audit { display: flex; flex-direction: column; min-height: 300px; gap: 0; }
.rdm-audit-header { display: flex; align-items: center; gap: 10px; padding: 0 0 10px; }
.rdm-audit-project { max-width: 220px; height: 32px; font-size: var(--fs-ui-sm); }
.rdm-audit-tabs { padding: 0 0 10px; }
.rdm-audit-body { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
.rdm-audit-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; margin-bottom: 12px; }
.rdm-audit-card { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; text-align: center; }
.rdm-audit-card-num { font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
.rdm-audit-card-label { font-size: var(--fs-ui-xs); color: var(--text-dim); margin-top: 2px; }
.rdm-audit-meta { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 8px; }
.rdm-audit-materials { display: flex; gap: 0; min-height: 200px; }
.rdm-audit-materials-side { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.rdm-audit-tree { display: flex; gap: 14px; min-height: 200px; }
.rdm-audit-tree-list { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.rdm-audit-tree-node { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: none; border: none; border-radius: var(--radius-sm); cursor: pointer; text-align: left; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-tree-node:hover { background: var(--bg-hover); color: var(--text); }
.rdm-audit-tree-node.sel { background: var(--bg-active); color: var(--text); }
.rdm-audit-tree-kind { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 3.2em; }
.rdm-audit-tree-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rdm-audit-more { margin-top: 8px; }
.rdm-audit-truncated { font-size: var(--fs-ui-sm); color: var(--text-faint); padding: 6px 0; }
.rdm-audit-detail { flex: 1; min-width: 0; border-left: 1px solid var(--border-soft); padding-left: 14px; }
.rdm-audit-detail-body { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-ui-sm); }
.rdm-audit-version { padding: 3px 0; color: var(--text-dim); }
.rdm-audit-formal { margin-top: 8px; padding: 8px 10px; background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--ok-text); }
.rdm-audit-pick { display: flex; flex-wrap: wrap; gap: 8px; }
.rdm-audit-trace-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.rdm-audit-trace-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.rdm-audit-trace-col { min-width: 0; }
.rdm-audit-rel { display: flex; align-items: center; gap: 6px; padding: 4px 0; font-size: var(--fs-ui-sm); }
.rdm-audit-rel-type { color: var(--text-dim); }
.rdm-audit-rel-endpoint { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rdm-audit-timeline-filters { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.rdm-audit-timeline-filters .ui-input { max-width: 160px; height: 30px; font-size: var(--fs-ui-sm); }
.rdm-audit-seq { max-width: 100px; }
.rdm-audit-opgroup { border: 1px solid var(--border-soft); border-radius: var(--radius-sm); margin-bottom: 4px; overflow: hidden; }
.rdm-audit-opgroup-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; background: var(--bg-panel); border: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text-dim); text-align: left; }
.rdm-audit-opgroup-head:hover { background: var(--bg-hover); }
.rdm-audit-opgroup-actor { font-weight: 550; color: var(--text); }
.rdm-audit-opgroup-time { font-size: var(--fs-ui-xs); color: var(--text-faint); }
.rdm-audit-event { display: flex; align-items: center; gap: 8px; padding: 4px 10px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 5em; }
.rdm-audit-event-op { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--accent-text); min-width: 8em; }
.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; }
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
.rdm-audit-coverage { margin-top: 12px; }
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
// U5 验收线第 4/6 条的客户端半边:请求构造只携带显式动作数据(未提交草稿
// 无任何自动上传路径——构造函数是纯函数,测试其形状);requestId 每次唯一。
function buildActionRequest(snapshot: { revision: number }, actionId: string, drafts: Record<string, unknown>) {
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now())
return {
actionId,
revision: snapshot.revision,
requestId,
data: drafts,
}
}
describe('surface action request', () => {
it('携带当前修订与显式草稿;requestId 唯一', () => {
const snap = { revision: 3 }
const a = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
const b = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
expect(a.revision).toBe(3)
expect(a.actionId).toBe('generative-ui.submit')
expect(a.data).toEqual({ f1: { years: '5' } })
expect(a.requestId).not.toBe(b.requestId)
})
})
+1
View File
@@ -18,6 +18,7 @@ export interface WorkspaceSource {
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
{ key: 'agents', title: '代理', icon: '🤖' },
{ key: 'rdm-audit', title: '追溯审计', icon: '🔍' },
]
function shortcutNum(raw?: string): number | null {
+11
View File
@@ -40,4 +40,15 @@ describe('auth 凭证模块', () => {
expect(auth.apiKey()).toBe('')
expect(auth.withKey('/x')).toBe('/x')
})
it('beginSession:密码会话生效后清残留 Bearer,fetch 不再带头', async () => {
auth.setKey('ask_stale')
auth.beginSession()
expect(auth.apiKey()).toBe('')
const spy = vi.fn().mockResolvedValue(new Response('{}'))
vi.stubGlobal('fetch', spy)
await auth.fetch('/api/me')
const [, init] = spy.mock.calls[0] as [string, RequestInit | undefined]
expect(new Headers(init?.headers ?? {}).get('Authorization')).toBeNull()
})
})
+32
View File
@@ -0,0 +1,32 @@
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
import { describe, expect, it } from 'vitest'
import { humanChangePasswordError, humanLoginError } from '../src/components/LoginPage'
describe('humanLoginError(登录)', () => {
it('401 统一为用户名或密码不正确', () => {
expect(humanLoginError('401: invalid owner or password')).toBe('用户名或密码不正确')
})
it('429/412 各有文案,其余剥状态码前缀', () => {
expect(humanLoginError('429: too many failed attempts; retry later')).toBe('失败次数过多,请稍后再试')
expect(humanLoginError('412: auth disabled (embedded single-tenant mode)')).toBe('本服务未开启登录(嵌入式单租户档)')
expect(humanLoginError('400: bad')).toBe('bad')
})
})
describe('humanChangePasswordError(改密门)', () => {
it('当前密码错误不再伪装成登录失败', () => {
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
})
it('无会话(API key 凭证)给出可行动指引', () => {
expect(humanChangePasswordError('401: session login required (API keys do not own passwords)')).toBe('请先用账号密码登录再改密(API 密钥不能修改密码)')
})
it('会话过期与失效 key 各有文案', () => {
expect(humanChangePasswordError('401: session expired')).toBe('会话已过期,请重新登录')
expect(humanChangePasswordError('401: unauthorized: invalid or revoked API key')).toBe('本地残留的 API 密钥已失效,请退出登录后重试')
})
it('未知 401 回落通用文案,其余错误剥前缀透传', () => {
expect(humanChangePasswordError('401: whatever')).toBe('认证失败,请重新登录')
expect(humanChangePasswordError('400: password must be 8-256 characters')).toBe('password must be 8-256 characters')
})
})