diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 163ce524..237d5778 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/review`, body), + /** 目录修改提案的预览:对着当前目录重算改前、改后,不写库(保存遇到 409 后重新载入用) */ + patchPreview: (sourceId: string, table: string, changes: CatalogPatchSubmit[]) => + post( + `/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/patch/preview`, { changes }), + /** 保存目录修改提案:改动过的项记为人工填写、已确认。版本不符 409,有一项不合法 422 */ + patch: (sourceId: string, table: string, changes: CatalogPatchSubmit[], ifVersion: number) => + post(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/patch`, + { changes, if_version: ifVersion }), /** * 同步起草(注释、外键、命名推断;use_model 时再请助手的模型起草)。不给 tables 时按使用次数取前 20 张。 * 人工确认、驳回过的项不动。没有表结构时回 409 diff --git a/frontend/src/dev/preview.tsx b/frontend/src/dev/preview.tsx index a1354c35..186ece6a 100644 --- a/frontend/src/dev/preview.tsx +++ b/frontend/src/dev/preview.tsx @@ -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' }) }] diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 2d8cb0be..40bee922 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -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}」`, +} diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index ac31f38c..f526244c 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -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, diff --git a/frontend/src/run/AssistantPanel.tsx b/frontend/src/run/AssistantPanel.tsx index a36b1492..5e9d78e4 100644 --- a/frontend/src/run/AssistantPanel.tsx +++ b/frontend/src/run/AssistantPanel.tsx @@ -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, diff --git a/frontend/src/run/AssistantStream.tsx b/frontend/src/run/AssistantStream.tsx index cb6aa5f3..a176de43 100644 --- a/frontend/src/run/AssistantStream.tsx +++ b/frontend/src/run/AssistantStream.tsx @@ -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 | null /** @@ -532,6 +539,10 @@ function TurnCard({ turn, last, approvals, onOpenGraph, onFollowUp }: { )} + {turn.catalogPatches?.map((p) => ( + + ))} + {!!turn.graph?.nodes?.length && ( onOpenGraph(g, turn.question))} /> diff --git a/frontend/src/run/CatalogPatchCard.tsx b/frontend/src/run/CatalogPatchCard.tsx new file mode 100644 index 00000000..867592a7 --- /dev/null +++ b/frontend/src/run/CatalogPatchCard.tsx @@ -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() + +export function CatalogPatchCard({ patch, turnId, dense = false }: { + patch: CatalogPatch + /** 所在轮次:和 patch.key 一起认出「同一张卡片」 */ + turnId: string + dense?: boolean +}) { + const memo = `${turnId}:${patch.key}` + const [state, setStateRaw] = useState(() => remembered.get(memo) + ?? { phase: 'open', changes: patch.changes, version: patch.version, problems: [], error: null }) + const setState = useCallback((next: Partial) => { + 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 ( +
+ + {T.ignored} + +
+ ) + } + + const busy = state.phase === 'saving' || state.phase === 'reloading' + const saved = state.phase === 'saved' + const settled = saved || (!pending.length && state.phase === 'open') + + return ( +
+
+ + + {T.title} + + + 「{label}」{patch.tableLabel && {patch.table}} + · {T.sourceOf(patch.source)} + +
+ +
    + {state.changes.map((c) => )} +
+ +
+ {state.problems.length > 0 && ( + +

{T.problems}

+
    {state.problems.map((p) =>
  • {p}
  • )}
+
+ )} + {state.error && {state.error}} + {state.phase === 'conflict' || state.phase === 'reloading' + ? ( + +

{T.conflictTitle}

+

{T.conflictBody}

+ +
+ ) + : settled + ? ( +
+

+ + {saved && state.savedVersion != null ? T.saved(state.savedVersion) : T.allSame} + + {T.open} + +

+
+ ) + : ( +
+ + + {T.hint} +
+ )} +
+
+ ) +} + +/** 一项:改到哪儿、改前、改后、理由 */ +function ChangeRow({ change: c, dense }: { change: CatalogPatchChange; dense: boolean }) { + const isCodes = patchTarget(c.path)?.kind === 'column' && c.path.endsWith('.codes') + return ( +
  • +
    + {patchWhere(c)} + {c.state === 'confirm' && {T.stateConfirm}} + {c.state === 'same' && {T.stateSame}} +
    +
    +
    {T.before}
    +
    + {patchValueText(c.path, c.before)} + {c.before != null && c.before_status && ({CATALOG_STATUS_LABEL[c.before_status]})} +
    +
    {T.after}
    +
    + {isCodes ? : patchValueText(c.path, c.after)} +
    + {c.reason && ( + <> +
    {T.reason}
    +
    {c.reason}
    + + )} +
    +
  • + ) +} + +/** 码值的改后:新加和改过的码加重,原有的照常写——一眼看出这条提案补了什么 */ +function CodesAfter({ before, after }: { before: unknown; after: unknown }) { + const prev = (before && typeof before === 'object' ? before : {}) as Record + const entries = Object.entries((after && typeof after === 'object' ? after : {}) as Record) + return ( + + {entries.map(([k, v], i) => ( + + {i > 0 && '、'} + + {k}={v} + + + ))} + + ) +} diff --git a/frontend/src/run/__tests__/synthetic.ts b/frontend/src/run/__tests__/synthetic.ts index 4ecc8fe8..ab4cc518 100644 --- a/frontend/src/run/__tests__/synthetic.ts +++ b/frontend/src/run/__tests__/synthetic.ts @@ -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' }, diff --git a/frontend/src/run/catalogPatch.ts b/frontend/src/run/catalogPatch.ts new file mode 100644 index 00000000..26d9a49c --- /dev/null +++ b/frontend/src/run/catalogPatch.ts @@ -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 + 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, 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[]): 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): 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).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)}` +} diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index f9ecb1ff..3ce410e1 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -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) }) diff --git a/frontend/src/store/studio.ts b/frontend/src/store/studio.ts index 3ea731c1..01fdbe3b 100644 --- a/frontend/src/store/studio.ts +++ b/frontend/src/store/studio.ts @@ -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((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 diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f6af5863..d41cc963 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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[] +} diff --git a/scripts/check-chat.mjs b/scripts/check-chat.mjs index 40fd88e5..69b7bd90 100644 --- a/scripts/check-chat.mjs +++ b/scripts/check-chat.mjs @@ -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 = [] diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index cec98cab..52ae8b22 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -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, diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs index 82427557..530dbee9 100644 --- a/scripts/check-stream.mjs +++ b/scripts/check-stream.mjs @@ -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() diff --git a/scripts/check-studio.mjs b/scripts/check-studio.mjs index a66c5282..025427ee 100644 --- a/scripts/check-studio.mjs +++ b/scripts/check-studio.mjs @@ -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' },