diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 00edd8da..e4e99d80 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,5 @@ import type { - ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftOut, CatalogImpact, CatalogList, CatalogNotes, + ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftJob, CatalogImpact, CatalogList, CatalogNotes, CatalogPatchPreview, CatalogPatchSubmit, CatalogProfileOut, CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview, EditRequest, ImportRecord, ManifestOut, PurgeRawBody, PurgeRawOut, QuestionAnswer, Recipe, RedraftRulesOut, RemovePeriodBody, RemovePeriodOut, @@ -31,6 +31,9 @@ export type { ApiErrorKind, RequestOptions, UploadOptions, UploadProgress } from const isObj = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) +/** 数据目录的接口前缀:/datasources/{id}/catalog */ +const catalogBase = (sourceId: string) => `/datasources/${encodeURIComponent(sourceId)}/catalog` + /** * 上传表格被退回、要用户先选怎么处理(422 带 decision)时,取出那份决定;别的错误返回 null。 * @@ -409,12 +412,37 @@ export const api = { post(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/patch`, { changes, if_version: ifVersion }), /** - * 同步起草(注释、外键、命名推断;use_model 时再请助手的模型起草)。不给 tables 时按使用次数取前 20 张。 - * 人工确认、驳回过的项不动。没有表结构时回 409 + * 建一个起草任务(后台进行:注释、外键、命名推断;use_model 时再请起草模型起草,带上数据源的起草要求和这次的 note)。 + * 不给 tables 时按使用次数取前 20 张;表结构里没有的表照样建进任务、记 skipped。人工确认、驳回过的项不动。 + * 409:同一个数据源已有排队、进行中的任务(code 为 catalog_draft_busy,改去取 activeDraftJob);起草模型用不了 + * (catalog_draft_model_unavailable,不建任务);没有表结构 */ - draft: (sourceId: string, body: { tables?: string[]; use_model: boolean }, opts?: RequestOptions) => - request(`/datasources/${encodeURIComponent(sourceId)}/catalog/draft`, - { method: 'POST', body: JSON.stringify(body), ...opts }), + createDraftJob: (sourceId: string, body: { tables?: string[]; use_model: boolean; note?: string }) => + post<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs`, body), + /** 排队或进行中的那个起草任务;没有为 null */ + activeDraftJob: (sourceId: string, opts?: RequestOptions) => + get<{ job: CatalogDraftJob | null }>(`${catalogBase(sourceId)}/draft-jobs/active`, opts), + /** 最近建的一个起草任务(任何状态);没有为 null */ + latestDraftJob: (sourceId: string, opts?: RequestOptions) => + get<{ job: CatalogDraftJob | null }>(`${catalogBase(sourceId)}/draft-jobs/latest`, opts), + /** 一个起草任务的当前状态;不存在或不属于这个数据源回 404 */ + draftJob: (sourceId: string, jobId: string, opts?: RequestOptions) => + get<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs/${encodeURIComponent(jobId)}`, opts), + /** + * 停止起草:排队的直接停;进行中的停掉正在进行的模型调用,没开始和没写完的表记已停止,已经写进目录的不回滚。 + * 已经结束的回 409(catalog_draft_finished) + */ + cancelDraftJob: (sourceId: string, jobId: string) => + post<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs/${encodeURIComponent(jobId)}/cancel`), + /** + * 只重试失败的表:新任务只含原任务里失败、部分完成、已停止的表,用模型和补充说明沿用,起草要求取数据源现在的。 + * 没有可重试的表 422;有进行中的任务 409(catalog_draft_busy) + */ + retryDraftJob: (sourceId: string, jobId: string) => + post<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs/${encodeURIComponent(jobId)}/retry`), + /** 存数据源的起草要求(去首尾空白,最多 2000 字,空串清除)。上传表格建的源存在它自己的数据源上 */ + putGuidance: (sourceId: string, guidance: string) => + put<{ guidance: string | null }>(`${catalogBase(sourceId)}/guidance`, { guidance }), /** * 同步剖析:对业务库发少量只读查询,核对关系、取码值候选,结论写进目录。不给 tables 时按使用次数取前 10 张 * 有待核实关系的表。没开启、数据源停用、没有表结构、同一个数据源正在剖析、快照被改动时回 409(detail 是一句中文) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 61073e35..bae8db47 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -43,10 +43,11 @@ */ import type { - CatalogCardinality, CatalogMeasure, CatalogProfileNumberKey, CatalogSource, CatalogStatus, CatalogTableKind, NodeType, SnapshotReasonCode, + CatalogCardinality, CatalogDraftJobStatus, CatalogDraftTaskStatus, CatalogMeasure, CatalogProfileNumberKey, CatalogSource, CatalogStatus, + CatalogTableKind, NodeType, SnapshotReasonCode, SqlCheckCode, SqlCheckLevel, ToolTrust, } from '../types' -import { formatCost, formatNumber } from './format' +import { formatCost, formatNumber, formatTokens } from './format' export const TERMS = { workflow: '工作流', @@ -2198,7 +2199,7 @@ export const CATALOG_TEXT = { noSchemaBody: (note: string) => `${note.replace(/[。.]$/, '')}。数据目录按表结构逐表记录,请先回到数据源卡片点击「探查结构」。`, noSchemaUploadBody: '这个表格还没有可用的表结构,请重新上传后再编写数据目录。', emptyTitle: '还没有数据目录', - emptyBody: '先起草,再逐表确认。起草按数据库注释、外键约束和列名推断,也可以请助手的模型起草中文名和含义。', + emptyBody: '先起草,再逐表确认。起草按数据库注释、外键约束和列名推断,也可以用模型起草中文名和含义。', // ---- 导入表格的说明 systemTitle: '导入时生成的说明', systemBody: '这个数据源由导入表格生成,表和列的说明由系统按核对结果生成,只读,不能在数据目录中改写。', @@ -2260,29 +2261,17 @@ export const CATALOG_TEXT = { scopeAll: (n: number) => `全部 ${formatNumber(n)} 张表`, draftBase: '按数据库注释、外键约束和列名推断起草。已确认、已驳回的项不会被改动;起草出的项都是推断,需要逐项确认。', useModel: '用模型起草', - useModelHint: '请助手的模型按表结构起草中文名、说明、粒度和列的含义。只发送表结构(表名、列名、类型、约束、注释),不发送数据;耗时较长,并产生模型费用。', + useModelHint: '请起草模型按表结构起草中文名、说明、粒度和列的含义。只发送表结构(表名、列名、类型、约束、注释)和起草要求,不发送数据;在后台进行,耗时较长,并产生模型费用。', draftStart: (n: number) => `起草 ${formatNumber(n)} 张表`, - drafting: '正在起草…', - draftProgress: (done: number, total: number) => `已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`, - draftStop: '停止', - draftStopping: '当前这一批完成后停止…', - draftDone: '起草完成', - draftStopped: (done: number, total: number) => `已停止:完成 ${formatNumber(done)} / ${formatNumber(total)} 张表,其余未起草`, - draftFailed: '起草中断', draftSummary: (added: number, updated: number, removed: number) => `新增 ${formatNumber(added)} 项、更新 ${formatNumber(updated)} 项${removed ? `、删除 ${formatNumber(removed)} 项` : ''}`, - // 只在起草确实给出了内容、只是和现有目录一致时用:有表的模型起草失败或没给出可用内容(model_error)时 - // 不能说「没有变化」,改用 draftNothingWritten + // 只在起草确实给出了内容、只是和现有目录一致时用:模型没给出可用内容时说 draftNothingWritten,有表没写全时说 + // draftNothingWrittenFailed,不能说「没有变化」 draftNoChange: '没有变化:起草出的内容与现有目录一致', draftNothingWritten: '目录没有更新:模型没有给出可用的内容,原因见下方', - draftModelSkipped: (reason: string) => `模型未参与起草:${reason.replace(/[。.]$/, '')}。已按注释、外键和命名起草。`, - draftModelUsed: (model: string) => `模型:${model}`, - draftTableErrors: (n: number) => `${formatNumber(n)} 张表未能起草`, - draftModelErrors: (n: number) => `${formatNumber(n)} 张表的模型起草失败,已按注释、外键和命名起草`, + draftNothingWrittenFailed: '目录没有更新:部分表未写全,原因见下方', draftClose: '完成', - moreTables: (n: number) => `…另有 ${formatNumber(n)} 张`, cancel: '取消', - draftBackground: '起草仍在进行,当前这一批完成后停止', // ---- 表详情 backToList: '返回表清单', prev: '上一张', @@ -2389,6 +2378,123 @@ export const CATALOG_TEXT = { reloadDiscardAction: '放弃修改并重新载入', } +// =========================================================================== +// 数据目录的起草任务(/api/datasources/{id}/catalog/draft-jobs):起草在服务端后台进行,关掉对话框不停;逐表起草, +// 每张表一结束就写进目录。 +// +// 术语: +// - 「起草任务」的状态:排队中、正在起草、起草完成、部分表未写全、起草失败、已停止、服务重启中断。 +// 一张表:等待、起草中、已完成、部分完成、失败、已跳过(表结构里没有)、已停止。 +// - 「起草要求」:使用者补充给模型的业务背景和用词约定,存在数据源上,以后每次用模型起草都带上; +// 「本次补充说明」只用于这一次(重试时沿用)。两者都只交给模型,不改起草规则:冲突时以规则为准。 +// - 「起草模型」:设置里单独的一项(偏好设置 → 起草模型),留空跟随助手的模型。 +// - 宽表的「段」:超过 40 列的表拆成表级一段和若干列分段,逐段起草;写的是「已完成 3 / 9 段」。 +// - 「只重试失败的表」:重新起草失败、部分完成和已停止的表;已经写进目录的内容不回滚、不受影响。 +// =========================================================================== + +/** 起草要求、本次补充说明的字数上限(服务端同一个数:超长 422) */ +export const DRAFT_GUIDANCE_MAX = 2000 +export const DRAFT_NOTE_MAX = 1000 + +export const CATALOG_DRAFT_TEXT = { + // ---- 设置阶段:起草要求、本次补充说明、起草模型 + guidance: '起草要求', + guidanceHint: '写给模型的业务背景和用词约定,保存在这个数据源上,以后每次用模型起草都会带上。例如:「核销」指检票入园;金额单位是元', + guidanceRule: '起草要求只帮助模型理解业务、统一用词;与起草规则冲突时以规则为准,起草出的项仍需逐项确认', + guidancePlaceholder: '业务背景、常用说法、单位约定……', + note: '本次补充说明', + noteHint: '只用于这一次起草,例如「这一批是会员相关的表」;重试时沿用', + notePlaceholder: '可不填', + count: (n: number, max: number) => `${formatNumber(n)} / ${formatNumber(max)} 字`, + tooLong: (max: number) => `不超过 ${formatNumber(max)} 字`, + sourceDescription: (text: string) => `数据源说明也会一并交给模型:${text}`, + model: '起草模型', + /** 设置里没配起草模型:跟随助手的模型,写出当前是哪个(取不到就不写) */ + modelFollow: (current: string | null) => (current ? `跟随助手的模型(当前:${current})` : '跟随助手的模型'), + /** 设置里配了:模型(没填模型就是那个接入的默认模型)和接入 */ + modelNamed: (model: string | null, provider: string | null) => (model + ? (provider ? `${model}(模型接入「${provider}」)` : model) + : `模型接入「${provider}」的默认模型`), + modelWhere: '可在「设置 → 偏好设置 → 起草模型」中更换', + tooMany: (max: number) => `一次最多起草 ${formatNumber(max)} 张表,请缩小范围`, + saveGuidanceFailed: (reason: string) => `起草要求未能保存,未开始起草:${reason}`, + startFailed: (reason: string) => `未能开始起草:${reason}`, + // ---- 上一次起草(设置阶段,没写全的才提) + last: (state: string, at: string) => `上一次起草(${at}):${state}`, + lastOpen: '查看', + // ---- 进行中 + queued: '排队中,等待开始…', + running: '正在起草…', + stopping: '正在停止:当前的模型调用结束后停止…', + progress: (done: number, total: number) => `已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`, + /** 结束以后:全部写出来的表有几张(停止、失败、部分完成的不算) */ + progressDone: (ok: number, total: number) => `写完 ${formatNumber(ok)} / ${formatNumber(total)} 张表`, + parts: (done: number, total: number) => `${formatNumber(done)} / ${formatNumber(total)} 段`, + partsHint: '超过 40 列的表拆成表级一段和若干列分段,逐段起草', + hide: '在后台继续', + hideHint: '关闭对话框不会停止起草,进度显示在目录页上方;每张表起草完就写进目录', + stop: '停止', + busy: '这个数据源已有进行中的起草任务,下面显示的是它的进度', + modelUsed: (model: string) => `模型:${model}`, + usage: (calls: number, input: number, output: number) => + `调用模型 ${formatNumber(calls)} 次,输入 ${formatTokens(input, { compact: true })}、输出 ${formatTokens(output, { compact: true })}`, + // ---- 结束 + title: { + queued: '排队中,等待开始…', + running: '正在起草…', + succeeded: '起草完成', + partial: '起草完成,部分表未写全', + failed: '起草失败', + cancelled: '已停止', + interrupted: '服务重启,起草中断', + } as Record, + interrupted: '服务重启时这次起草还没有完成,没写完的表未起草。已写进目录的内容保留;可以只重试这些表,接着起草', + cancelled: '已写进目录的内容保留,没写完的表未起草', + jobFailed: (reason: string) => `起草任务出错:${reason}`, + /** 没写全的表:失败、部分完成、已停止各几张;跳过的(表结构里没有)另说 */ + problems: (c: { failed: number; partial: number; cancelled: number; skipped: number }) => { + const parts = [ + c.failed ? `失败 ${formatNumber(c.failed)} 张` : '', + c.partial ? `部分完成 ${formatNumber(c.partial)} 张` : '', + c.cancelled ? `已停止 ${formatNumber(c.cancelled)} 张` : '', + ].filter(Boolean) + const bad = parts.length ? `${formatNumber(c.failed + c.partial + c.cancelled)} 张表未写全:${parts.join('、')}` : '' + const skipped = c.skipped ? `${formatNumber(c.skipped)} 张表已跳过(表结构里没有)` : '' + return [bad, skipped].filter(Boolean).join(';') + }, + retry: (n: number) => `只重试失败的表(${formatNumber(n)} 张)`, + retryHint: '重新起草失败、部分完成和已停止的表;已写进目录的内容不受影响', + showAll: (n: number) => `显示全部 ${formatNumber(n)} 张表`, + showProblems: '只看未写全的表', + tasks: '逐表进度', + taskStatus: { + pending: '等待', running: '起草中', ok: '已完成', partial: '部分完成', failed: '失败', skipped: '已跳过', cancelled: '已停止', + } as Record, + taskAdded: (added: number, updated: number) => (added || updated + ? `+${formatNumber(added)}${updated ? ` · 改 ${formatNumber(updated)}` : ''}` : ''), + taskAddedHint: (added: number, updated: number, removed: number) => + `新增 ${formatNumber(added)} 项、更新 ${formatNumber(updated)} 项${removed ? `、删除 ${formatNumber(removed)} 项` : ''}`, + // ---- 目录页上方的进度、结束提示 + strip: (done: number, total: number) => `正在起草:已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`, + stripQueued: (total: number) => `起草任务排队中,共 ${formatNumber(total)} 张表`, + stripOpen: '查看进度', + toast: { + succeeded: (summary: string) => `数据目录起草完成:${summary}`, + partial: (n: number) => `数据目录起草完成,${formatNumber(n)} 张表未写全`, + failed: (reason: string) => `数据目录起草失败:${reason}`, + cancelled: '数据目录起草已停止', + interrupted: '服务重启,数据目录起草中断', + }, + viewResult: '查看结果', + // ---- 清单上的标记、表详情里的说明 + lastFailed: '上次起草失败', + lastPartial: '上次起草部分完成', + lastWith: (label: string, reason: string) => `${label}:${reason}`, + redraft: '重新起草这张表', + redraftHint: '用模型重新起草这张表(不带补充说明,带上数据源的起草要求);人工确认、驳回过的项不会被改动', + redraftBusy: '已有起草任务在进行,结束后再重新起草这张表', +} + // =========================================================================== // 目录修改提案:用户在助手(画布)或问数据页说出一条数据事实时,助手提出的数据目录修改。 // diff --git a/frontend/src/pages/catalog/CatalogPage.tsx b/frontend/src/pages/catalog/CatalogPage.tsx index 48442f3a..15f8e24e 100644 --- a/frontend/src/pages/catalog/CatalogPage.tsx +++ b/frontend/src/pages/catalog/CatalogPage.tsx @@ -7,9 +7,13 @@ import { ApiError, api } from '../../api/client' import type { CatalogDetail, CatalogList, CatalogTableRow } from '../../types' import { EmptyState, ErrorState, PageHeader, Skeleton, Spinner, toast, useTicker } from '../../components/ui' import { useCatalog, useDatasources, useOnReconnect } from '../../store/catalog' -import { CATALOG_TEXT as CT, PROFILE_STOP_LABEL, PROFILE_TEXT as PT, SCHEMA_PARTIAL_TEXT as SPT } from '../../lib/terms' +import { + CATALOG_DRAFT_TEXT as DT, CATALOG_TEXT as CT, PROFILE_STOP_LABEL, PROFILE_TEXT as PT, SCHEMA_PARTIAL_TEXT as SPT, +} from '../../lib/terms' import { StatusLegend, SystemNotesNotice } from './parts' import { DraftDialog } from './DraftDialog' +import { isJobActive, retryableCount, useDraftJob } from './draftJob' +import type { DraftStartBody } from './draftJob' import { ProfileDialog } from './ProfileDialog' import type { ProfileJob } from './ProfileDialog' import { ProfileSettingsDialog } from './ProfileSettings' @@ -99,7 +103,11 @@ export function CatalogPage() { const search = writeListParams(params, list).toString() searchRef.current = search ? `?${search}` : '' const [selected, setSelected] = useState>(() => new Set()) - const [drafting, setDrafting] = useState(false) + /** + * 起草对话框开着没有、打开时显示什么:setup 选范围开始;job 显示页面跟着的任务(进行中的进度、刚结束的结果)。 + * busy:是撞上已有任务(409)转过去显示的 + */ + const [draftOpen, setDraftOpen] = useState<{ view: 'setup' | 'job'; busy?: boolean } | null>(null) /** 起草写进了这些表:详情据此重新载入(编辑中的不动,保存时服务端会用版本号拦下) */ const [drafted, setDrafted] = useState<{ seq: number; tables: Set }>({ seq: 0, tables: new Set() }) const [profiling, setProfiling] = useState(false) @@ -142,11 +150,59 @@ export function CatalogPage() { })) }, []) - const onDrafted = useCallback((tables: string[]) => { - setDrafted((cur) => ({ seq: cur.seq + 1, tables: new Set(tables) })) - setSelected(new Set()) - void load() - }, [load]) + /** + * 起草任务(后台进行,状态在页面上):每张表一结束就重取清单、正在看的表在里面就重新载入(编辑中的不动,保存时服务端 + * 会用版本号拦下);结束时重取一次清单。对话框关着时结束的,弹提示并给「查看结果」 + */ + const draftOpenRef = useRef(draftOpen) + draftOpenRef.current = draftOpen + const draft = useDraftJob(sourceId, data?.active_job, { + onTablesEnded: (tables) => { + setDrafted((cur) => ({ seq: cur.seq + 1, tables: new Set(tables) })) + void load() + }, + onFinished: (job) => { + void load() + if (draftOpenRef.current) return + const src = job.source_id + const opts = { + key: `catalog-draft:${src}`, duration: 10000, + action: { label: DT.viewResult, onClick: () => { if (sourceRef.current === src) setDraftOpen({ view: 'job' }) } }, + } + if (job.status === 'succeeded') toast.ok(DT.toast.succeeded(CT.draftSummary(job.added, job.updated, job.removed)), opts) + else if (job.status === 'partial') toast.warn(DT.toast.partial(retryableCount(job)), opts) + else if (job.status === 'failed') toast.error(DT.toast.failed(job.error ?? ''), opts) + else if (job.status === 'interrupted') toast.warn(DT.toast.interrupted, opts) + else toast.warn(DT.toast.cancelled, opts) + }, + }) + const draftJob = draft.job + const draftActive = isJobActive(draftJob) + /** 打开起草对话框:有进行中的任务就直接显示它;上一次的结果看过了,再点就是重新开始 */ + const openDraft = useCallback(() => setDraftOpen({ view: draftActive ? 'job' : 'setup' }), [draftActive]) + const startDraft = useCallback(async (body: DraftStartBody) => { + const r = await draft.start(body) + // 选中的表已经交给起草任务了:清空,免得接着点「起草所选」又起草一遍 + if (!r.busy) setSelected(new Set()) + return r + }, [draft.start]) + /** 详情里「重新起草这张表」:只含这张表、用模型、不带补充说明。模型用不了等开始不了的,直接提示原因 */ + const redraftTable = useCallback(async (t: string) => { + try { + const r = await draft.start({ tables: [t], use_model: true }) + setDraftOpen({ view: 'job', busy: r.busy }) + } catch (e) { + toast.error(e) + } + }, [draft.start]) + const onGuidanceSaved = useCallback((g: string | null) => { + setData((cur) => cur && ({ ...cur, guidance: g })) + }, []) + // 上方的进度:跟着的任务在进行就按它;跟着的就是清单说的那一个、已经结束了,就不信清单(清单还没重取回来) + const listActive = isJobActive(data?.active_job) ? data?.active_job ?? null : null + const stripJob: { done: number; total: number; status: string } | null = draftJob && (draftActive || draftJob.id === listActive?.id) + ? (draftActive ? draftJob : null) + : listActive const options = savedOptions?.sourceId === sourceId ? savedOptions.options : source?.options const profileSettings = useMemo(() => profileSettingsOf(options), [options]) @@ -262,7 +318,7 @@ export function CatalogPage() { const empty = rows.length > 0 && rows.every((r) => progressOf(r.counts) === 'none') const hasTables = rows.length > 0 const draftButton = (primary = false) => ( - ) @@ -281,7 +337,7 @@ export function CatalogPage() { ) // 页头的「起草」窄屏只留图标,标题才放得下 const headerDraft = ( - ) @@ -335,7 +391,7 @@ export function CatalogPage() { onSelected={setSelected} active={table} onOpen={openTable} - onDraft={() => setDrafting(true)} + onDraft={openDraft} onProfile={openProfile} /> @@ -343,7 +399,8 @@ export function CatalogPage() { {table ? ( + onFillDone={onFillDone} onOpen={openTable} onBack={backToList} onDetail={onDetail} + draftBusy={!!stripJob} onRedraft={redraftTable} /> ) : } @@ -373,16 +430,27 @@ export function CatalogPage() { onGoSource={() => navigate(back)} /> )} {hasTables && usage.used > 0 && !empty && } + {hasTables && stripJob && !draftOpen && } {body} - {drafting && data && ( + {draftOpen && data && ( setDrafting(false)} - onDrafted={onDrafted} + job={draftJob} + initial={draftOpen.view} + initialBusy={!!draftOpen.busy} + stopping={draft.stopping} + onStart={startDraft} + onCancel={draft.cancel} + onRetry={draft.retry} + onTrack={draft.track} + onGuidanceSaved={onGuidanceSaved} + onClose={() => setDraftOpen(null)} /> )} {profiling && data && ( @@ -412,6 +480,29 @@ export function CatalogPage() { ) } +/** + * 起草任务在进行:目录页上方一条进度(关掉对话框任务照常进行,每张表起草完就写进目录)。点「查看进度」回到对话框 + */ +function DraftStrip({ job, onOpen }: { job: { done: number; total: number; status: string }; onOpen: () => void }) { + const text = job.status === 'queued' ? DT.stripQueued(job.total) : DT.strip(job.done, job.total) + return ( +
+ +
+

{text}

+
+
+
+
+ +
+ ) +} + /** 媒体查询的当前结果,跟着窗口大小变 */ function useMedia(query: string): boolean { const [on, setOn] = useState(() => typeof matchMedia !== 'function' || matchMedia(query).matches) diff --git a/frontend/src/pages/catalog/DraftDialog.tsx b/frontend/src/pages/catalog/DraftDialog.tsx index c9fba7fc..47e11c65 100644 --- a/frontend/src/pages/catalog/DraftDialog.tsx +++ b/frontend/src/pages/catalog/DraftDialog.tsx @@ -1,66 +1,41 @@ -import { useMemo, useRef, useState } from 'react' -import { Sparkles } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { CircleCheck, CircleDashed, CircleMinus, CircleSlash, CircleX, RotateCcw, Sparkles, TriangleAlert } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' import clsx from 'clsx' -import { api } from '../../api/client' -import type { CatalogDraftRow, CatalogTableRow } from '../../types' -import { Modal, Notice, Spinner } from '../../components/ui' +import { ApiError, api } from '../../api/client' +import type { CatalogDraftJob, CatalogDraftTask, CatalogDraftTaskStatus, CatalogTableRow } from '../../types' +import { Field, Modal, Notice, Spinner } from '../../components/ui' import { errorMessage } from '../../lib/errors' -import { CATALOG_TEXT as CT } from '../../lib/terms' +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_TOP_N, RETRYABLE, isJobActive, nothingWritten, retryableCount, taskReason, +} from './draftJob' +import type { DraftStartBody } from './draftJob' // =========================================================================== -// 起草数据目录。接口是同步的,一次请求起草完才返回;表多时分批调用,每批回来就更新进度和累计的新增、更新、删除, -// 中途可以停止(当前这一批做完后不再发下一批)。模型整体用不了时第一批就会知道:之后的批次不再请模型,结果里 -// 写明原因。已经写进去的批次不回滚——起草只增改推断项,人工确认、驳回过的项服务端不会动。 +// 起草数据目录。起草是服务端的后台任务:这里选范围、写起草要求和本次补充说明,建任务;之后显示页面跟着的那个任务 +// (进行中的进度、结束后的结果),任务本身的状态在页面上(draftJob.ts),关掉对话框照常进行。 +// +// 三段:设置(范围、用不用模型;用模型时写起草要求、补充说明,写明用哪个起草模型)→ 进行中(总进度、合计、逐表的 +// 状态和宽表的段进度、没写全的原因;可以停止、在后台继续)→ 结束(汇总、没写全的表和原因、只重试失败的表)。 +// 已经写进目录的内容不回滚——起草只增改推断项,人工确认、驳回过的项服务端不会动。 // =========================================================================== -/** 每批几张表。只按注释、外键和命名起草时只看探查缓存,很快;请模型时服务端每 4 张一批、最多 3 批并发 */ -const BATCH_PLAIN = 40 -const BATCH_MODEL = 12 -/** 服务端不给 tables 时取前 20 张,这里给出同样的范围,进度才算得出来 */ -const TOP_N = 20 -/** 结果里逐张列出的表最多几张,其余合成一句 */ -const LIST_MAX = 8 - type Scope = 'selected' | 'filtered' | 'top' | 'all' -type Phase = 'setup' | 'running' | 'done' -interface Outcome { - done: number - total: number - added: number - updated: number - removed: number - tableErrors: CatalogDraftRow[] - modelErrors: CatalogDraftRow[] - modelSkipped: string | null - model: string | null - failure: string | null - stopped: boolean - /** 这一次勾了「用模型起草」 */ - modelAsked: boolean - /** 模型给了可用内容(model_items > 0)的表有几张;服务端没给 model_items(老服务端)时 modelItemsKnown 为假 */ - modelItems: number - modelItemsKnown: boolean -} +/** 字数按服务端的算法:去掉首尾空白后的字符数(按码位,不按 UTF-16 的长度) */ +const charCount = (s: string) => [...s.trim()].length -const fresh = (total: number, modelAsked = false): Outcome => ({ - done: 0, total, added: 0, updated: 0, removed: 0, tableErrors: [], modelErrors: [], modelSkipped: null, model: null, - failure: null, stopped: false, modelAsked, modelItems: 0, modelItemsKnown: false, -}) - -/** - * 一项都没写进去时怎么说。「没有变化」只在确实是「起草出了内容、和现有目录一致」时说:有表没起草、模型在某些表上 - * 没给出内容(model_error),或者勾了用模型、却没有一张表拿到模型的可用内容(模型整体用不了、model_items 都是 0), - * 都说「目录没有更新」,原因写在下面 - */ -function nothingWrittenText(out: Outcome): string { - const failed = out.modelErrors.length > 0 || out.tableErrors.length > 0 - const modelEmpty = out.modelAsked && (!!out.modelSkipped || (out.modelItemsKnown && out.modelItems === 0)) - return failed || modelEmpty ? CT.draftNothingWritten : CT.draftNoChange -} - -export function DraftDialog({ sourceId, rows, visible, selected, filtered, onClose, onDrafted }: { +export function DraftDialog({ + sourceId, sourceDescription, guidance, rows, visible, selected, filtered, job, initial, initialBusy = false, stopping, + onStart, onCancel, onRetry, onTrack, onGuidanceSaved, onClose, +}: { sourceId: string + /** 数据源说明:用模型起草时连同起草要求一起交给模型 */ + sourceDescription: string + /** 数据源存着的起草要求(没有为空串) */ + guidance: string /** 全部表(服务端的顺序:使用次数多的在前) */ rows: CatalogTableRow[] /** 当前筛选结果 */ @@ -68,11 +43,25 @@ export function DraftDialog({ sourceId, rows, visible, selected, filtered, onClo selected: string[] /** 清单上有没有生效的搜索或筛选:有时才给「当前筛选结果」这一项 */ filtered: boolean + /** 页面跟着的起草任务(进行中的、刚结束的) */ + job: CatalogDraftJob | null + /** 打开时显示什么:setup 选范围、开始;job 显示页面跟着的那个任务 */ + initial: 'setup' | 'job' + /** 打开时显示的任务是撞上已有任务(409)转过来的:写明 */ + initialBusy?: boolean + /** 点了停止、服务端还没停下来 */ + stopping: boolean + /** 建任务;409 已有任务时页面改取进行中的那一个(busy 为真) */ + onStart: (body: DraftStartBody) => Promise<{ job: CatalogDraftJob; busy: boolean }> + onCancel: () => Promise + onRetry: (from: CatalogDraftJob) => Promise<{ job: CatalogDraftJob; busy: boolean }> + /** 设置阶段点开了上一次起草的结果(或者发现别处建的进行中的任务):页面改跟这个任务 */ + onTrack: (job: CatalogDraftJob) => void + /** 起草要求存好了:页面记下新的这一份 */ + onGuidanceSaved: (guidance: string | null) => void onClose: () => void - /** 有批次写进去了:重取清单、刷新正在看的表 */ - onDrafted: (tables: string[]) => void }) { - // 表结构里已经没有的表起草不了(服务端会回「表结构里没有」),不算进范围 + // 表结构里已经没有的表起草不了(服务端会记「已跳过」),不算进范围 const live = useMemo(() => rows.filter((r) => r.in_schema).map((r) => r.table_name), [rows]) const liveSet = useMemo(() => new Set(live), [live]) const scopes = useMemo(() => { @@ -81,80 +70,156 @@ export function DraftDialog({ sourceId, rows, visible, selected, filtered, onClo if (sel.length) out.push({ key: 'selected', label: CT.scopeSelected(sel.length), tables: sel }) const vis = visible.filter((r) => r.in_schema).map((r) => r.table_name) if (filtered && vis.length && vis.length !== live.length) out.push({ key: 'filtered', label: CT.scopeFiltered(vis.length), tables: vis }) - if (live.length > TOP_N) out.push({ key: 'top', label: CT.scopeTop(TOP_N), tables: live.slice(0, TOP_N) }) + if (live.length > DRAFT_TOP_N) out.push({ key: 'top', label: CT.scopeTop(DRAFT_TOP_N), tables: live.slice(0, DRAFT_TOP_N) }) out.push({ key: 'all', label: CT.scopeAll(live.length), tables: live }) return out }, [selected, visible, filtered, live, liveSet]) const [scope, setScope] = useState(() => scopes[0].key) - const [useModel, setUseModel] = useState(false) - const [phase, setPhase] = useState('setup') - const [out, setOut] = useState(() => fresh(0)) - const [stopping, setStopping] = useState(false) - const stopRef = useRef(false) const tables = scopes.find((s) => s.key === scope)?.tables ?? scopes[0].tables + const tooMany = tables.length > DRAFT_MAX_TABLES + const [useModel, setUseModel] = useState(false) + const [guidanceText, setGuidanceText] = useState(guidance) + const [note, setNote] = useState('') + const guidanceLen = charCount(guidanceText) + const noteLen = charCount(note) + const guidanceOver = guidanceLen > DRAFT_GUIDANCE_MAX + const noteOver = noteLen > DRAFT_NOTE_MAX + const guidanceChanged = guidanceText.trim() !== guidance.trim() - const run = async () => { - stopRef.current = false - setStopping(false) - setPhase('running') - const acc = fresh(tables.length, useModel) - setOut({ ...acc }) - const touched: string[] = [] - let withModel = useModel - let i = 0 - while (i < tables.length) { - if (stopRef.current) { acc.stopped = true; break } - const batch = tables.slice(i, i + (withModel ? BATCH_MODEL : BATCH_PLAIN)) - try { - const r = await api.dataCatalog.draft(sourceId, { tables: batch, use_model: withModel }) - acc.added += r.total.added - acc.updated += r.total.updated - acc.removed += r.total.removed - acc.tableErrors.push(...r.tables.filter((t) => t.error)) - acc.modelErrors.push(...r.tables.filter((t) => !t.error && t.model_error)) - acc.modelItems += r.tables.filter((t) => !t.error && (t.model_items ?? 0) > 0).length - if (r.tables.some((t) => t.model_items !== undefined)) acc.modelItemsKnown = true - if (r.model) acc.model = r.model - // 模型整体用不了(没配置、已停用……):后面的批次不再请模型,免得每批都撞一次同样的错 - if (withModel && r.model_error && !r.model_used) { acc.modelSkipped = r.model_error; withModel = false } - touched.push(...r.tables.filter((t) => !t.error).map((t) => t.table_name)) - } catch (e) { - acc.failure = errorMessage(e) - break + const [mode, setMode] = useState<'setup' | 'job'>(initial === 'job' && job ? 'job' : 'setup') + const [starting, setStarting] = useState(false) + const [startError, setStartError] = useState(null) + /** 是 409(已有任务)转过来显示的那一个 */ + const [busy, setBusy] = useState(initialBusy) + const [acting, setActing] = useState(false) + const [actionError, setActionError] = useState(null) + /** 上一次起草(没写全的才提),设置阶段写一行、可以点开 */ + const [last, setLast] = useState(null) + /** 起草模型的说法:设置里的起草模型,没配就是跟随助手的模型 */ + const [modelLine, setModelLine] = useState<{ text: string; warn: string | null } | null>(null) + const onTrackRef = useRef(onTrack) + onTrackRef.current = onTrack + + const inSetup = mode === 'setup' || !job + // 设置阶段:取上一次起草。别处(另一个标签页)建的任务还在进行:直接显示它 + useEffect(() => { + if (!inSetup) return + let alive = true + api.dataCatalog.latestDraftJob(sourceId).then(({ job: latest }) => { + if (!alive || !latest) return + if (isJobActive(latest)) { + onTrackRef.current(latest) + setBusy(true) + setMode('job') + } else if (latest.status !== 'succeeded') { + setLast(latest) } - i += batch.length - acc.done = i - setOut({ ...acc }) + }, () => {}) + return () => { alive = false } + }, [inSetup, sourceId]) + + // 起草模型:设置里的 catalog_draft 填了就是它;没填跟随助手的模型(写出当前是哪个,用不了时写原因) + useEffect(() => { + let alive = true + void Promise.allSettled([api.settings.get(), api.copilot.getModel()]).then(([s, c]) => { + if (!alive) return + const cfg = s.status === 'fulfilled' ? s.value?.catalog_draft : null + const provider = typeof cfg?.provider === 'string' && cfg.provider.trim() ? cfg.provider.trim() : null + const model = typeof cfg?.model === 'string' && cfg.model.trim() ? cfg.model.trim() : null + if (provider || model) { + setModelLine({ text: DT.modelNamed(model, provider), warn: null }) + return + } + const assistant = c.status === 'fulfilled' ? c.value : null + setModelLine({ text: DT.modelFollow(assistant?.effective_model ?? null), warn: assistant?.error ?? null }) + }) + return () => { alive = false } + }, []) + + const begin = async () => { + if (starting || !tables.length || tooMany || (useModel && (guidanceOver || noteOver))) return + setStarting(true) + setStartError(null) + try { + // 起草要求改过:先存(存不上就不开始,免得模型拿着旧的那份起草) + if (useModel && guidanceChanged) { + try { + const saved = await api.dataCatalog.putGuidance(sourceId, guidanceText.trim()) + onGuidanceSaved(saved.guidance) + } catch (e) { + setStartError(DT.saveGuidanceFailed(errorMessage(e))) + return + } + } + const extra = useModel && note.trim() ? { note: note.trim() } : {} + const r = await onStart({ tables, use_model: useModel, ...extra }) + setBusy(r.busy) + setMode('job') + } catch (e) { + // 起草模型用不了(没有接入、已停用、只有演示接入):服务端那句话已经写了原因和去处 + setStartError(e instanceof ApiError && e.code === DRAFT_MODEL_UNAVAILABLE ? e.message : DT.startFailed(errorMessage(e))) + } finally { + setStarting(false) } - setOut({ ...acc }) - setPhase('done') - if (touched.length) onDrafted(touched) } - const stop = () => { - stopRef.current = true - setStopping(true) + const act = async (fn: () => Promise) => { + setActing(true) + setActionError(null) + try { + await fn() + } catch (e) { + setActionError(errorMessage(e)) + } finally { + setActing(false) + } + } + const stop = () => act(onCancel) + const retry = (from: CatalogDraftJob) => act(async () => { + const r = await onRetry(from) + setBusy(r.busy) + }) + const openLast = (j: CatalogDraftJob) => { + onTrack(j) + setBusy(false) + setMode('job') } - // 进行中关窗 = 停止:当前这一批做完再关,结果照常显示 - const requestClose = () => (phase === 'running' ? stop() : onClose()) + const phase: 'setup' | 'running' | 'done' = inSetup ? 'setup' : isJobActive(job) ? 'running' : 'done' + const canStart = !!tables.length && !tooMany && !(useModel && (guidanceOver || noteOver)) && !starting + const retryable = job && phase === 'done' ? retryableCount(job) : 0 const footer = phase === 'setup' ? ( <> - ) : phase === 'running' - ? - : + ? ( + <> + + + + ) + : ( + <> + {retryable > 0 && job && ( + + )} + + + ) return ( - -
+ +
{phase === 'setup' && ( <>
@@ -168,71 +233,192 @@ export function DraftDialog({ sourceId, rows, visible, selected, filtered, onClo ))}
+ {tooMany &&

{DT.tooMany(DRAFT_MAX_TABLES)}

}

{CT.draftBase}

- +
+ + {useModel && ( +
+
+ {DT.model}: + {modelLine ? modelLine.text : '…'} + {DT.modelWhere} + {modelLine?.warn && {modelLine.warn}} +
+
+ + {(p) => ( +