From 1cc9fe1a3dfa8e6ba78752c1898244bbfa593783 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 14 Oct 2026 08:44:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E9=97=AD?= =?UTF-8?q?=E7=8E=AF=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A=E8=A1=A8?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=8A=A0=E3=80=8C=E8=A1=A8=E5=8A=A9=E6=89=8B?= =?UTF-8?q?=E3=80=8D=E2=80=94=E2=80=94=E9=A1=B6=E9=83=A8=E5=92=8C=E4=B8=8A?= =?UTF-8?q?=E6=AC=A1=E8=B5=B7=E8=8D=89=E7=9A=84=E6=8F=90=E7=A4=BA=E9=87=8C?= =?UTF-8?q?=E9=83=BD=E6=9C=89=E3=80=8C=E5=92=8C=E5=8A=A9=E6=89=8B=E4=B8=80?= =?UTF-8?q?=E8=B5=B7=E5=AE=8C=E5=96=84=E3=80=8D=E5=85=A5=E5=8F=A3=EF=BC=8C?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=B5=81=E5=BC=8F=E6=98=BE=E7=A4=BA=E3=80=81?= =?UTF-8?q?=E8=83=BD=E5=81=9C=E6=AD=A2=EF=BC=8C=E5=8A=A9=E6=89=8B=E8=B7=91?= =?UTF-8?q?=E8=BF=87=E7=9A=84=E5=8F=AA=E8=AF=BB=E6=9F=A5=E8=AF=A2=E5=86=99?= =?UTF-8?q?=20SQL=20=E5=92=8C=E5=89=8D=E5=87=A0=E8=A1=8C=EF=BC=88=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=94=B6=E8=B5=B7=EF=BC=89=EF=BC=8C=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E6=8F=90=E6=A1=88=E9=80=90=E9=A1=B9=E5=86=99=E6=94=B9=E5=89=8D?= =?UTF-8?q?=E3=80=81=E6=94=B9=E5=90=8E=E3=80=81=E4=BE=9D=E6=8D=AE=E5=B9=B6?= =?UTF-8?q?=E8=83=BD=E9=80=90=E9=A1=B9=E5=8E=BB=E6=8E=89=E3=80=81=E5=8A=A0?= =?UTF-8?q?=E5=9B=9E=EF=BC=8C=E3=80=8C=E5=BE=85=E4=BF=9D=E5=AD=98=E7=9A=84?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E3=80=8D=E8=B7=A8=E5=A4=9A=E8=BD=AE=E7=B4=AF?= =?UTF-8?q?=E7=A7=AF=EF=BC=88=E5=90=8C=E4=B8=80=E9=A1=B9=E4=BB=A5=E6=9C=80?= =?UTF-8?q?=E5=90=8E=E4=B8=80=E6=AC=A1=E6=8F=90=E6=A1=88=E4=B8=BA=E5=87=86?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E4=B8=80=E9=94=AE=E4=BF=9D=E5=AD=98=EF=BC=88?= =?UTF-8?q?=E6=9C=80=E5=A4=9A=20200=20=E9=A1=B9=EF=BC=8C409=20=E6=97=B6?= =?UTF-8?q?=E6=8C=89=E6=9C=80=E6=96=B0=E5=86=85=E5=AE=B9=E9=87=8D=E7=AE=97?= =?UTF-8?q?=E5=90=8E=E5=81=9C=E4=B8=8B=E6=9D=A5=E5=86=8D=E7=9C=8B=E4=B8=80?= =?UTF-8?q?=E7=9C=BC=EF=BC=89=EF=BC=8C=E6=B2=A1=E5=BC=80=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=89=96=E6=9E=90=E7=9A=84=E6=BA=90=E5=86=99=E6=98=8E=E5=8F=AA?= =?UTF-8?q?=E8=83=BD=E6=A0=B9=E6=8D=AE=E8=A1=A8=E7=BB=93=E6=9E=84=E5=92=8C?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E6=8E=A8=E6=96=AD=E3=80=81=E4=B8=8D=E8=83=BD?= =?UTF-8?q?=E6=9F=A5=E6=95=B0=E6=8D=AE=EF=BC=8C=E6=9C=89=E7=95=99=E7=A9=BA?= =?UTF-8?q?=E5=88=97=E6=97=B6=E5=BC=80=E5=9C=BA=E5=BC=95=E5=AF=BC=E5=85=88?= =?UTF-8?q?=E9=97=AE=E8=BF=99=E5=87=A0=E5=88=97=EF=BC=8C=E6=9C=89=E5=BE=85?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E7=9A=84=E4=BF=AE=E6=94=B9=E6=97=B6=E6=8D=A2?= =?UTF-8?q?=E8=A1=A8=E3=80=81=E5=85=B3=E9=97=AD=E5=85=88=E9=97=AE=EF=BC=9B?= =?UTF-8?q?=E8=A1=A8=E8=AF=A6=E6=83=85=E5=8A=A0=E3=80=8C=E7=94=A8=E8=BF=87?= =?UTF-8?q?=E8=BF=99=E5=BC=A0=E8=A1=A8=E7=9A=84=E8=BF=90=E8=A1=8C=E3=80=8D?= =?UTF-8?q?=E3=80=81=E6=9C=89=E6=95=88=E8=AE=B0=E5=BD=95=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E5=92=8C=E5=8E=BB=E9=87=8D=E8=A7=84=E5=88=99=E7=9A=84=E6=8E=A8?= =?UTF-8?q?=E6=96=AD=E9=A1=B9=E5=9C=A8=E5=80=BC=E4=B8=8B=E9=9D=A2=E5=86=99?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=EF=BC=88=E5=89=96=E6=9E=90=E6=A0=B8=E5=AF=B9?= =?UTF-8?q?=E7=9A=84=E6=8E=92=E9=99=A4=E6=AF=94=E4=BE=8B=E6=88=96=E6=B2=A1?= =?UTF-8?q?=E6=A0=B8=E5=AF=B9=E7=9A=84=E5=8E=9F=E5=9B=A0=EF=BC=89=E3=80=81?= =?UTF-8?q?=E4=BB=8E=E5=AE=A1=E9=98=85=E9=98=9F=E5=88=97=E6=89=93=E5=BC=80?= =?UTF-8?q?=E7=9A=84=E9=82=A3=E4=B8=80=E9=A1=B9=E6=A0=87=E5=87=BA=E6=9D=A5?= =?UTF-8?q?=EF=BC=9B=E8=B5=B7=E8=8D=89=E4=BB=BB=E5=8A=A1=E9=80=90=E8=A1=A8?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=9D=97=E8=BF=9B=E5=BA=A6=EF=BC=88=E6=AE=B5?= =?UTF-8?q?=E8=A2=AB=E6=8B=86=E5=B0=8F=E5=90=8E=E7=9C=8B=E5=BE=97=E5=87=BA?= =?UTF-8?q?=E6=9D=A5=EF=BC=89=E5=92=8C=E5=B7=B2=E5=86=99=E8=BF=9B=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E7=9A=84=E6=AE=B5=EF=BC=8C=E5=81=9C=E6=AD=A2=E5=90=8E?= =?UTF-8?q?=E5=86=99=E6=98=8E=E5=B7=B2=E5=86=99=E8=BF=9B=E5=87=A0=E6=AE=B5?= =?UTF-8?q?=E3=80=81=E5=8F=AA=E9=87=8D=E8=AF=95=E6=97=B6=E6=8E=A5=E7=9D=80?= =?UTF-8?q?=E8=B5=B7=E8=8D=89=EF=BC=8C=E6=B8=85=E5=8D=95=E5=92=8C=E8=A1=A8?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=9C=A8=E6=9C=80=E8=BF=91=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E3=80=81=E4=B8=AD=E6=96=AD=E5=8F=88=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E6=96=B0=E5=86=99=E5=85=A5=E6=97=B6=E6=8E=A5=E7=9D=80?= =?UTF-8?q?=E7=94=A8=E5=86=8D=E4=B9=8B=E5=89=8D=E9=82=A3=E6=AC=A1=E8=B5=B7?= =?UTF-8?q?=E8=8D=89=E8=AE=B0=E4=B8=8B=E7=9A=84=E7=95=99=E7=A9=BA=E5=88=97?= =?UTF-8?q?=E6=95=B0=E6=8F=90=E7=A4=BA=EF=BC=9Bcheck-catalog=20=E5=8A=A0?= =?UTF-8?q?=E4=B8=80=E8=8A=82=E6=8C=89=E7=BA=A6=E5=AE=9A=E7=9A=84=E6=96=B0?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E6=8E=A8=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/catalog/ColumnTable.tsx | 28 +- frontend/src/pages/catalog/DraftDialog.tsx | 34 +- frontend/src/pages/catalog/TableAssistant.tsx | 591 ++++++++++++++++++ frontend/src/pages/catalog/TableDetail.tsx | 117 +++- frontend/src/pages/catalog/TableIndex.tsx | 12 +- frontend/src/pages/catalog/TableUsage.tsx | 90 +++ frontend/src/pages/catalog/draftJob.ts | 37 +- frontend/src/pages/catalog/parts.tsx | 4 + scripts/check-catalog.mjs | 109 +++- 9 files changed, 983 insertions(+), 39 deletions(-) create mode 100644 frontend/src/pages/catalog/TableAssistant.tsx create mode 100644 frontend/src/pages/catalog/TableUsage.tsx diff --git a/frontend/src/pages/catalog/ColumnTable.tsx b/frontend/src/pages/catalog/ColumnTable.tsx index cec5a1ba..56e64f27 100644 --- a/frontend/src/pages/catalog/ColumnTable.tsx +++ b/frontend/src/pages/catalog/ColumnTable.tsx @@ -9,7 +9,7 @@ import { IconButton } from '../../components/ui' import { CATALOG_COLUMN_FIELD_LABEL, CATALOG_MEASURE_HINT, CATALOG_MEASURE_LABEL, CATALOG_TEXT as CT, CODES_TEXT as KT, } from '../../lib/terms' -import { ItemMark } from './parts' +import { FOCUS_RING, FOCUS_STYLE, ItemMark } from './parts' import type { ReviewTarget } from './parts' import { COLUMN_FIELDS, codesComplete, columnPath, completeOf } from './model' import type { ColumnDraft, ColumnField } from './model' @@ -50,7 +50,16 @@ export interface ColumnModel { type ColumnTexts = ColumnDraft -export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange, onCodesComplete }: { +/** 审阅路径 columns.<列名>.<字段> → [列名, 字段](列名里可以有句点,按最后一个句点切);不是列级项为 null */ +function columnOfPath(path: string | null | undefined): [string, string] | null { + if (!path?.startsWith('columns.')) return null + const rest = path.slice('columns.'.length) + const at = rest.lastIndexOf('.') + return at > 0 ? [rest.slice(0, at), rest.slice(at + 1)] : null +} + +export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange, onCodesComplete, + focusPath = null }: { columns: ColumnModel[] /** 编辑中:列名 → 字段 → 文字(form 是当前的,initial 是进入编辑时的);不在编辑时为 null */ form: Record | null @@ -67,7 +76,10 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote onChange: (col: string, field: ColumnField, value: string) => void /** 编辑中勾选或取消码值的「已列出全部取值」 */ onCodesComplete: (col: string, value: boolean) => void + /** 从审阅队列打开的那一项(列级项的审阅路径):那一格标出来 */ + focusPath?: string | null }) { + const focus = columnOfPath(focusPath) return (
@@ -89,7 +101,7 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote {columns.map((c) => ( + onCodesComplete={onCodesComplete} focusField={focus && focus[0] === c.name ? focus[1] : null} /> ))}
@@ -98,7 +110,7 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote } const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange, - onCodesComplete }: { + onCodesComplete, focusField }: { col: ColumnModel editing: boolean /** 这一列的表单。只有改了这一列时引用才会变,别的行不重画 */ @@ -112,6 +124,8 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble onFillCodes: (col: string) => void onChange: (col: string, field: ColumnField, value: string) => void onCodesComplete: (col: string, value: boolean) => void + /** 从审阅队列打开的是这一列的哪一项 */ + focusField: string | null }) { const { name, structure, items } = col const pending = COLUMN_FIELDS.filter((f) => items?.[f]?.status === 'proposed').length @@ -121,9 +135,12 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble const where = CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL[f]) const problem = problems[`c:${name}:${f}`] const changed = editing && ((draft?.[f] ?? '') !== (initial?.[f] ?? '') || (f === 'codes' && completeOf(draft) !== completeOf(initial))) + const focused = focusField === f return ( // 窄框下没填的项不占行(编辑时照常列出来好填) - + {CT.columnHead[f]}
{editing ? input : view}
@@ -223,6 +240,7 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble ) }, (a, b) => a.col === b.col && a.editing === b.editing && a.draft === b.draft && a.initial === b.initial && a.busy === b.busy + && a.focusField === b.focusField && a.systemNotes === b.systemNotes && a.onReview === b.onReview && a.onConfirmColumn === b.onConfirmColumn && a.onFillCodes === b.onFillCodes && a.onChange === b.onChange && a.onCodesComplete === b.onCodesComplete // 格式问题每次都是新对象:只比这一列的几格 diff --git a/frontend/src/pages/catalog/DraftDialog.tsx b/frontend/src/pages/catalog/DraftDialog.tsx index 3a7547fc..06371021 100644 --- a/frontend/src/pages/catalog/DraftDialog.tsx +++ b/frontend/src/pages/catalog/DraftDialog.tsx @@ -9,8 +9,8 @@ import { errorMessage } from '../../lib/errors' import { formatTime } from '../../lib/format' import { CATALOG_DRAFT_TEXT as DT, CATALOG_TEXT as CT, DRAFT_GUIDANCE_MAX, DRAFT_NOTE_MAX } from '../../lib/terms' import { - DRAFT_MAX_TABLES, DRAFT_MODEL_UNAVAILABLE, DRAFT_PART_COLUMNS, DRAFT_TOP_N, RETRYABLE, blankOf, isJobActive, jobBlank, needsLook, - nothingWritten, retryableCount, taskReason, + DRAFT_MAX_TABLES, DRAFT_MODEL_UNAVAILABLE, DRAFT_PART_COLUMNS, DRAFT_TOP_N, RETRYABLE, blankOf, chunksOf, isJobActive, jobBlank, needsLook, + nothingWritten, retryableCount, taskReason, writtenOf, } from './draftJob' import type { DraftPreset, DraftStartBody } from './draftJob' @@ -435,6 +435,9 @@ function JobView({ job, running, stopping, busy, blankTables, onRedraftBlank }: // 额度用完、超时多了,之后的段缩小了:进行中写一句,段数为什么变多看得明白 const partColumns = running && job.use_model && job.part_columns != null && job.part_columns < DRAFT_PART_COLUMNS ? job.part_columns : null const blankTotal = jobBlank(job) + // 停下来时已经写进了一部分段的表(宽表每写完一段就写进目录):几张、合计几段 + const stopped = job.tasks.filter((t) => t.status === 'cancelled' && writtenOf(t) > 0) + const stoppedParts = { tables: stopped.length, parts: stopped.reduce((n, t) => n + writtenOf(t), 0) } return (
{busy && {DT.busy}} @@ -468,7 +471,12 @@ function JobView({ job, running, stopping, busy, blankTables, onRedraftBlank }: {running &&

{DT.hideHint}

} {job.status === 'failed' && job.error && {job.error}} {job.status === 'interrupted' && {DT.interrupted}} - {job.status === 'cancelled' &&

{DT.cancelled}

} + {job.status === 'cancelled' && ( +

+ {DT.cancelled} + {stoppedParts.tables > 0 && {DT.cancelledParts(stoppedParts.tables, stoppedParts.parts)}} +

+ )} {problemText && (

{problemText}

)} @@ -523,7 +531,15 @@ const TASK_ICON: Record 1 && t.status !== 'pending' && t.status !== 'skipped' + const started = t.status !== 'pending' && t.status !== 'skipped' + const parts = t.parts_total != null && t.parts_total > 1 && started + // 段被缩小、拆半后实际的块(比段多时才写);已经写进目录的段:起草中写着(停止也不丢),停下来时服务端的原因里 + // 没说的才补一句(没写全的段为什么没写出来,服务端的原因里有),写完了的不写 + const chunks = started ? chunksOf(t) : null + const written = writtenOf(t) + const writtenText = written > 0 && t.status === 'running' ? DT.partsWritten(written) + : written > 0 && t.status === 'cancelled' && !reason.includes(DT.partsWritten(written)) + ? DT.partsWrittenStopped(written, Math.max(0, (t.parts_total ?? written) - written)) : '' const changes = DT.taskAdded(t.added, t.updated) const blank = blankOf(t) const sample = t.blank_sample?.filter(Boolean) ?? [] @@ -537,6 +553,11 @@ function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: stri {DT.parts(t.parts_done, t.parts_total!)} )} + {chunks && ( + + {DT.chunks(chunks.done, chunks.total)} + + )} {changes && ( {changes} )} @@ -545,6 +566,11 @@ function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: stri {DT.taskStatus[t.status]}
+ {writtenText && ( +

+ {writtenText} +

+ )} {reason && (

{reason}

)} diff --git a/frontend/src/pages/catalog/TableAssistant.tsx b/frontend/src/pages/catalog/TableAssistant.tsx new file mode 100644 index 00000000..db116014 --- /dev/null +++ b/frontend/src/pages/catalog/TableAssistant.tsx @@ -0,0 +1,591 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import { ArrowUp, BookMarked, Database, Info, MessagesSquare, Minus, Plus, Square, Trash2, X } from 'lucide-react' +import clsx from 'clsx' +import { ApiError, api, streamCatalogAssistant } from '../../api/client' +import type { CatalogAssistantEnd } from '../../api/client' +import type { CatalogDetail } from '../../types' +import { Notice, Spinner, confirmDialog, isComposing, toast } from '../../components/ui' +import { useLeaveGuard } from '../../lib/leave' +import { CATALOG_ASSISTANT_TEXT as T, CATALOG_STATUS_LABEL } from '../../lib/terms' +import { Markdown } from '../../run/Markdown' +import { patchValueText } from '../../run/catalogPatch' +import { + ASSISTANT_MAX_CHARS, ASSISTANT_MAX_SAVE, QUERY_ROWS_SHOWN, applyEvent, applyPreview, assistantChangesOf, blankColumnsOf, changeStateOf, + changeWhere, dropPending, historyOf, mergeProposal, pendingSubmit, pendingVersion, savedKey, +} from './assistant' +import type { AssistantTurn, ChangeState, PendingChange, Turn, TurnPart } from './assistant' + +// =========================================================================== +// 表助手:在表详情里和助手对话完善这一张表的数据目录(POST /catalog/{table}/assistant,事件流)。 +// +// 回答流式显示,能停止;助手跑过的只读查询(只有开了数据剖析的源才有)写 SQL 和前几行,默认收起;修改提案逐项写改前、 +// 改后、依据,可以逐项去掉、加回。提案并进「待保存的修改」:跨多轮累积,同一项以最后一次提案为准,一键保存(最多 200 项)。 +// 保存带待保存的修改里最早那条提案对照的版本:别人在那之后改过就 409,按最新内容重算(预览接口,只读)后停下来让人再看 +// 一眼。对话不存服务端:每一轮把前面的对话和待保存的修改一起交上去;关闭、换表、离开页面就没有了,有待保存的修改时先问。 +// =========================================================================== + +type SaveState = + | { phase: 'idle'; error?: string | null; saved?: { n: number; version: number } | null } + | { phase: 'saving' } + | { phase: 'reloading' } + | { phase: 'conflict'; problems: string[] } + +const charCount = (s: string) => [...s.trim()].length + +export function TableAssistant({ sourceId, table, tableLabel, detail, profileEnabled, lastBlank, editing, onSaved, onClose }: { + sourceId: string + table: string + /** 表的中文名(没有就是表名):标题、提示里用 */ + tableLabel: string + /** 页面上这张表最新的目录:留空的列、保存后更新 */ + detail: CatalogDetail + /** 数据源开了数据剖析:助手才能发只读查询 */ + profileEnabled: boolean + /** 上次起草模型拿不准、留空的列数:开场引导先问这几列 */ + lastBlank: number + /** 正在编辑这张表:先不让保存(同一张表两处同时改,保存会撞上版本冲突) */ + editing: boolean + /** 保存成功:页面换上返回的目录 */ + onSaved: (d: CatalogDetail) => void + /** 关上面板(有待保存的修改时这里先问过了) */ + onClose: () => void +}) { + const [turns, setTurns] = useState([]) + const [pending, setPending] = useState([]) + const [saved, setSaved] = useState>(() => new Set()) + const [text, setText] = useState('') + const [save, setSave] = useState({ phase: 'idle' }) + const [trayOpen, setTrayOpen] = useState(true) + const turnsRef = useRef(turns) + turnsRef.current = turns + const pendingRef = useRef(pending) + pendingRef.current = pending + const stopRef = useRef<(() => void) | null>(null) + const seq = useRef(0) + const inputRef = useRef(null) + const logRef = useRef(null) + const titleId = useId() + const streaming = turns.some((t) => t.role === 'assistant' && t.state === 'streaming') + const dirty = pending.length > 0 || streaming + + // 打开时焦点给输入框;卸下时停掉进行中的回答 + useEffect(() => { inputRef.current?.focus() }, []) + useEffect(() => () => { stopRef.current?.() }, []) + + useLeaveGuard(pending.length > 0, () => confirmDialog({ + title: T.leaveTitle, consequences: T.leaveConsequences, confirmLabel: T.leaveConfirm, danger: true, + })) + + // 新的内容到了:滚到底(人往上翻着看的时候不打扰) + const lastLen = useRef(0) + useEffect(() => { + const el = logRef.current + if (!el) return + const size = turns.reduce((n, t) => n + (t.role === 'user' ? 1 : t.parts.length + 1), 0) + const near = el.scrollHeight - el.scrollTop - el.clientHeight < 160 + if (size !== lastLen.current || near) el.scrollTop = el.scrollHeight + lastLen.current = size + }, [turns]) + + const blankCols = useMemo(() => (lastBlank > 0 ? blankColumnsOf(detail) : []), [lastBlank, detail]) + const hasProposed = useMemo(() => { + const n = detail.notes + const items = [n.label, n.description, n.grain, n.keys, n.kind, n.business_date, n.valid_filter, n.dedup, + ...Object.values(n.columns ?? {}).flatMap((c) => Object.values(c ?? {})), ...(n.relations ?? [])] + return items.some((it) => !!it && (it as { status?: string }).status === 'proposed') + }, [detail]) + + const updateTurn = useCallback((id: string, fn: (t: AssistantTurn) => AssistantTurn) => { + setTurns((ts) => ts.map((t) => (t.id === id && t.role === 'assistant' ? fn(t) : t))) + }, []) + + const send = useCallback((raw: string) => { + const question = raw.trim() + if (!question || charCount(question) > ASSISTANT_MAX_CHARS) return + if (turnsRef.current.some((t) => t.role === 'assistant' && t.state === 'streaming')) return + const n = ++seq.current + const userTurn: Turn = { id: `u${n}`, role: 'user', text: question } + const reply: AssistantTurn = { id: `a${n}`, role: 'assistant', parts: [], state: 'streaming' } + const messages = historyOf(turnsRef.current, question) + setTurns((ts) => [...ts, userTurn, reply]) + setText('') + let proposals = 0 + // 这一轮已经收尾(done、error):之后再来的事件不认 + let ended = false + stopRef.current = streamCatalogAssistant(sourceId, table, { messages, pending: pendingSubmit(pendingRef.current) }, (ev) => { + if (ended) return + if (ev.type === 'done' || ev.type === 'error') ended = true + if (ev.type === 'proposal') { + const pid = `${reply.id}.p${++proposals}` + const changes = assistantChangesOf(ev.changes) + updateTurn(reply.id, (t) => applyEvent(t, ev, pid)) + if (changes.length) { + const version = typeof ev.version === 'number' && Number.isFinite(ev.version) ? ev.version : detail.version + setPending((cur) => mergeProposal(cur, changes, pid, version)) + // 待保存的修改变了:上一次保存的结果、冲突的说明都不再对 + setSave({ phase: 'idle' }) + } + return + } + updateTurn(reply.id, (t) => applyEvent(t, ev, '')) + }, (end: CatalogAssistantEnd) => { + stopRef.current = null + updateTurn(reply.id, (t) => { + if (t.state !== 'streaming') return t + if (end.aborted) return { ...t, state: 'stopped' } + if (end.error) return { ...t, state: 'error', error: end.error, code: end.code ?? null } + // 流结束了却没有 done:这一轮没答完 + return { ...t, state: 'error', error: T.interrupted } + }) + }) + }, [sourceId, table, detail.version, updateTurn]) + + const stop = () => stopRef.current?.() + + const onKeyDown = (e: ReactKeyboardEvent) => { + if (e.key !== 'Enter' || e.shiftKey || isComposing(e)) return + e.preventDefault() + if (!streaming) send(text) + } + + const drop = (path: string) => setPending((cur) => dropPending(cur, path)) + const restore = (part: Extract, path: string) => { + const change = part.changes.find((c) => c.path === path) + if (change) setPending((cur) => mergeProposal(cur, [change], part.id, part.version)) + } + const clearAll = async () => { + if (!pendingRef.current.length) return + if (!(await confirmDialog({ title: T.clearTitle(pendingRef.current.length), confirmLabel: T.clearConfirm, danger: true }))) return + setPending([]) + setSave({ phase: 'idle' }) + } + + const doSave = async () => { + const list = pendingRef.current + if (!list.length || list.length > ASSISTANT_MAX_SAVE || editing) return + setSave({ phase: 'saving' }) + try { + const d = await api.dataCatalog.patch(sourceId, table, pendingSubmit(list), pendingVersion(list)) + setSaved((cur) => new Set([...cur, ...list.map((p) => savedKey(p.origin, p.path))])) + setPending((cur) => cur.filter((p) => !list.includes(p))) + setSave({ phase: 'idle', saved: { n: list.length, version: d.version } }) + onSaved(d) + toast.ok(T.savedToast(list.length, tableLabel)) + } catch (e) { + if (e instanceof ApiError && e.status === 409) { + // 别人刚改过:按最新内容重算(只读),停下来让人再看一眼 + setSave({ phase: 'reloading' }) + try { + const p = await api.dataCatalog.patchPreview(sourceId, table, pendingSubmit(list)) + setPending((cur) => applyPreview(cur, p)) + setSave({ phase: 'conflict', problems: p.problems }) + } catch { + setSave({ phase: 'idle', error: T.conflictFailed }) + } + } else if (e instanceof ApiError && e.kind === 'http' && (e.status === 422 || e.status === 404)) { + setSave({ phase: 'idle', error: e.message }) + } else { + setSave({ phase: 'idle' }) + toast.error(e) + } + } + } + + const close = async () => { + if (dirty && !(await confirmDialog({ title: T.closeTitle, consequences: T.closeConsequences, confirmLabel: T.closeConfirm, danger: true }))) return + stopRef.current?.() + onClose() + } + + const onPanelKey = (e: ReactKeyboardEvent) => { + if (e.key !== 'Escape' || isComposing(e) || e.defaultPrevented) return + e.preventDefault() + void close() + } + + const fresh = turns.length === 0 + const len = charCount(text) + const over = len > ASSISTANT_MAX_CHARS + const lastAssistant = [...turns].reverse().find((t): t is AssistantTurn => t.role === 'assistant') + const status = streaming ? T.answering : lastAssistant?.state === 'stopped' ? T.stopped : lastAssistant ? T.answered : '' + const sampleCols = blankCols.slice(0, Math.min(Math.max(lastBlank, 1), 5)) + + return ( +