数据目录阶段 4A 前端:画布和问数据页的回答下显示「建议更新数据目录」卡片,逐项列出改前、改后和理由,保存带版本记为人工确认,遇到 409 可重新载入后再保存,也可忽略

This commit is contained in:
ThearchyHelios
2026-10-03 11:40:37 +08:00
parent 1a760726f7
commit 1c806fa3a0
16 changed files with 713 additions and 5 deletions
+9 -1
View File
@@ -1,6 +1,6 @@
import type {
ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftOut, CatalogList, CatalogNotes,
CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview, EditRequest, ImportRecord,
CatalogPatchPreview, CatalogPatchSubmit, CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview, EditRequest, ImportRecord,
ManifestOut, PurgeRawBody, PurgeRawOut, QuestionAnswer, Recipe, RedraftRulesOut, RemovePeriodBody, RemovePeriodOut,
RevokeAcceptanceBody, RevokeAcceptanceOut, SnapshotOut, Staging,
} from '../types'
@@ -669,6 +669,14 @@ export const api = {
/** 单项审阅。path:表级项写字段名(grain);列级项 columns.<列名>.<字段>;关系 relations.<编号> */
review: (sourceId: string, table: string, body: { path: string; action: CatalogReviewAction; if_version: number }) =>
post<CatalogDetail>(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/review`, body),
/** 目录修改提案的预览:对着当前目录重算改前、改后,不写库(保存遇到 409 后重新载入用) */
patchPreview: (sourceId: string, table: string, changes: CatalogPatchSubmit[]) =>
post<CatalogPatchPreview>(
`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/patch/preview`, { changes }),
/** 保存目录修改提案:改动过的项记为人工填写、已确认。版本不符 409,有一项不合法 422 */
patch: (sourceId: string, table: string, changes: CatalogPatchSubmit[], ifVersion: number) =>
post<CatalogDetail>(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/patch`,
{ changes, if_version: ifVersion }),
/**
* 同步起草(注释、外键、命名推断;use_model 时再请助手的模型起草)。不给 tables 时按使用次数取前 20 张。
* 人工确认、驳回过的项不动。没有表结构时回 409
+8 -1
View File
@@ -6,6 +6,7 @@ import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantS
import { Markdown } from '../run/Markdown'
import { ApprovalCard } from '../run/RunPanel'
import { decodeCopilot, decodeRun } from '../run/decode'
import { catalogPatchesOf } from '../run/catalogPatch'
import { ToastHost } from '../components/ui'
import type { RunEvent } from '../types'
import fixtures from '../run/__tests__/fixtures.json'
@@ -13,7 +14,7 @@ import mdSamples from '../run/__tests__/markdown-samples.json'
import evidenceFixture from '../run/__tests__/evidence-doc.json'
import evidenceJudge from '../run/__tests__/evidence-judge.json'
import {
CALIBER_ONE_OUTPUT, CALIBERS_OUTPUT, COPILOT_CONTEXT, COPILOT_CONTEXT_FALLBACK, COPILOT_STUCK, COPILOT_TOOLS_DROPPED, MIXED_OUTPUT, abandonedRun, cancelledRun, exhaustedTeam, longLoop,
CALIBER_ONE_OUTPUT, CALIBERS_OUTPUT, COPILOT_CATALOG_PATCH, COPILOT_CONTEXT, COPILOT_CONTEXT_FALLBACK, COPILOT_STUCK, COPILOT_TOOLS_DROPPED, MIXED_OUTPUT, abandonedRun, cancelledRun, exhaustedTeam, longLoop,
markupRun, mergeRun, mixedRun, pipelineRun, repairRun, teamRun, timeoutRun,
} from '../run/__tests__/synthetic'
import '../index.css'
@@ -343,6 +344,12 @@ function synthetic(name: string): StreamTurn[] {
// 大库按需求挑表:过程里一行「参考了 N 张表」,展开按数据源分组列表名
return [{ id: 'copilot-context', question: '各渠道上月的入园人次', phase: 'done', status: '已应用到画布',
steps: decodeCopilot(COPILOT_CONTEXT, { context: 'canvas' }) }]
case 'catalog-patch':
// 用户说了一条数据事实:助手只回答、不改图,附一张「建议更新数据目录」卡片
return [{ id: 'catalog-patch', question: 'status=9 表示作废,统计时要排除', phase: 'done', status: '已回答(未修改画布)',
steps: decodeCopilot(COPILOT_CATALOG_PATCH, { context: 'canvas' }),
output: { 回答: '好的,已整理成数据目录的修改建议,确认后保存即可。' },
catalogPatches: catalogPatchesOf(COPILOT_CATALOG_PATCH) }]
case 'copilot-fallback':
return [{ id: 'copilot-fallback', question: '各渠道上月的入园人次', phase: 'done', status: '已应用到画布',
steps: decodeCopilot(COPILOT_CONTEXT_FALLBACK, { context: 'canvas' }) }]
+39
View File
@@ -2148,3 +2148,42 @@ export const CATALOG_TEXT = {
reloadDiscardTitle: '放弃未保存的修改并重新载入?',
reloadDiscardAction: '放弃修改并重新载入',
}
// ===========================================================================
// 目录修改提案:用户在助手(画布)或问数据页说出一条数据事实时,助手提出的数据目录修改。
//
// 术语:
// - 卡片标题写「建议更新数据目录」;两个动作是「保存到数据目录」和「忽略」。提案在保存之前不写入目录。
// - 每一项写「改前 / 改后 / 理由」。保存后改动过的项记为人工填写、已确认(和直接编辑同一条规矩)。
// - 别人在提案之后改过这张表:写「这张表刚被修改过」,给「重新载入」,按最新内容重算改前和改后再保存。
// ===========================================================================
export const CATALOG_PATCH_TEXT = {
title: '建议更新数据目录',
/** 过程里的那一行 */
step: (table: string, n: number) => `建议更新「${table}」的数据目录(${formatNumber(n)} 项)`,
stepSub: '确认后才写入数据目录',
before: '改前',
after: '改后',
reason: '理由',
empty: '未填写',
stateConfirm: '值不变,保存即确认',
stateSame: '已是这个值',
save: '保存到数据目录',
saving: '正在保存…',
ignore: '忽略',
hint: '保存后改动的项记为人工填写、已确认,助手和 SQL 检查随即采用',
saved: (version: number) => `已保存到数据目录(第 ${formatNumber(version)} 版)`,
savedToast: (table: string) => `已更新「${table}」的数据目录`,
ignored: '已忽略这条建议,数据目录未修改',
undoIgnore: '重新查看',
open: '在数据目录中查看',
conflictTitle: '这张表刚被修改过',
conflictBody: '其他人在这条建议之后修改了这张表的数据目录。重新载入后按最新内容重算改前和改后,确认后再保存。',
reload: '重新载入',
reloading: '正在重新载入…',
allSame: '数据目录中已是这些值,无需保存',
problems: '以下几项已无法按建议保存:',
whereRelationNew: (to: string) => `新增指向 ${to} 的关联关系`,
sourceOf: (source: string) => `数据源「${source}」`,
}
+3
View File
@@ -10,6 +10,7 @@ import { ApprovalCard } from '../run/RunPanel'
import { AssistantStream, type StreamTurn, type TurnFailure } from '../run/AssistantStream'
import { PromptBox } from '../run/Composer'
import { decodeCopilot, decodeRun, exitLabels } from '../run/decode'
import { catalogPatchesOf } from '../run/catalogPatch'
import { sourceHandles } from '../canvas/nodeDefs'
import { isComposing, PageHeader, Skeleton, Spinner, StatusBadge, TechDetails, toast } from '../components/ui'
import { humanizeError } from '../lib/errors'
@@ -401,6 +402,8 @@ function toStreamTurn(turn: ChatTurn): StreamTurn {
runClass: turn.run?.run_class,
graph: turn.graph,
graphNote: turn.explanation,
// 助手提出的数据目录修改:每条一张卡片,人点保存才写入。不随「执行过程」收起——要人拿主意的事不能藏着
catalogPatches: catalogPatchesOf(turn.ops),
noQuery: turn.noQuery,
review: turn.review,
rawOutput: turn.rawOutput,
+2
View File
@@ -22,6 +22,7 @@ import { EvidenceHostContext, type EvidenceHost } from './evidenceHost'
import {
COPILOT_PHASE_TEXT, copilotOutcome, decodeCopilot, decodeRun, exitLabels, issueLine, SELF_CHECK_ROUNDS, type CopilotIssue, type CopilotOutcome,
} from './decode'
import { catalogPatchesOf } from './catalogPatch'
import { ApprovalCard } from './RunPanel'
import { useRunGlance } from './RunHud'
import { isSettled, lastEventAt, liveAt, project, type RunPhase, type Trace } from './trace'
@@ -204,6 +205,7 @@ function copilotTurn(
statusCode,
steps,
output: reply ? { 回答: reply } : t.explanation ? { 说明: t.explanation } : null,
catalogPatches: catalogPatchesOf(t.ops),
error: errorOp
? { error: errorOp.message, hint: errorOp.hint, detail: errorOp.raw }
: t.error || undefined,
+11
View File
@@ -26,6 +26,8 @@ import { ClaimTally, EvidenceField, type EvidenceDocHandle } from './EvidenceDoc
import { EVIDENCE_STATE, claimProblems, docTally, evidenceFields, issuanceMarks, type EvidenceTally } from '../lib/evidence'
import type { EvidenceDocData } from '../types'
import { useRunClock } from './useRunClock'
import { CatalogPatchCard } from './CatalogPatchCard'
import type { CatalogPatch } from './catalogPatch'
import type { ReviewResult } from '../types'
/**
@@ -98,6 +100,11 @@ export interface StreamTurn {
noQuery?: boolean
/** 复核结论:这次运行有什么不对,以及对这个答案意味着什么 */
review?: ReviewResult | null
/**
* 助手这一轮提出的数据目录修改(catalog_patch)。每条一张「建议更新数据目录」卡片,人点保存才写入;
* 从库里恢复的轮次没有操作流,也就没有卡片
*/
catalogPatches?: CatalogPatch[]
/** 被复核重写之前的答案。改写是有损的,得能对照原件 */
rawOutput?: Record<string, any> | null
/**
@@ -532,6 +539,10 @@ function TurnCard({ turn, last, approvals, onOpenGraph, onFollowUp }: {
</details>
)}
{turn.catalogPatches?.map((p) => (
<CatalogPatchCard key={p.key} patch={p} turnId={turn.id} dense={dense} />
))}
{!!turn.graph?.nodes?.length && (
<GraphPeek graph={turn.graph} note={turn.graphNote}
onOpen={onOpenGraph && ((g) => onOpenGraph(g, turn.question))} />
+219
View File
@@ -0,0 +1,219 @@
import { useCallback, useState } from 'react'
import { Link } from 'react-router-dom'
import { BookMarked, Check, ExternalLink, RotateCw } from 'lucide-react'
import clsx from 'clsx'
import { ApiError, api } from '../api/client'
import { Notice, Spinner, toast } from '../components/ui'
import { CATALOG_PATCH_TEXT as T, CATALOG_STATUS_LABEL } from '../lib/terms'
import type { CatalogPatchChange } from '../types'
import {
catalogTableHref, patchSubmit, patchTarget, patchValueText, patchWhere, type CatalogPatch,
} from './catalogPatch'
// ===========================================================================
// 「建议更新数据目录」卡片:助手把用户说的一条数据事实整理成目录修改提案(catalog_patch),这里逐项列出改前、
// 改后和理由,人点「保存到数据目录」才写入——服务端从不自动写。
//
// 保存带着提案对照的版本:别人在这之后改过这张表就 409,卡片写明「这张表刚被修改过」,「重新载入」按最新的
// 目录重算改前和改后(预览接口,只读),人看过再保存。保存成功后给到数据目录那张表的入口。
//
// 卡片的结局(已保存、已忽略、重新载入后的内容)记在模块级的表里:画布右栏在对话层和运行层之间切换会卸载
// 卡片,回来时不能又变回「未保存」,再点一次只会撞上 409。
// ===========================================================================
type Phase = 'open' | 'saving' | 'conflict' | 'reloading' | 'saved' | 'ignored'
interface CardState {
phase: Phase
changes: CatalogPatchChange[]
version: number
/** 重新载入后已无法按建议保存的项(列已删除……) */
problems: string[]
/** 保存被拒(422)时服务端的原话 */
error: string | null
savedVersion?: number
}
const remembered = new Map<string, CardState>()
export function CatalogPatchCard({ patch, turnId, dense = false }: {
patch: CatalogPatch
/** 所在轮次:和 patch.key 一起认出「同一张卡片」 */
turnId: string
dense?: boolean
}) {
const memo = `${turnId}:${patch.key}`
const [state, setStateRaw] = useState<CardState>(() => remembered.get(memo)
?? { phase: 'open', changes: patch.changes, version: patch.version, problems: [], error: null })
const setState = useCallback((next: Partial<CardState>) => {
setStateRaw((cur) => {
const merged = { ...cur, ...next }
remembered.set(memo, merged)
return merged
})
}, [memo])
const label = patch.tableLabel ?? patch.table
const pending = state.changes.filter((c) => c.state !== 'same')
const href = catalogTableHref(patch.sourceId, patch.table)
const save = async () => {
if (!pending.length) return
setState({ phase: 'saving', error: null })
try {
const d = await api.dataCatalog.patch(patch.sourceId, patch.table, patchSubmit(state.changes), state.version)
setState({ phase: 'saved', savedVersion: d.version })
toast.ok(T.savedToast(label))
} catch (e) {
if (e instanceof ApiError && e.status === 409) setState({ phase: 'conflict' })
else if (e instanceof ApiError && e.kind === 'http' && (e.status === 422 || e.status === 404)) {
setState({ phase: 'open', error: e.message })
} else {
setState({ phase: 'open' })
toast.error(e)
}
}
}
const reload = async () => {
setState({ phase: 'reloading', error: null })
try {
// 交回的是助手原来的提案:码值的补充要落在最新的目录上重算,不能沿用上一次算好的「改后」
const p = await api.dataCatalog.patchPreview(patch.sourceId, patch.table, patchSubmit(patch.changes))
setState({ phase: 'open', changes: p.changes, version: p.version, problems: p.problems })
} catch (e) {
setState({ phase: 'conflict' })
toast.error(e)
}
}
if (state.phase === 'ignored') {
return (
<div className="mt-2 flex flex-wrap items-center gap-2 rounded-lg border border-dashed px-2.5 py-1.5 text-2xs text-faint"
data-catalog-patch={patch.table} data-patch-phase="ignored">
<BookMarked size={11} aria-hidden />
<span className="min-w-0 flex-1">{T.ignored}</span>
<button type="button" className="rounded px-1 text-dim hover:bg-hover hover:text-fg" onClick={() => setState({ phase: 'open' })}
data-patch-unignore="">
{T.undoIgnore}
</button>
</div>
)
}
const busy = state.phase === 'saving' || state.phase === 'reloading'
const saved = state.phase === 'saved'
const settled = saved || (!pending.length && state.phase === 'open')
return (
<section className="mt-2 rounded-lg border bg-bg" aria-label={T.title}
data-catalog-patch={patch.table} data-patch-phase={state.phase} data-patch-version={state.version}>
<header className={clsx('flex flex-wrap items-baseline gap-x-2 gap-y-0.5 border-b', dense ? 'px-2.5 py-1.5' : 'px-3 py-2')}>
<span className="inline-flex items-center gap-1.5 text-xs font-semibold">
<BookMarked size={12} className="shrink-0" style={{ color: 'var(--accent)' }} aria-hidden />
{T.title}
</span>
<span className="min-w-0 text-2xs text-dim">
「{label}」{patch.tableLabel && <span className="mono text-faint"> {patch.table}</span>}
<span className="text-faint"> · {T.sourceOf(patch.source)}</span>
</span>
</header>
<ol className={clsx('divide-y divide-[var(--hairline)]', dense ? 'px-2.5' : 'px-3')}>
{state.changes.map((c) => <ChangeRow key={c.path} change={c} dense={dense} />)}
</ol>
<div className={clsx('space-y-2 border-t', dense ? 'px-2.5 py-2' : 'px-3 py-2.5')}>
{state.problems.length > 0 && (
<Notice tone="warn" attr={{ 'data-patch-problems': String(state.problems.length) }}>
<p>{T.problems}</p>
<ul className="mt-0.5 list-disc pl-4 text-dim">{state.problems.map((p) => <li key={p}>{p}</li>)}</ul>
</Notice>
)}
{state.error && <Notice tone="err" attr={{ 'data-patch-error': '' }}>{state.error}</Notice>}
{state.phase === 'conflict' || state.phase === 'reloading'
? (
<Notice tone="warn" attr={{ 'data-patch-conflict': '' }}>
<p className="font-medium">{T.conflictTitle}</p>
<p className="mt-0.5 text-dim">{T.conflictBody}</p>
<button type="button" className="btn btn-sm mt-2" disabled={busy} onClick={() => void reload()} data-patch-reload="">
{state.phase === 'reloading' ? <><Spinner size={11} /> {T.reloading}</> : <><RotateCw size={11} aria-hidden /> {T.reload}</>}
</button>
</Notice>
)
: settled
? (
<div className="space-y-2">
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs" data-patch-done="">
<Check size={12} style={{ color: 'var(--ok)' }} aria-hidden />
<span>{saved && state.savedVersion != null ? T.saved(state.savedVersion) : T.allSame}</span>
<Link to={href} className="inline-flex items-center gap-1 text-2xs text-[var(--accent)] hover:underline" data-patch-open="">
{T.open} <ExternalLink size={10} aria-hidden />
</Link>
</p>
</div>
)
: (
<div className="flex flex-wrap items-center gap-2">
<button type="button" className="btn btn-sm btn-primary" disabled={busy} onClick={() => void save()} data-patch-save="">
{state.phase === 'saving' ? <><Spinner size={11} /> {T.saving}</> : T.save}
</button>
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => setState({ phase: 'ignored' })} data-patch-ignore="">
{T.ignore}
</button>
<span className="min-w-0 flex-1 text-2xs text-faint">{T.hint}</span>
</div>
)}
</div>
</section>
)
}
/** 一项:改到哪儿、改前、改后、理由 */
function ChangeRow({ change: c, dense }: { change: CatalogPatchChange; dense: boolean }) {
const isCodes = patchTarget(c.path)?.kind === 'column' && c.path.endsWith('.codes')
return (
<li className={clsx('text-xs', dense ? 'py-1.5' : 'py-2')} data-patch-change={c.path} data-patch-state={c.state}>
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="font-medium">{patchWhere(c)}</span>
{c.state === 'confirm' && <span className="text-2xs text-faint">{T.stateConfirm}</span>}
{c.state === 'same' && <span className="text-2xs text-faint">{T.stateSame}</span>}
</div>
<dl className="mt-1 grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5 text-2xs leading-relaxed">
<dt className="text-faint">{T.before}</dt>
<dd className={clsx('break-words', c.before == null ? 'text-faint' : 'text-dim')} data-patch-before="">
{patchValueText(c.path, c.before)}
{c.before != null && c.before_status && <span className="text-faint">({CATALOG_STATUS_LABEL[c.before_status]})</span>}
</dd>
<dt className="text-faint">{T.after}</dt>
<dd className="break-words text-fg" data-patch-after="">
{isCodes ? <CodesAfter before={c.before} after={c.after} /> : patchValueText(c.path, c.after)}
</dd>
{c.reason && (
<>
<dt className="text-faint">{T.reason}</dt>
<dd className="break-words text-dim" data-patch-reason="">{c.reason}</dd>
</>
)}
</dl>
</li>
)
}
/** 码值的改后:新加和改过的码加重,原有的照常写——一眼看出这条提案补了什么 */
function CodesAfter({ before, after }: { before: unknown; after: unknown }) {
const prev = (before && typeof before === 'object' ? before : {}) as Record<string, string>
const entries = Object.entries((after && typeof after === 'object' ? after : {}) as Record<string, string>)
return (
<span>
{entries.map(([k, v], i) => (
<span key={k}>
{i > 0 && '、'}
<span className={clsx(prev[k] !== v && 'font-medium text-[var(--accent)]')} data-code-changed={prev[k] !== v ? k : undefined}>
{k}={v}
</span>
</span>
))}
</span>
)
}
+18
View File
@@ -455,6 +455,24 @@ export const COPILOT_CONTEXT = [
{ op: 'final', graph: { nodes: [{ id: 'q' }, { id: 'o' }] } },
]
/**
* 用户在助手里说了一条数据事实(「status=9 表示作废,统计时要排除」):服务端核对后转出目录修改提案
* (catalog_patch,带版本、改前、改后),只回答、不改图。服务端没写库,要人在卡片上点保存
*/
export const COPILOT_CATALOG_PATCH = [
{ op: 'model', model: 'claude-sonnet-4' },
{ op: 'catalog_patch', source: 'scenic', source_id: 'src-scenic', table: 'visits', table_label: '入园记录', version: 3,
changes: [
{ path: 'columns.status.codes', before: { 1: '有效', 0: '作废' }, before_status: 'confirmed',
after: { 1: '有效', 0: '作废', 9: '作废' }, value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' },
{ path: 'valid_filter', before: 'status = 1', before_status: 'proposed', after: 'status = 1 AND status <> 9',
value: 'status = 1 AND status <> 9', reason: '统计时要排除作废记录', state: 'change' },
{ path: 'columns.visitor_count.measure', before: 'flow', before_status: 'proposed', after: 'flow', value: 'flow',
reason: '', state: 'confirm' },
] },
{ op: 'reply', text: '好的,已整理成数据目录的修改建议,确认后保存即可。' },
]
/** 挑表失败(超时):只给了表名,原因随 context 带来;生成照常进行 */
export const COPILOT_CONTEXT_FALLBACK = [
{ op: 'model', model: 'claude-sonnet-4' },
+143
View File
@@ -0,0 +1,143 @@
import type { CatalogBusinessDate, CatalogCardinality, CatalogMeasure, CatalogPatchChange, CatalogTableKind } from '../types'
import {
CATALOG_CARDINALITY_LABEL, CATALOG_COLUMN_FIELD_LABEL, CATALOG_KIND_LABEL, CATALOG_MEASURE_LABEL, CATALOG_PATCH_TEXT,
CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT,
} from '../lib/terms'
// ===========================================================================
// 目录修改提案(助手流里的 catalog_patch 操作):解析、每一项的中文说法、值怎么写成一行字。
//
// 服务端(api/copilot.py 的 catalog_patch_op)已经核对过数据源、表、路径和取值格式,附上了版本、改前和改后,
// 这里只认形状:认不出的项跳过,一项都不剩就整条不认(不出卡片,也不报错)。路径的写法和审阅接口一致:表级项
// 写字段名,列级项 columns.<列名>.<字段>(列名里可以有句点,按最后一个句点切),关系 relations.<编号>。
// ===========================================================================
export interface CatalogPatch {
/** 在这一轮操作里的位置:卡片的键、保存状态都按「轮次 + 它」记 */
key: string
source: string
sourceId: string
table: string
/** 表的中文名(目录里有、没被驳回时) */
tableLabel?: string
/** 提案对照的目录版本:保存时带上,别人在这之后改过就 409 */
version: number
changes: CatalogPatchChange[]
}
const TABLE_FIELDS = Object.keys(CATALOG_TABLE_FIELD_LABEL) as (keyof typeof CATALOG_TABLE_FIELD_LABEL)[]
const COLUMN_FIELDS = Object.keys(CATALOG_COLUMN_FIELD_LABEL) as (keyof typeof CATALOG_COLUMN_FIELD_LABEL)[]
const STATES = new Set(['change', 'confirm', 'same'])
export type PatchTarget =
| { kind: 'table'; field: keyof typeof CATALOG_TABLE_FIELD_LABEL }
| { kind: 'column'; column: string; field: keyof typeof CATALOG_COLUMN_FIELD_LABEL }
| { kind: 'relation'; id: string }
/** 路径 → 指的是哪一项。认不出返回 null */
export function patchTarget(path: string): PatchTarget | null {
if ((TABLE_FIELDS as string[]).includes(path)) return { kind: 'table', field: path as keyof typeof CATALOG_TABLE_FIELD_LABEL }
if (path.startsWith('columns.')) {
const rest = path.slice('columns.'.length)
const at = rest.lastIndexOf('.')
const field = rest.slice(at + 1)
if (at > 0 && (COLUMN_FIELDS as string[]).includes(field)) {
return { kind: 'column', column: rest.slice(0, at), field: field as keyof typeof CATALOG_COLUMN_FIELD_LABEL }
}
}
if (path.startsWith('relations.') && path.length > 'relations.'.length) return { kind: 'relation', id: path.slice('relations.'.length) }
return null
}
function changeOf(x: unknown): CatalogPatchChange | null {
if (!x || typeof x !== 'object') return null
const c = x as Record<string, unknown>
if (typeof c.path !== 'string' || !patchTarget(c.path) || !('after' in c)) return null
return {
path: c.path,
before: c.before ?? null,
before_status: typeof c.before_status === 'string' ? c.before_status as CatalogPatchChange['before_status'] : null,
after: c.after,
value: 'value' in c ? c.value : c.after,
reason: typeof c.reason === 'string' ? c.reason : '',
state: typeof c.state === 'string' && STATES.has(c.state) ? c.state as CatalogPatchChange['state'] : 'change',
}
}
/** catalog_patch 操作 → 提案。认不出的形状返回 null */
export function catalogPatchOf(op: Record<string, any>, key = ''): CatalogPatch | null {
if (typeof op.source !== 'string' || typeof op.source_id !== 'string' || typeof op.table !== 'string') return null
const changes = (Array.isArray(op.changes) ? op.changes : []).map(changeOf).filter((c): c is CatalogPatchChange => !!c)
if (!changes.length) return null
const version = typeof op.version === 'number' && Number.isFinite(op.version) ? op.version : 0
return {
key, source: op.source, sourceId: op.source_id, table: op.table, version, changes,
...(typeof op.table_label === 'string' && op.table_label ? { tableLabel: op.table_label } : {}),
}
}
/** 一轮操作里的全部提案,按出现的先后 */
export function catalogPatchesOf(ops: Record<string, any>[]): CatalogPatch[] {
const out: CatalogPatch[] = []
ops.forEach((op, i) => {
if (op?.op !== 'catalog_patch') return
const p = catalogPatchOf(op, String(i))
if (p) out.push(p)
})
return out
}
/** 这一项的中文说法:「列 status 的码值」「表的有效记录条件」「指向 visits 的关联关系」 */
export function patchWhere(change: Pick<CatalogPatchChange, 'path' | 'after' | 'before'>): string {
const t = patchTarget(change.path)
if (!t) return change.path
if (t.kind === 'table') return CATALOG_TEXT.whereTable(CATALOG_TABLE_FIELD_LABEL[t.field])
if (t.kind === 'column') return CATALOG_TEXT.whereColumn(t.column, CATALOG_COLUMN_FIELD_LABEL[t.field])
const rel = (change.after ?? change.before) as { to_table?: string } | null
const to = rel?.to_table ?? ''
return change.before == null ? CATALOG_PATCH_TEXT.whereRelationNew(to) : CATALOG_TEXT.whereRelation(to)
}
/** 关联关系写成一行:visit_id → visits.id(多对一) */
export function relationText(v: unknown): string {
const r = v as { columns?: string[]; to_table?: string; to_columns?: string[]; cardinality?: CatalogCardinality | null }
if (!r || typeof r !== 'object') return String(v)
const left = (r.columns ?? []).join('、')
const right = (r.to_columns ?? []).map((c) => `${r.to_table}.${c}`).join('、')
const card = r.cardinality ? CATALOG_CARDINALITY_LABEL[r.cardinality] : ''
return `${left} → ${right}${card ? `(${card})` : ''}`
}
/** 码值写成一行:1=有效、9=作废 */
export function codesText(v: unknown): string {
if (!v || typeof v !== 'object') return String(v)
return Object.entries(v as Record<string, string>).map(([k, x]) => `${k}=${x}`).join('、')
}
/** 一项的值写成一行字(过程里的明细、卡片里的改前改后都用它)。没有值写「未填写」 */
export function patchValueText(path: string, v: unknown): string {
if (v == null || v === '') return CATALOG_PATCH_TEXT.empty
const t = patchTarget(path)
if (t?.kind === 'relation') return relationText(v)
const field = t?.field
if (field === 'kind') return CATALOG_KIND_LABEL[v as CatalogTableKind] ?? String(v)
if (field === 'measure') return CATALOG_MEASURE_LABEL[v as CatalogMeasure] ?? String(v)
if (field === 'keys' && Array.isArray(v)) return v.join('、')
if (field === 'codes') return codesText(v)
if (field === 'business_date' && typeof v === 'object') {
const d = v as CatalogBusinessDate
return [d.column, d.rule ? `${CATALOG_TEXT.dateRule}:${d.rule}` : '', d.timezone ? `${CATALOG_TEXT.dateTimezone}:${d.timezone}` : '']
.filter(Boolean).join(';')
}
return typeof v === 'string' ? v : JSON.stringify(v)
}
/** 交回保存、预览的那一份:路径、原样取值、理由 */
export function patchSubmit(changes: CatalogPatchChange[]) {
return changes.map((c) => ({ path: c.path, value: c.value, ...(c.reason ? { reason: c.reason } : {}) }))
}
/** 数据目录页上这张表的地址 */
export function catalogTableHref(sourceId: string, table: string): string {
return `/data/catalog/${encodeURIComponent(sourceId)}/${encodeURIComponent(table)}`
}
+15 -1
View File
@@ -1,7 +1,10 @@
import type { RunEvent, TeamMember, TeamRound, TeamRun, ToolChange } from '../types'
import type { NodeState, RunPhase } from './trace'
import { formatDuration, formatNumber } from '../lib/format'
import { JUDGE_TEXT, MERGE_TEXT, TYPE_LABEL, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms'
import {
CATALOG_PATCH_TEXT, JUDGE_TEXT, MERGE_TEXT, TYPE_LABEL, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel,
} from '../lib/terms'
import { catalogPatchOf, patchValueText, patchWhere } from './catalogPatch'
import { claimCountsOf, isJudged, statsTally } from '../lib/evidence'
// 泳道数据画布也要用(supervisor 节点要展开成协作矩阵),所以类型放在
@@ -2678,6 +2681,17 @@ export function decodeCopilot(ops: CopilotOp[], opts?: {
...(ctx.ms != null ? { ms: ctx.ms, meta: formatDuration(ctx.ms) } : {}) })
break
}
case 'catalog_patch': {
// 目录修改提案:过程里记一行(哪张表、几项,展开看改前改后),保存、忽略在轮次里的卡片上做(catalogPatchesOf)
const patch = catalogPatchOf(op, String(i - 1))
if (!patch) break
add({ id: `cpt-${i}`, seq: i, kind: 'schema', status: 'done', code: 'catalog_patch',
title: CATALOG_PATCH_TEXT.step(patch.tableLabel ?? patch.table, patch.changes.length),
sub: CATALOG_PATCH_TEXT.stepSub,
detail: patch.changes.map((c) => `${patchWhere(c)}:${patchValueText(c.path, c.before)} → ${patchValueText(c.path, c.after)}`)
.join('\n') })
break
}
case 'plan':
add({ id: `cp-${i}`, seq: i, kind: 'note', status: 'done',
title: clip(String(op.summary ?? '已完成规划'), 120) })
+9 -1
View File
@@ -10,9 +10,10 @@ import { NODE_DEFS, sourceHandles } from '../canvas/nodeDefs'
import { toast } from '../components/ui'
import { humanizeError } from '../lib/errors'
import { formatShortcut } from '../lib/keys'
import { UPGRADE_TEXT } from '../lib/terms'
import { CATALOG_PATCH_TEXT, UPGRADE_TEXT } from '../lib/terms'
import type { CopilotOp } from '../run/decode'
import { copilotContext, endingOf, reduceTeam, settleTeam } from '../run/decode'
import { catalogPatchOf } from '../run/catalogPatch'
import { activeEdgesOf, applyDerived } from '../run/derive'
import {
emptyTrace, finalizeTrace, foldEvent, isActivePhase, isSettled, isTerminal, runStatusOf,
@@ -1894,6 +1895,13 @@ export const useStudio = create<StudioState>((set, get) => ({
if (ctx) set({ copilot: { ...s.copilot, lastOp: ctx.title } })
break
}
case 'catalog_patch': {
// 助手把用户说的数据事实整理成了目录修改提案:浮条上先说一句,卡片在这一轮的对话里(catalogPatchesOf),
// 人点「保存到数据目录」才写入。和画布无关,不动节点、不占撤销栈
const patch = catalogPatchOf(op)
if (patch) set({ copilot: { ...s.copilot, lastOp: CATALOG_PATCH_TEXT.step(patch.tableLabel ?? patch.table, patch.changes.length) } })
break
}
case 'plan':
set({ copilot: { ...s.copilot, phase: 'planning', lastOp: op.summary ?? '规划中' } })
break
+34
View File
@@ -2705,3 +2705,37 @@ export interface CatalogDraftOut {
/** 模型整体用不了(没有配置、已停用……):只按注释、外键和命名起草 */
model_error: string | null
}
/**
* 目录修改提案的一项(服务端 catalog.PatchChange)。助手转出的提案和预览接口都是这个形状。
* path 是审阅接口的写法;新增关联关系已规范成 relations.<编号>
*/
export interface CatalogPatchChange {
path: string
/** 当前目录里的值;没有(或被驳回)为 null */
before: unknown
before_status: CatalogStatus | null
/** 保存后的值(码值是补充后的完整对照) */
after: unknown
/** 交回保存的原样取值(码值只有补充的那几个) */
value: unknown
reason: string
/** change 值有变化;confirm 值相同、还不是已确认(保存即确认);same 已经是这个值且已确认 */
state: 'change' | 'confirm' | 'same'
}
/** 保存、预览提案时交回的一项:路径、原样取值、理由 */
export interface CatalogPatchSubmit {
path: string
value: unknown
reason?: string
}
/** POST /datasources/{id}/catalog/{table}/patch/preview:对着当前目录重算改前、改后(只读) */
export interface CatalogPatchPreview {
table_name: string
version: number
changes: CatalogPatchChange[]
/** 不合法的项的说明(目录刚被改过、列已删除……) */
problems: string[]
}
+64 -1
View File
@@ -371,12 +371,14 @@ const TABLES = {
const log = {
patches: [], cancels: [], continues: [], runStarts: [], archived: [], posts: [], approvalGets: [], runGets: [],
deleted: [], gens: [], sourceGets: 0,
deleted: [], gens: [], sourceGets: 0, catalogPatches: [],
}
const ctl = {
slowMs: 0, failE: true, wsClosed: {}, approvals: [], thinkGo: false, writingGo: false, writingAt: 0,
/** 列表只给前 8 个:筛选框刚好出现,删一个就不够 8 个了 */
few: false, run500: true, reviewDelay: {}, launchFail: true, scope400: false,
/** 目录修改提案第一次保存回 409(别人刚改过这张表) */
patchConflict: false,
/** 回收站里多摆两个(TRASH_MORE) */
trashMore: false,
/** 列表里多摆「护栏」那段的三个会话(GUARD_CONVS) */
@@ -470,6 +472,18 @@ async function fakeApi(route) {
return route.fulfill({ status: 200, contentType: 'text/event-stream',
body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') })
}
if (String(b.instruction ?? '').includes('表示作废')) {
// 用户说了一条数据事实:服务端核对后转出目录修改提案,只回答、不建图
const ops = [
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 300 },
{ op: 'catalog_patch', source: 'shop', source_id: 'src-shop', table: 'orders', table_label: '订单', version: 2, changes: [
{ path: 'columns.status.codes', before: { 1: '已支付' }, before_status: 'confirmed', after: { 1: '已支付', 9: '作废' },
value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' }] },
{ op: 'reply', text: '已整理成数据目录的修改建议,确认后保存即可。' },
]
return route.fulfill({ status: 200, contentType: 'text/event-stream',
body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') })
}
const ops = [
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 800 },
// 服务端按需求挑了表(copilot_context):过程里多一行「参考了 N 张表」
@@ -483,6 +497,24 @@ async function fakeApi(route) {
return route.fulfill({ status: 200, contentType: 'text/event-stream',
body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') })
}
if (path === '/datasources/src-shop/catalog/orders/patch' && method === 'POST') {
log.catalogPatches.push(body())
if (ctl.patchConflict) {
ctl.patchConflict = false
return json({ detail: '表「orders」的数据目录刚被修改过,请重新载入后再提交' }, 409)
}
return json({ table_name: 'orders', in_schema: true, notes: {}, version: 4, updated_at: null, updated_by: null,
structure: null, system_notes: false, usage: 0 })
}
if (path === '/datasources/src-shop/catalog/orders/patch/preview' && method === 'POST') {
log.catalogPatches.push({ preview: body() })
return json({ table_name: 'orders', version: 3, problems: [], changes: [
{ path: 'columns.status.codes', before: { 1: '已支付', 2: '已退款' }, before_status: 'confirmed',
after: { 1: '已支付', 2: '已退款', 9: '作废' }, value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' }] })
}
if (path === '/datasources/src-shop/catalog/orders/impact' && method === 'GET') {
return json({ table: 'orders', templates: [] })
}
if (path === '/copilot/review' && method === 'POST') {
const wait = ctl.reviewDelay[body().run_id]
if (wait) await new Promise((r) => setTimeout(r, wait))
@@ -1387,6 +1419,37 @@ for (const theme of THEMES) {
await ctx.close()
})
await section('catalogpatch', '说了一条数据事实:「建议更新数据目录」卡片,保存撞上 409 能重新载入', async () => {
const { page, ctx, errors } = await open(theme)
await goto(page, 'c0emptg')
ctl.patchConflict = true
const before = log.catalogPatches.length
await send(page, 'status=9 表示作废,统计时要排除')
const card = page.locator('[data-catalog-patch="orders"]')
check('回答下面有「建议更新数据目录」卡片', await card.waitFor({ timeout: 6000 }).then(() => true, () => false)
&& (await card.innerText()).includes('建议更新数据目录'))
check('逐项写改前、改后、理由', (await card.locator('[data-patch-before]').innerText()).includes('1=已支付')
&& (await card.locator('[data-patch-after]').innerText()).includes('9=作废')
&& (await card.locator('[data-patch-reason]').innerText()).includes('status=9'))
const t = (await chatState(page)).byConversation.c0emptg.at(-1)
check('提案原样记进这一轮的操作流,回答照常交付', t.ops.some((o) => o.op === 'catalog_patch') && t.phase === 'done' && t.noQuery === true)
await card.locator('[data-patch-save]').click()
await card.locator('[data-patch-conflict]').waitFor({ timeout: 3000 })
check('409:提示这张表刚被修改过', (await card.innerText()).includes('这张表刚被修改过'))
await card.locator('[data-patch-reload]').click()
await card.locator('[data-patch-save]').waitFor({ timeout: 3000 })
check('重新载入后改前按最新的目录写', (await card.locator('[data-patch-before]').innerText()).includes('2=已退款'))
await card.locator('[data-patch-save]').click()
await card.locator('[data-patch-done]').waitFor({ timeout: 3000 })
const sent = log.catalogPatches.slice(before)
check('先带提案的版本,重新载入后带新版本', sent[0]?.if_version === 2 && !!sent[1]?.preview && sent[2]?.if_version === 3,
JSON.stringify(sent))
check('保存成功写明第几版、给到数据目录的入口', (await card.locator('[data-patch-done]').innerText()).includes('第 4 版')
&& (await card.locator('[data-patch-open]').getAttribute('href')) === '/data/catalog/src-shop/orders')
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
})
await section('approval', '停在审批上:审批卡立刻出现,不等 4 秒轮询', async () => {
const { page, ctx, errors } = await open(theme)
ctl.approvals = []
+29
View File
@@ -691,6 +691,35 @@ await section('Copilot:这一轮参考了哪些表(context 操作)', async
check('认不出的操作照旧忽略', mod.decodeCopilot([{ op: 'something_new', x: 1 }]).length === 0)
})
await section('Copilot:建议更新数据目录(catalog_patch 操作)', async () => {
// 用户在助手里说了一条数据事实,服务端核对后转出目录修改提案。过程里记一行,保存、忽略在卡片上做
const cp = await import(await load('/src/run/catalogPatch.ts'))
const steps = mod.decodeCopilot(synthetic.COPILOT_CATALOG_PATCH, { context: 'canvas' })
const row = steps.find((s) => s.code === 'catalog_patch')
check('一行说清建议改哪张表、几项', row?.title === '建议更新「入园记录」的数据目录(3 项)', row?.title)
check('副标题说明确认后才写入', row?.sub === '确认后才写入数据目录', row?.sub)
check('展开逐项写改前 → 改后,用界面上的叫法', !!row?.detail?.includes('列 status 的码值:0=作废、1=有效 → 0=作废、1=有效、9=作废')
&& row.detail.includes('表的有效记录条件:status = 1 → status = 1 AND status <> 9')
&& row.detail.includes('列 visitor_count 的度量类型:可累加 → 可累加'), row?.detail)
check('是看表结构那一类、标着规划阶段', row?.kind === 'schema' && row?.stage === 'plan' && row?.status === 'done')
check('结局仍是「只回答」', mod.copilotOutcome(synthetic.COPILOT_CATALOG_PATCH).kind === 'reply')
const [patch] = cp.catalogPatchesOf(synthetic.COPILOT_CATALOG_PATCH)
check('卡片拿到版本、数据源 id、表和中文名', patch?.version === 3 && patch.sourceId === 'src-scenic' && patch.table === 'visits'
&& patch.tableLabel === '入园记录' && patch.key === '1', JSON.stringify(patch && { ...patch, changes: patch.changes.length }))
check('三项都认、状态照抄', patch?.changes.map((c) => c.state).join(',') === 'change,change,confirm')
check('交回保存的是原样取值', JSON.stringify(cp.patchSubmit(patch.changes)[0]) === '{"path":"columns.status.codes","value":{"9":"作废"},"reason":"用户说明 status=9 表示作废"}')
check('新增关联关系的说法', cp.patchWhere({ path: 'relations.r1', before: null, after: { to_table: 'visits' } }) === '新增指向 visits 的关联关系')
check('关联关系写成一行', cp.patchValueText('relations.r1', { columns: ['visit_id'], to_table: 'visits', to_columns: ['id'],
cardinality: 'many_to_one' }) === 'visit_id → visits.id(多对一)')
check('表类型、度量类型写界面上的叫法', cp.patchValueText('kind', 'fact') === '明细表' && cp.patchValueText('columns.a.measure', 'stock') === '存量')
check('没有值写「未填写」', cp.patchValueText('grain', null) === '未填写')
check('列名里有句点照样认', JSON.stringify(cp.patchTarget('columns.a.b.unit')) === '{"kind":"column","column":"a.b","field":"unit"}')
check('形状不对不出卡片、不出行', cp.catalogPatchesOf([
{ op: 'catalog_patch' }, { op: 'catalog_patch', source: 's', source_id: 'i', table: 't', changes: [{ path: 'nope', after: 1 }] },
]).length === 0 && mod.decodeCopilot([{ op: 'catalog_patch', source: 's', table: 't' }]).length === 0)
})
await section('Copilot:心跳穿插、回话、少了一步、报错', async () => {
const steps = mod.decodeCopilot(synthetic.COPILOT_STUCK, { context: 'canvas' })
check('心跳穿插的思考仍然并成一条', steps.filter((s) => s.kind === 'think').length === 1,
+79
View File
@@ -768,6 +768,85 @@ await section('参考了哪些表:画布右栏和问数据页(context 操作
await q.page.close()
})
await section('建议更新数据目录:卡片的保存、忽略和 409(catalog_patch 操作)', async () => {
// 保存、预览都在浏览器层答掉:预览页没有真的数据源,检查也不许写库
const detail = (version) => ({ table_name: 'visits', in_schema: true, notes: {}, version, updated_at: null, updated_by: '检查脚本',
structure: null, system_notes: false, usage: 0 })
const c = await open('syn=catalog-patch&dense=1', { w: 380, h: 900, name: 'catalog-patch' })
const sent = []
let conflictOnce = true
await c.page.route('**/api/datasources/src-scenic/catalog/visits/patch**', async (route) => {
const req = route.request()
const body = req.postDataJSON()
sent.push({ url: req.url(), body, actor: req.headers()['x-actor'] })
if (req.url().endsWith('/patch/preview')) {
// 重新载入:别人刚把码值补了一个 8,改前改后按最新的算
return route.fulfill({ json: { table_name: 'visits', version: 4, problems: [], changes: [
{ path: 'columns.status.codes', before: { 1: '有效', 0: '作废', 8: '退票' }, before_status: 'confirmed',
after: { 1: '有效', 0: '作废', 8: '退票', 9: '作废' }, value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' },
{ path: 'valid_filter', before: 'status = 1', before_status: 'proposed', after: 'status = 1 AND status <> 9',
value: 'status = 1 AND status <> 9', reason: '统计时要排除作废记录', state: 'change' },
] } })
}
if (conflictOnce) {
conflictOnce = false
return route.fulfill({ status: 409, json: { detail: '表「visits」的数据目录刚被修改过,请重新载入后再提交' } })
}
return route.fulfill({ json: detail(5) })
})
await c.page.route('**/api/datasources/src-scenic/catalog/visits/impact', (route) =>
route.fulfill({ json: { table: 'visits', templates: [] } }))
const card = c.page.locator('[data-catalog-patch="visits"]')
check('卡片在:标题写「建议更新数据目录」', await card.count() === 1 && (await card.innerText()).includes('建议更新数据目录'))
const codes = card.locator('[data-patch-change="columns.status.codes"]')
check('逐项写改到哪儿:「列 status 的码值」', (await codes.innerText()).includes('列 status 的码值'))
check('改前、改后、理由都在', (await codes.locator('[data-patch-before]').innerText()).includes('0=作废、1=有效')
&& (await codes.locator('[data-patch-after]').innerText()).includes('9=作废')
&& (await codes.locator('[data-patch-reason]').innerText()).includes('status=9'))
check('新补的码值加重显示', await codes.locator('[data-code-changed="9"]').count() === 1
&& await codes.locator('[data-code-changed="1"]').count() === 0)
check('值不变的项写明保存即确认', (await card.locator('[data-patch-state="confirm"]').innerText()).includes('值不变,保存即确认'))
check('改前写状态(推断)', (await card.locator('[data-patch-change="valid_filter"] [data-patch-before]').innerText()).includes('(推断)'))
check('过程里也记了一行', (await c.page.locator('[data-step-code="catalog_patch"]').innerText()).includes('建议更新「入园记录」的数据目录(3 项)'))
const overflow = await c.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
check('卡片不撑破 380px 窄栏', overflow <= 0, `${overflow}px`)
// 第一次保存撞上 409:写明「这张表刚被修改过」,给「重新载入」。署名在设置页填,这里直接写进本机
await c.page.evaluate(() => localStorage.setItem('agentlab_actor', '检查脚本'))
await card.locator('[data-patch-save]').click()
await card.locator('[data-patch-conflict]').waitFor({ timeout: 3000 })
check('409:提示这张表刚被修改过', (await card.innerText()).includes('这张表刚被修改过'))
check('保存带着提案对照的版本、原样取值和署名', sent[0]?.body?.if_version === 3
&& JSON.stringify(sent[0]?.body?.changes?.[0]?.value) === '{"9":"作废"}' && !!sent[0]?.actor, JSON.stringify(sent[0]))
await card.locator('[data-patch-reload]').click()
await c.page.waitForFunction(() => document.querySelector('[data-catalog-patch="visits"]')?.getAttribute('data-patch-version') === '4',
null, { timeout: 3000 })
check('重新载入交回的是助手原来的提案', sent[1]?.url.endsWith('/patch/preview')
&& JSON.stringify(sent[1]?.body?.changes?.[0]?.value) === '{"9":"作废"}', JSON.stringify(sent[1]))
check('重新载入后改前按最新的目录写', (await codes.locator('[data-patch-before]').innerText()).includes('8=退票'))
await card.locator('[data-patch-save]').click()
await card.locator('[data-patch-done]').waitFor({ timeout: 3000 })
check('再保存带新版本', sent[2]?.body?.if_version === 4, JSON.stringify(sent[2]?.body))
check('保存成功:写明第几版,给到数据目录的入口', (await card.locator('[data-patch-done]').innerText()).includes('已保存到数据目录(第 5 版)')
&& (await card.locator('[data-patch-open]').getAttribute('href')) === '/data/catalog/src-scenic/visits')
check('保存后不再有保存按钮', await card.locator('[data-patch-save]').count() === 0)
check('没有运行时报错', c.errors.length === 0, c.errors.join(' | '))
await c.page.close()
// 忽略:收成一行,可以重新查看;不发任何请求
const g = await open('syn=catalog-patch&dense=1', { w: 380, h: 900 })
let writes = 0
await g.page.route('**/api/datasources/**', (route) => { writes++; return route.abort() })
const gc = g.page.locator('[data-catalog-patch="visits"]')
await gc.locator('[data-patch-ignore]').click()
check('忽略:收成一行说明', (await gc.getAttribute('data-patch-phase')) === 'ignored'
&& (await gc.innerText()).includes('已忽略这条建议'))
await gc.locator('[data-patch-unignore]').click()
check('重新查看:卡片回来', await gc.locator('[data-patch-save]').count() === 1)
check('忽略不发请求', writes === 0, `${writes}`)
await g.page.close()
})
await section('步骤行和画布联动', async () => {
const { page } = await open('syn=mixed&link=1', { w: 1100, h: 800 })
const row = page.locator('[data-node-id="agent"]').first()
+31
View File
@@ -1112,6 +1112,37 @@ await section('助手改图:一步撤销、回执、只回话、失败退回
check('reply:这一轮记下回答', t2.reply?.includes('input.goal') && t2.outcome === 'answered', t2.outcome)
check('reply:画布没动、不占撤销栈', (await st(page)).nodes.length === b2.nodes.length && (await st(page)).past === b2.past)
// 2b) 用户说了一条数据事实:助手只回答,附一条目录修改提案。卡片在助手栏这一轮里,点保存才写(这里在浏览器层答掉)
state.stream = [
{ op: 'model', model: 'fake-model' },
{ op: 'catalog_patch', source: 'shop', source_id: 'src-shop', table: 'orders', table_label: '订单', version: 2, changes: [
{ path: 'columns.status.codes', before: { 1: '已支付' }, before_status: 'confirmed', after: { 1: '已支付', 9: '作废' },
value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' }] },
{ op: 'reply', text: '已整理成数据目录的修改建议。' },
]
const patchSent = []
await page.route(/\/api\/datasources\/src-shop\/catalog\/orders\/patch$/, (route) => {
patchSent.push(route.request().postDataJSON())
return route.fulfill({ json: { table_name: 'orders', in_schema: true, notes: {}, version: 3, updated_at: null, updated_by: null,
structure: null, system_notes: false, usage: 0 } })
})
await page.route(/\/api\/datasources\/src-shop\/catalog\/orders\/impact$/, (route) =>
route.fulfill({ json: { table: 'orders', templates: [] } }))
const b2b = await st(page)
await S(page, () => window.__studio.getState().runCopilot('status=9 表示作废', true))
await page.waitForFunction(() => window.__studio.getState().copilotTurns.at(-1)?.phase !== 'running')
const t2b = await S(page, () => window.__studio.getState().copilotTurns.at(-1))
check('目录修改提案:这一轮照样记下回答', t2b.outcome === 'answered' && t2b.ops.some((o) => o.op === 'catalog_patch'), t2b.outcome)
check('目录修改提案:画布没动', (await st(page)).nodes.length === b2b.nodes.length && (await st(page)).past === b2b.past)
const patchCard = page.locator('[data-catalog-patch="orders"]').last()
check('助手栏里有「建议更新数据目录」卡片', await patchCard.waitFor({ timeout: 3000 }).then(() => true, () => false)
&& (await patchCard.innerText()).includes('列 status 的码值'))
await patchCard.locator('[data-patch-save]').click()
await patchCard.locator('[data-patch-done]').waitFor({ timeout: 3000 })
check('保存带版本和原样取值', patchSent[0]?.if_version === 2 && JSON.stringify(patchSent[0]?.changes?.[0]?.value) === '{"9":"作废"}',
JSON.stringify(patchSent[0]))
check('保存后给到数据目录的入口', (await patchCard.locator('[data-patch-open]').getAttribute('href')) === '/data/catalog/src-shop/orders')
// 3) 从头生成,搭到一半失败:画布退回原样,半成品能 ⇧⌘Z 找回
state.stream = [
{ op: 'model', model: 'fake-model' },