diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 0e1c6428..0071a7c9 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3002,13 +3002,36 @@ export const CATALOG_ASSISTANT_TEXT = { profileOn: '这个数据源开了数据剖析:助手可以发少量只读查询核对数据,遮罩列不取值', notStored: '对话不保存在服务端,关闭表助手或离开页面后就没有了', greeting: '说出你知道的业务事实(某一列的含义、码值、有效记录条件……),助手整理成修改提案,你确认后才写进数据目录。助手只记数据事实,不写计算公式。', - /** 上次起草有留空的列:开场引导先问这几列 */ - blankLead: (n: number, cols: string[]) => - `上次起草有 ${formatNumber(n)} 列模型拿不准、留空了${cols.length ? `,例如 ${cols.join('、')}` : ''}。可以先从这几列开始。`, + /** + * 上次起草有留空的列:开场引导先问这几列(assistant.ts 的 blankLeadOf)。例子只举起草时记下的留空列里还没补上的; + * 拿不到起草时记下的列名时,改说目录里还没有中文名和含义的列——那里面还有没写出来的列,不能说成留空的列 + */ + blankLead: (b: { from: 'draft' | 'catalog'; n: number; examples: string[]; remaining: number | null; unnamed: number }) => { + const head = `上次起草有 ${formatNumber(b.n)} 列模型拿不准、留空了` + const cols = b.examples.join('、') + if (b.from === 'catalog') { + return b.unnamed > 0 + ? `${head}。目录里还有 ${formatNumber(b.unnamed)} 列没有中文名和含义${cols ? `,例如 ${cols}` : ''},可以先从这些列开始。` + : `${head},现在目录里的列都已有中文名或含义。` + } + if (b.remaining === 0) return `${head},现在都已补上中文名或含义。` + if (!cols) return `${head}。` + if (b.remaining == null) return `${head},例如 ${cols}。可以先从这几列开始。` + return b.remaining < b.n + ? `${head},还有 ${formatNumber(b.remaining)} 列没补上:${cols}。可以先从这几列开始。` + : `${head}:${cols}。可以先从这几列开始。` + }, suggestLabel: '可以这样开始', - suggestBlank: (n: number) => `补上留空的 ${formatNumber(n)} 列`, - suggestBlankText: (cols: string[], total: number) => - `请帮我补上这几列的中文名和含义:${cols.join('、')}${total > cols.length ? ` 等 ${formatNumber(total)} 列` : ''}。拿不准的地方先问我。`, + /** 「补上……」按钮:上面的数字就是点名的列数(不知道一共几列时不写数字) */ + suggestBlank: (from: 'draft' | 'catalog', total: number | null) => (from === 'catalog' + ? `补上没有中文名和含义的 ${formatNumber(total ?? 0)} 列` + : total == null ? '补上留空的列' : `补上留空的 ${formatNumber(total)} 列`), + suggestBlankText: (from: 'draft' | 'catalog', cols: string[], total: number | null) => { + const named = cols.join('、') + if (from === 'draft' && total == null) return `请帮我补上上次起草时模型留空的列的中文名和含义,例如 ${named}。拿不准的地方先问我。` + const more = total != null && total > cols.length ? ` 等 ${formatNumber(total)} 列` : '' + return `请帮我补上这几列的中文名和含义:${named}${more}。拿不准的地方先问我。` + }, suggestFacts: '核对有效记录条件和去重规则', suggestFactsText: '这张表的有效记录条件和去重规则应该怎么写?拿不准的地方先问我。', suggestPending: '看看哪些推断可以确认', @@ -3031,11 +3054,14 @@ export const CATALOG_ASSISTANT_TEXT = { usage: (input: number, output: number) => `输入 ${formatTokens(input, { compact: true })}、输出 ${formatTokens(output, { compact: true })}`, queries: (n: number) => `查询 ${formatNumber(n)} 次`, - // ---- 只读查询 - query: (rows: number) => `查询了数据(${formatNumber(rows)} 行)`, + // ---- 只读查询(rows 是取回的行数;truncated:后面还有行,取回的只是前一截,不是结果的总行数) + query: (rows: number, truncated = false) => (truncated + ? `查询了数据(取回前 ${formatNumber(rows)} 行,后面还有)` : `查询了数据(${formatNumber(rows)} 行)`), queryFailed: '查询没有成功', - queryRows: (shown: number, total: number) => (total > shown - ? `共 ${formatNumber(total)} 行,显示前 ${formatNumber(shown)} 行` : `共 ${formatNumber(total)} 行`), + queryRows: (shown: number, total: number, truncated = false) => { + const head = truncated ? `取回前 ${formatNumber(total)} 行(后面还有)` : `共 ${formatNumber(total)} 行` + return total > shown ? `${head},显示前 ${formatNumber(shown)} 行` : head + }, queryEmpty: '没有返回行', // ---- 修改提案 proposal: (n: number) => `修改提案(${formatNumber(n)} 项)`, diff --git a/frontend/src/pages/catalog/TableAssistant.tsx b/frontend/src/pages/catalog/TableAssistant.tsx index caf55712..2d0e0648 100644 --- a/frontend/src/pages/catalog/TableAssistant.tsx +++ b/frontend/src/pages/catalog/TableAssistant.tsx @@ -11,8 +11,8 @@ import { CATALOG_ASSISTANT_TEXT as T, CATALOG_STATUS_LABEL } from '../../lib/ter 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, + ASSISTANT_MAX_CHARS, ASSISTANT_MAX_SAVE, QUERY_ROWS_SHOWN, applyEvent, applyPreview, assistantChangesOf, blankColumnsOf, blankLeadOf, + changeStateOf, changeWhere, dropPending, historyOf, mergeProposal, pendingForAssistant, pendingSubmit, pendingVersion, savedKey, } from './assistant' import type { AssistantTurn, ChangeState, PendingChange, Turn, TurnPart } from './assistant' @@ -22,7 +22,10 @@ import type { AssistantTurn, ChangeState, PendingChange, Turn, TurnPart } from ' // 回答流式显示,能停止;助手跑过的只读查询(只有开了数据剖析的源才有)写 SQL 和前几行,默认收起;修改提案逐项写改前、 // 改后、依据,可以逐项去掉、加回。提案并进「待保存的修改」:跨多轮累积,同一项以最后一次提案为准,一键保存(最多 200 项)。 // 保存带待保存的修改里最早那条提案对照的版本:别人在那之后改过就 409,按最新内容重算(预览接口,只读)后停下来让人再看 -// 一眼。对话不存服务端:每一轮把前面的对话和待保存的修改一起交上去;关闭、换表、离开页面就没有了,有待保存的修改时先问。 +// 一眼;只重算交去保存的那几项,保存期间回答里又到的提案原样留着。对话不存服务端:每一轮把前面的对话和待保存的修改 +// (最多 200 项)一起交上去;关闭、换表、离开页面就没有了,有待保存的修改时先问。 +// 开场:上次起草有留空的列时,引导先问这几列——列名取那次起草记下的(任务里这张表的 blank_sample),拿不到时改说目录里 +// 还没有中文名和含义的列。 // =========================================================================== type SaveState = @@ -42,8 +45,11 @@ export function TableAssistant({ sourceId, table, tableLabel, detail, profileEna detail: CatalogDetail /** 数据源开了数据剖析:助手才能发只读查询 */ profileEnabled: boolean - /** 上次起草模型拿不准、留空的列数:开场引导先问这几列 */ - lastBlank: number + /** + * 上次起草模型拿不准、留空的列数(n)和出处:开场引导先问这几列。jobId 是那次起草的任务(从中取起草时记下的列名); + * earlier:最近一次停止了,n 是再之前那次起草记下的(不知道是哪一次,取不到列名) + */ + lastBlank: { n: number; earlier: boolean; jobId: string | null } /** 正在编辑这张表:先不让保存(同一张表两处同时改,保存会撞上版本冲突) */ editing: boolean /** 保存成功:页面换上返回的目录 */ @@ -88,7 +94,23 @@ export function TableAssistant({ sourceId, table, tableLabel, detail, profileEna lastLen.current = size }, [turns]) - const blankCols = useMemo(() => (lastBlank > 0 ? blankColumnsOf(detail) : []), [lastBlank, detail]) + // 上次起草记下的留空列名:从那次起草的任务里取(null 是还没取回来;取不到是空数组,改说目录里还没有中文名和含义的列) + const blankN = lastBlank.n + const blankJob = blankN > 0 && !lastBlank.earlier ? lastBlank.jobId : null + const [blankSample, setBlankSample] = useState(null) + useEffect(() => { + if (!blankJob) return + let live = true + setBlankSample(null) + api.dataCatalog.draftJob(sourceId, blankJob) + .then(({ job }) => job.tasks.find((t) => t.table_name === table)?.blank_sample?.filter(Boolean) ?? []) + .catch(() => [] as string[]) + .then((cols) => { if (live) setBlankSample(cols) }) + return () => { live = false } + }, [sourceId, table, blankJob]) + // 没有留空的列、还在取那次起草记下的列名时不写 + const blankLead = useMemo(() => (blankN <= 0 || (blankJob && blankSample == null) + ? null : blankLeadOf(blankN, blankJob ? blankSample : null, blankColumnsOf(detail))), [blankN, blankJob, blankSample, 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, @@ -113,7 +135,7 @@ export function TableAssistant({ sourceId, table, tableLabel, detail, profileEna let proposals = 0 // 这一轮已经收尾(done、error):之后再来的事件不认 let ended = false - stopRef.current = streamCatalogAssistant(sourceId, table, { messages, pending: pendingSubmit(pendingRef.current) }, (ev) => { + stopRef.current = streamCatalogAssistant(sourceId, table, { messages, pending: pendingForAssistant(pendingRef.current) }, (ev) => { if (ended) return if (ev.type === 'done' || ev.type === 'error') ended = true if (ev.type === 'proposal') { @@ -123,8 +145,8 @@ export function TableAssistant({ sourceId, table, tableLabel, detail, profileEna 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' }) + // 待保存的修改变了:上一次保存的结果、冲突的说明都不再对。正在保存、重算的不动(按钮照旧不能点,结果出来再说) + setSave((s) => (s.phase === 'saving' || s.phase === 'reloading' ? s : { phase: 'idle' })) } return } @@ -179,7 +201,8 @@ export function TableAssistant({ sourceId, table, tableLabel, detail, profileEna setSave({ phase: 'reloading' }) try { const p = await api.dataCatalog.patchPreview(sourceId, table, pendingSubmit(list)) - setPending((cur) => applyPreview(cur, p)) + // 只重算交去保存的那几项:保存、重算期间回答里又到的提案不在预览里,原样留着 + setPending((cur) => applyPreview(cur, p, list)) setSave({ phase: 'conflict', problems: p.problems }) } catch { setSave({ phase: 'idle', error: T.conflictFailed }) @@ -210,7 +233,6 @@ export function TableAssistant({ sourceId, table, tableLabel, detail, profileEna 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 (