Files
Agent-System-Frontend/src/documentCockpit.ts
T
weichen 86770e35fb Add cockpit reader view and evidence panel (I3)
Why: implement the reviewed I3 increment - continuous body reading with
per-section status lines, version summary with collapsed line-diff (backend
versions projection from RDM PR #420), right evidence panel with
sources/history/impacts/conflicts tabs, panorama-reader positioning on the
shared selection, per user+project view preferences (panorama/reader,
density, panel tab), strict-seq history merging and honest restricted states.

Evidence: 10 new pure-logic tests (prefs validation, version summary and
diff marks, strict history merge drops, reader assembly three-state); vitest
324/324 green; tsc and vite build green; tokens-only styles.

Impact: cockpit-only; no fourth column, no standalone graph (relations stay
in the right panel); reader entry is explicit (panorama button / view toggle).

Refs: rollout I3
2026-10-16 12:21:24 +00:00

343 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 文档驾驶舱 I2 纯逻辑层(rollout I2;契约:Dev 仓 docs/superpowers/specs
// 2026-10-16-document-visualization-read-model-contract.md @ 37b272ba,
// 四项取舍冻结见 plans/…-rollout.md §7)。只放可测判定:筛选切换、统计呈现
//(真 0 vs 不可算)、快照一致性与更新提示、上下文键与选择保留。组件只做取数编排与渲染。
// ---- 契约类型(I1 后端 DTO 的前端精简形态) ----
export interface CockpitNodeRef { kind: string; id: string; version?: string }
export interface CockpitMeta {
schemaVersion: string
projectId: string
snapshotRevision: string
asOfSeq: number
generatedAt: string
truncated: boolean
truncationReasons?: string[]
}
export interface CockpitNodeState {
content: string // complete|partial|empty|inaccessible|unavailable
verification: string // verified|mixed|unverified|disputed|not_applicable
sourceCount: number
conflictCount: number
impactCount: number
missingChildCount: number
structuralGap: boolean
}
export interface CockpitTreeNode {
ref: CockpitNodeRef
title: string
path: string[]
parent?: CockpitNodeRef
childCount: number
state: CockpitNodeState
lastChangedAt?: string
lastChangedBy?: string
visibility: string // full|redacted|metadata_only
}
export interface CockpitStats {
documentCount: number
completion: { known: number; total: number; state: string }
emptyNodeCount: number
unverifiedSourceCount: number
conflictCount: number
pendingChangeCount: number | null
updatedAt?: string
statReasons: Record<string, string>
}
export interface CockpitChangeSummary {
node: CockpitNodeRef
title: string
changedAt: string
actor?: string
kind: string
summary: string
version?: string
}
export interface CockpitOverview {
meta: CockpitMeta
project: { title: string }
stats: CockpitStats
documents: CockpitTreeNode[]
recentChanges: CockpitChangeSummary[]
}
export interface CockpitTreePage {
meta: CockpitMeta
items: CockpitTreeNode[]
nextCursor?: string
hasMore: boolean
}
// ---- 统计带:点击即切换共享筛选(视图规格 §2;未知筛选由后端 400 拒绝) ----
export const STAT_FILTERS: { key: string; label: string; filter: string }[] = [
{ key: 'empty', label: '空缺节点', filter: 'state:empty' },
{ key: 'unverified', label: '未核实来源', filter: 'state:unverified' },
{ key: 'conflict', label: '冲突', filter: 'state:conflict' },
]
/** 点击统计卡:同筛选再次点击=清除(只定位,不执行写入)。 */
export function toggleFilter(current: string, filter: string): string {
return current === filter ? '' : filter
}
/** 统计呈现:null(不可算)显示 — 加原因;真 0 显示 0。两侧都不得互相冒充(契约 §2/§6)。 */
export function statDisplay(stats: CockpitStats, key: string): { value: string; hint?: string } {
const reasons = stats.statReasons || {}
if (key === 'documents') return { value: String(stats.documentCount) }
if (key === 'completion') {
const c = stats.completion
if (c.state === 'unavailable') return { value: '—', hint: reasons['completion'] || '无法计算' }
if (c.state === 'partial') return { value: `${c.known}/${c.total}`, hint: '部分计算' }
return { value: `${c.known}/${c.total}` }
}
const map: Record<string, number | null | undefined> = {
empty: stats.emptyNodeCount,
unverified: stats.unverifiedSourceCount,
conflict: stats.conflictCount,
pending: stats.pendingChangeCount,
}
const v = map[key]
// 原因表按后端 DTO 字段名(statReasons 键=字段名),展示键需映射。
const reasonKey: Record<string, string> = {
empty: 'emptyNodeCount', unverified: 'unverifiedSourceCount',
conflict: 'conflictCount', pending: 'pendingChangeCount',
}
if (v === null || v === undefined) return { value: '—', hint: reasons[reasonKey[key] || key] || '暂不可计算' }
return { value: String(v) }
}
// ---- 快照一致性(DV-R05 前端侧):统计与树徽标同快照才同屏可信 ----
export function sameSnapshot(a?: CockpitMeta, b?: CockpitMeta): boolean {
if (!a || !b) return false
return a.snapshotRevision === b.snapshotRevision && a.asOfSeq === b.asOfSeq
}
/** 更新提示:展示中快照落后于最新已见快照时提示刷新(不抢占滚动,视图规格 §5)。 */
export function updateNotice(viewed: CockpitMeta | null, latest: CockpitMeta | null): string | null {
if (!viewed || !latest) return null
if (viewed.snapshotRevision === latest.snapshotRevision && viewed.asOfSeq === latest.asOfSeq) return null
return `内容已更新(快照 ${latest.snapshotRevision})——刷新查看最新状态`
}
/** 上下文键:项目+筛选标识一轮取数上下文;不匹配的旧响应直接丢弃(契约 §8)。 */
export function contextKey(projectId: string, filter: string): string {
return `${projectId}\u0001${filter}`
}
/** 刷新不重置选择:文档仍在根清单(或选中章节的父文档仍在)则保留,否则清空。 */
export function selectionSurvives(
selected: CockpitNodeRef | null,
rootDocIds: string[],
parentDocId?: string,
): CockpitNodeRef | null {
if (!selected) return null
if (selected.kind === 'document') return rootDocIds.includes(selected.id) ? selected : null
if (parentDocId && rootDocIds.includes(parentDocId)) return selected
return null
}
/** 树行徽标(诚实状态:空/部分/受限/未核实/冲突计数;不虚构成功)。 */
export function treeBadges(n: CockpitTreeNode): string[] {
const out: string[] = []
if (n.state.content === 'empty') out.push('空')
else if (n.state.content === 'partial') out.push('部分')
else if (n.state.content === 'inaccessible') out.push('受限')
if (n.visibility === 'metadata_only') out.push('仅元数据')
if (n.state.verification === 'unverified') out.push('未核实')
if (n.state.conflictCount > 0) out.push(`冲突 ${n.state.conflictCount}`)
return out
}
/** 节点状态一句话总结(全景 §4.2)。 */
export function nodeSummaryLine(n: CockpitTreeNode): string {
const parts: string[] = []
const content = { complete: '内容完整', partial: '内容部分', empty: '内容为空', inaccessible: '内容受限', unavailable: '暂不可读' }[n.state.content] || n.state.content
parts.push(content)
if (n.state.structuralGap) parts.push('层级止于章节')
if (n.state.sourceCount > 0) parts.push(`来源 ${n.state.sourceCount}`)
if (n.state.conflictCount > 0) parts.push(`冲突 ${n.state.conflictCount}`)
if (n.state.impactCount > 0) parts.push(`影响 ${n.state.impactCount}`)
return parts.join(' · ')
}
// ---- I3:阅读器与右栏纯逻辑 ----
export interface CockpitVersionSummary {
seq: number
version: string
actor?: string
at: string
basisCount: number
content?: string
redacted?: boolean
}
export interface CockpitContentBlock { kind: string; text?: string; reason?: string }
export interface CockpitSourceSummary {
ref: { kind: string; id: string }
label: string
capturedAt?: string
confidence: string
verification: string
visibility: string
redactedReason?: string
}
export interface CockpitHistorySummary {
seq: number
at: string
op: string
actor?: string
summary: string
operationId?: string
}
export interface CockpitImpactSummary {
ref: { kind: string; id: string }
edgeType: string
edgeState: string
direction: string
layer: string
}
export interface CockpitAssertion { node: CockpitNodeRef; claim: string; visibility: string; redactedReason?: string }
export interface CockpitConflictSummary {
id: string
node: CockpitNodeRef
state: string
left: CockpitAssertion
right: CockpitAssertion
detectedAt: string
resolutionRef?: string
}
export interface CockpitPage<T> { meta?: CockpitMeta; items: T[]; nextCursor?: string; hasMore: boolean; truncated?: boolean }
export const PANEL_TABS = [
{ id: 'sources', label: '内容来源' },
{ id: 'history', label: '修改历史' },
{ id: 'impacts', label: '影响对象' },
{ id: 'conflicts', label: '冲突与风险' },
] as const
export type PanelTabId = (typeof PANEL_TABS)[number]['id']
export type ViewMode = 'panorama' | 'reader'
export type Density = 'compact' | 'full'
export interface CockpitPrefs {
lastView: ViewMode
density: Density
rightPanelTab: PanelTabId
}
/** 偏好按 用户+项目 隔离(设计 §7.3);仅本地存储,不构成授权。 */
export function prefsKey(actor: string, projectId: string): string {
return `cockpit-prefs:${actor}:${projectId}`
}
/** 解析偏好:枚举外的脏值逐项回落默认(panorama+compact+sources)。 */
export function mergePrefs(raw: unknown): CockpitPrefs {
const base: CockpitPrefs = { lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' }
if (!raw || typeof raw !== 'object') return base
const r = raw as Record<string, unknown>
const out = { ...base }
if (r.lastView === 'panorama' || r.lastView === 'reader') out.lastView = r.lastView
if (r.density === 'compact' || r.density === 'full') out.density = r.density
if (PANEL_TABS.some(t => t.id === r.rightPanelTab)) out.rightPanelTab = r.rightPanelTab as PanelTabId
return out
}
export function loadPrefs(actor: string, projectId: string): CockpitPrefs {
try {
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(prefsKey(actor, projectId)) : null
return mergePrefs(raw ? JSON.parse(raw) : null)
} catch {
return mergePrefs(null)
}
}
export function savePrefs(actor: string, projectId: string, prefs: CockpitPrefs): void {
try {
if (typeof localStorage !== 'undefined') localStorage.setItem(prefsKey(actor, projectId), JSON.stringify(prefs))
} catch { /* 存储不可用时偏好静默降级为会话内 */ }
}
/** 版本摘要:当前版本 + 前一版本(视图规格 §5)。versions 为最新在前;受限版本无 content。 */
export function versionSummary(versions: CockpitVersionSummary[] | undefined): {
current?: CockpitVersionSummary
previous?: CockpitVersionSummary
redacted: boolean
} {
if (!versions || versions.length === 0) return { redacted: false }
const current = versions[0]
const previous = versions.length > 1 ? versions[1] : undefined
return { current, previous, redacted: !!current.redacted }
}
/** 历史面板合并:只接受严格更新(seq 更大且未见)的条目;过期与重复都计入 dropped(DV-R05/契约 §8)。 */
export function mergeHistoryStrict(existing: CockpitHistorySummary[], incoming: CockpitHistorySummary[]): {
items: CockpitHistorySummary[]
dropped: number
} {
const seen = new Set(existing.map(e => e.seq))
const maxSeq = existing.length > 0 ? Math.max(...seen) : -1
const items = [...existing]
let dropped = 0
for (const e of incoming) {
if (e.seq <= maxSeq || seen.has(e.seq)) {
dropped++
continue
}
seen.add(e.seq)
items.push(e)
}
return { items, dropped }
}
/** 阅读器装配:把文档的章节详情按树顺序组装为连续正文;受限章节给占位说明而非空白。 */
export function assembleReader(
sections: CockpitTreeNode[],
details: Record<string, { body?: CockpitContentBlock[]; versions?: CockpitVersionSummary[] } | undefined>,
): { ref: CockpitNodeRef; title: string; contentState: string; blocks: CockpitContentBlock[]; versions?: CockpitVersionSummary[] }[] {
return sections.map(sec => {
const d = details[sec.ref.id]
const readable = !!d?.body && d.body.length > 0 && d.body[0].kind === 'text'
const restricted = !!d?.body && d.body.length > 0 && d.body[0].kind === 'restricted'
const blocks: CockpitContentBlock[] = readable
? (d!.body as CockpitContentBlock[])
: restricted
? [{ kind: 'restricted', reason: d?.body?.[0]?.reason || 'section-duty-or-authorship' }]
: [{ kind: 'restricted', reason: 'not_loaded' }]
return { ref: sec.ref, title: sec.title, contentState: sec.state.content, blocks, versions: d?.versions }
})
}
/** 行级差异(阅读器 Collapsible 的完整对比):按行对齐的朴素 LCS 免实现——
* 以行为单位做集合比对标记 +/=/-,足够呈现「完整差异」而不冒充精确补丁。 */
export function diffLines(current: string, previous: string): { flag: '+' | '-' | '='; text: string }[] {
const cur = current.split('\n')
const prev = new Set(previous.split('\n'))
const curSet = new Set(cur)
const out: { flag: '+' | '-' | '='; text: string }[] = []
for (const line of cur) out.push({ flag: prev.has(line) ? '=' : '+', text: line })
for (const line of previous.split('\n')) if (!curSet.has(line)) out.push({ flag: '-', text: line })
return out
}
/** 历史条目的自然语言时间线文本(右栏 §6)。 */
export function historyLine(h: CockpitHistorySummary): string {
const who = h.actor ? `${h.actor} ` : ''
return `${h.summary}(${who}${new Date(h.at).toLocaleString('zh-CN')})`
}