import { useEffect, useRef, useState } from "react"; import { ChevronLeft, ChevronRight, Download, Save, Trash2, X, } from "lucide-react"; import { api } from "./api"; import { labels, roleLabels, type AIConfig, type AssistantDesign, type AssistantMessage, type AssistantMode, type AssistantReply, type Context, type Dataset, type FormulaColumn, type FormulaExpression, type SavedFormulaDefinition, type Metric, type Preferences, type Session, } from "./types"; type User = NonNullable; interface ChatMessage { role: "user" | "assistant"; content: string; history?: string; source?: "model" | "rules"; model?: string | null; } const navigationActions = new Set([ "overview", "anomalies", "drill", "evidence", ]); function scopeKeyOf(ds: Dataset, ctx: Context) { return [ ds.id, ds.version, ctx.start, ctx.end, ctx.baseline_end || "", ctx.comparison, ctx.group, ctx.metric, JSON.stringify(Object.entries(ctx.filters || {}).sort()), ].join("|"); } // 只提交已完成的 user→assistant 轮次,最多 5 轮,当前问题不进入 history。 function completedHistory(messages: ChatMessage[]): AssistantMessage[] { const history: AssistantMessage[] = []; for (let index = 0; index + 1 < messages.length; index += 2) { const user = messages[index]; const assistant = messages[index + 1]; if (user.role === "user" && assistant.role === "assistant") history.push( { role: "user", content: user.content }, { role: "assistant", content: assistant.history || assistant.content, }, ); } return history.slice(-10); } function replyContent(reply: AssistantReply) { const answer = (reply.answer || "").trim(); if (reply.kind !== "column_design") return answer || "本轮没有返回内容。"; const questions = (reply.design?.questions || []) .map((question) => question.trim()) .filter(Boolean); if ( questions.length && !questions.every((question) => answer.includes(question)) ) return [answer, questions.join("\n")].filter(Boolean).join("\n"); return answer || "请补充列名、计算含义和适用范围。"; } function describeExpression(expression: FormulaExpression): string { if ("metric" in expression) return labels[expression.metric]; if ("number" in expression) return String(expression.number); const operators = { add: "+", subtract: "−", multiply: "×", divide: "÷" }; return `(${describeExpression(expression.left)} ${operators[expression.operator]} ${describeExpression(expression.right)})`; } export function Modal({ title, onClose, wide = false, children, }: { title: string; onClose: () => void; wide?: boolean; children: React.ReactNode; }) { const ref = useRef(null); useEffect(() => { const dialog = ref.current; if (!dialog) return; dialog.showModal(); return () => dialog.close(); }, []); return (

{title}

{children}
); } export function ImportDialog({ ds, onClose, onDone, }: { ds: Dataset; onClose: () => void; onDone: () => void; }) { const [mapping, setMapping] = useState(ds.mapping), [error, setError] = useState(""), [busy, setBusy] = useState(false); async function submit() { setBusy(true); setError(""); try { await api(`/datasets/${ds.id}/mapping`, "POST", mapping); onDone(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } } function update( index: number, patch: Partial<(typeof mapping.columns)[number]>, ) { setMapping({ ...mapping, columns: mapping.columns.map((c) => c.index === index ? { ...c, ...patch } : c, ), }); } return (

{ds.name} · 检查识别字段及财务口径后确认导入。

{mapping.columns.map((c) => ( ))}
源字段 角色 维度键 说明
{c.name} {c.role === "dimension" && ( update(c.index, { key: e.target.value }) } /> )} update(c.index, { description: e.target.value }) } />
{error && (

{error}

)}
导入后该版本映射将锁定。
); } export function SettingsDialog({ ds, onClose, onSave, }: { ds: Dataset; onClose: () => void; onSave: (value: Preferences, revision: number) => void; }) { const [value, setValue] = useState(ds.preferences), [revision, setRevision] = useState(ds.revision || 0), [error, setError] = useState(""), [busy, setBusy] = useState(false); useEffect(() => { api<{ value: Preferences; revision: number }>(`/datasets/${ds.id}/settings`) .then((r) => { setValue(r.value); setRevision(r.revision); }) .catch((e) => setError(e.message)); }, [ds.id]); async function save() { setBusy(true); setError(""); try { const result = await api<{ value: Preferences; revision: number }>( `/datasets/${ds.id}/settings`, "PUT", { value, revision }, ); onSave(result.value, result.revision); onClose(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } } return (
{error && (

{error}

)}
); } export function EvidenceDialog({ ds, ctx, user, onClose, }: { ds: Dataset; ctx: Context; user: NonNullable; onClose: () => void; }) { const pageSize = 50; const [result, setResult] = useState<{ rows: Record[]; total: number; sheet: string; }>(), [side, setSide] = useState<"current" | "baseline">("current"), [page, setPage] = useState(0), [error, setError] = useState(""); useEffect(() => { let active = true; setResult(undefined); setError(""); api<{ rows: Record[]; total: number; sheet: string }>( `/datasets/${ds.id}/evidence`, "POST", { ...ctx, side, offset: page * pageSize, limit: pageSize }, ) .then((value) => active && setResult(value)) .catch((e) => active && setError(e.message)); return () => { active = false; }; }, [ds.id, ctx, side, page]); function selectSide(value: "current" | "baseline") { if (value === side && page === 0) return; setResult(undefined); setPage(0); setSide(value); } function selectPage(value: number) { setResult(undefined); setPage(value); } const first = result?.total ? page * pageSize + 1 : 0; const last = result ? Math.min((page + 1) * pageSize, result.total) : 0; return (

来源工作表:{result?.sheet || "加载中"} · {result?.total ?? 0} 条

{user.role !== "admin" &&

客户原始标识不包含在明细中。

} {error && (

{error}

)}
{ds.dimensions.map((d) => ( ))} {result?.rows.map((r, i) => ( {ds.dimensions.map((d) => ( ))} ))}
来源行 期间{d.name}收入 成本 毛利
{String(r.source_row)} {String(r.period)} {String( (r.dimensions as Record)[d.key] || "", )} {String(r.revenue)} {String(r.cost ?? "")} {String(r.gross ?? "")}
{result ? `第 ${first}-${last} 条,共 ${result.total} 条` : error ? "加载失败" : "加载中"}
); } export function AIConfigDialog({ onClose, onSaved, }: { onClose: () => void; onSaved: (config: AIConfig) => void; }) { const [value, setValue] = useState({ enabled: false, provider: "local", base_url: "", model: "", api_key_set: false, updated_at: null, }), [savedConfig, setSavedConfig] = useState(null), [key, setKey] = useState(""), [models, setModels] = useState([]), [error, setError] = useState(""), [message, setMessage] = useState(""), [modelsError, setModelsError] = useState(""), [loading, setLoading] = useState(true), [busy, setBusy] = useState(false), [listing, setListing] = useState(false); const modelRequest = useRef(0); const connectionChanged = Boolean( savedConfig && (value.provider !== savedConfig.provider || value.base_url !== savedConfig.base_url || key), ); const availableModels = [ ...new Set([savedConfig?.model || "", ...models]), ].filter(Boolean); const canListModels = Boolean( savedConfig?.updated_at && (savedConfig.base_url || savedConfig.provider === "openai"), ); useEffect(() => { let active = true; api("/ai-config") .then((r) => { if (!active) return; setValue(r); setSavedConfig(r); if (r.updated_at && (r.base_url || r.provider === "openai")) void fetchModels(); }) .catch((e) => { if (active) setError(`加载配置失败:${(e as Error).message},请关闭后重试。`); }) .finally(() => { if (active) setLoading(false); }); return () => { active = false; modelRequest.current++; }; }, []); function updateValue(patch: Partial) { setValue((v) => ({ ...v, ...patch })); setMessage(""); setError(""); } async function fetchModels() { const ticket = ++modelRequest.current; setListing(true); setModelsError(""); try { const r = await api<{ items: string[] }>("/ai-models"); if (ticket !== modelRequest.current) return; setModels(r.items); } catch (e) { if (ticket === modelRequest.current) setModelsError( `获取模型列表失败:${(e as Error).message}。已保存的配置不受影响,可重试或手动填写模型名。`, ); } finally { if (ticket === modelRequest.current) setListing(false); } } async function save() { setBusy(true); setError(""); setMessage(""); try { const config = await api("/ai-config", "PUT", { enabled: value.enabled, provider: value.provider, base_url: value.base_url, model: value.model, api_key: key, }); setValue(config); setSavedConfig(config); setKey(""); setMessage("配置已保存"); onSaved(config); modelRequest.current++; setListing(false); setModelsError(""); if (connectionChanged) setModels([]); if (config.base_url || config.provider === "openai") void fetchModels(); } catch (e) { setError(`保存失败:${(e as Error).message}`); } finally { setBusy(false); } } return (

配置财务分析助手使用的模型服务。数据库配置优先;未配置时回退到部署环境变量。

{loading &&

正在加载已保存配置…

}
{savedConfig && ( <>

已保存的配置

{savedConfig.updated_at ? ( <>

{savedConfig.enabled ? "当前使用的模型:" : "已保存的模型:"} {savedConfig.model || "尚未选择"} {savedConfig.enabled ? "已启用" : "未启用"}

{savedConfig.provider === "openai" ? "OpenAI" : "OpenAI 兼容服务"} {" · "} {savedConfig.base_url || (savedConfig.provider === "openai" ? "https://api.openai.com/v1" : "未设置地址")} {" · "}API Key{" "} {savedConfig.api_key_set ? "已配置" : "未配置"}

) : (

尚未保存配置。请先保存服务地址和 API Key,再选择模型并启用。

)}

可选模型

选择后点击“保存配置”生效;也可以在上方手动填写模型名。

{connectionChanged ? (

服务配置已修改,请先保存后获取可选模型。

) : ( <> {listing &&

正在获取可用模型…

} {modelsError && (

{modelsError}

)} {!listing && !modelsError && !models.length && canListModels && (

该服务没有返回可用模型,可重试或手动填写模型名。

)} {savedConfig.updated_at && !canListModels && (

填写并保存 Base URL 后可获取模型列表。

)}
{availableModels.map((model) => ( ))}
)} {value.model !== savedConfig.model && (

待保存的模型:{value.model || "尚未选择"}

)}
)}

只向模型服务发送问题与维度/当前筛选,原始数据行不出服务器;密钥仅保存在后端, 配置修改会记入操作审计。

{message && (

{message}

)} {error && (

{error}

)}
); } export function Copilot({ ds, ctx, ai, canDesign, onClose, onApply, onColumnsApplied, }: { ds: Dataset; ctx: Context; ai: boolean; canDesign: boolean; onClose: () => void; onApply: (reply: AssistantReply) => void; onColumnsApplied: (columns: FormulaColumn[]) => void; }) { const [question, setQuestion] = useState(""), [consent, setConsent] = useState(false), [useModel, setUseModel] = useState(false), [mode, setMode] = useState("auto"), [messages, setMessages] = useState([]), [inFlight, setInFlight] = useState(""), [design, setDesign] = useState(null), [notice, setNotice] = useState(""), [error, setError] = useState(""), [busy, setBusy] = useState(false), [saving, setSaving] = useState(false); const epoch = useRef(0); const savingRef = useRef(false); const scopeRef = useRef(null); const listRef = useRef(null); const inputRef = useRef(null); const currentScope = scopeKeyOf(ds, ctx); const completedTurns = messages.filter((item) => item.role === "user").length; const limitReached = completedTurns >= 6; const canSend = !busy && !saving && !!question.trim() && !limitReached && (!useModel || consent) && (mode !== "column_design" || (useModel && consent)); useEffect(() => { if (scopeRef.current === currentScope) return; const initial = scopeRef.current === null; scopeRef.current = currentScope; if (initial) return; // 数据/范围/映射版本变化:历史与待确认定义立即失效,迟到响应按 epoch 丢弃。 epoch.current += 1; savingRef.current = false; setBusy(false); setSaving(false); setInFlight(""); setMessages([]); setDesign(null); setMode("auto"); setError(""); setNotice("数据范围已变更,已开始新的对话。"); }, [currentScope]); useEffect(() => { const list = listRef.current; if (list) list.scrollTop = list.scrollHeight; }, [messages, inFlight, design, notice, error]); async function submit(event: React.FormEvent) { event.preventDefault(); const text = question.trim(); if (!canSend) return; const nextMode: AssistantMode = useModel ? mode : "auto"; const history = completedHistory(messages); const ticket = ++epoch.current; setBusy(true); setError(""); setNotice(""); // 新消息立即让旧预览失效。 setDesign(null); setInFlight(text); try { const reply = await api( `/datasets/${ds.id}/assistant`, "POST", { question: text, context: ctx, use_model: useModel, consent, history, mode: nextMode, }, ); if (ticket !== epoch.current) return; const assistantContent = replyContent(reply); // 完整列定义进入后续对话历史,便于“继续修改”沿用上一版口径。 const historyContent = reply.kind === "column_design" && reply.design?.column ? `${assistantContent}\n列定义:${JSON.stringify(reply.design.column)}` : undefined; setMessages((items) => [ ...items, { role: "user", content: text }, { role: "assistant", content: assistantContent, history: historyContent, source: reply.source, model: reply.model, }, ]); if (reply.kind === "column_design") { setMode("column_design"); setDesign(reply.design || null); if (!reply.design) setNotice("列设计未返回可用预览,请补充描述后重试。"); } else { setMode(nextMode === "column_design" ? "auto" : mode); setDesign(null); } setQuestion((current) => (current === text ? "" : current)); if ( navigationActions.has(reply.intent?.action) && reply.context && scopeKeyOf(ds, reply.context) !== currentScope ) { // 本轮由助手主动更新范围,保留刚生成的回答;页面变化由 App 应用。 scopeRef.current = scopeKeyOf(ds, reply.context); } onApply(reply); } catch (e) { if (ticket === epoch.current) setError((e as Error).message); } finally { if (ticket === epoch.current) { setBusy(false); setInFlight(""); } } } function newConversation() { epoch.current += 1; savingRef.current = false; setMessages([]); setDesign(null); setInFlight(""); setMode("auto"); setBusy(false); setSaving(false); setError(""); setNotice("已开始新对话。"); } function exitDesign() { setMode("auto"); setDesign(null); setNotice("已退出列设计,可继续普通问答。"); inputRef.current?.focus(); } function continueEditing() { setDesign(null); setNotice("请补充或修改列定义后重新发送。"); inputRef.current?.focus(); } async function confirmDesign() { const column = design?.complete ? design.column : null; if (!column || !canDesign || savingRef.current || busy) return; const nameKey = column.name.trim().toLowerCase(); const columns = ctx.custom_columns || []; const position = columns.findIndex( (item) => item.name.trim().toLowerCase() === nameKey, ); if (position < 0 && columns.length >= 10) { setError("当前已有 10 个分析列,请先移除一列后再添加。"); return; } const ticket = epoch.current; savingRef.current = true; setSaving(true); setError(""); setNotice(""); try { let definitions: SavedFormulaDefinition[]; try { definitions = ( await api<{ items: SavedFormulaDefinition[] }>( `/datasets/${ds.id}/formula-definitions`, ) ).items; } catch (e) { if (ticket === epoch.current) setError(`无法读取已保存定义:${(e as Error).message}`); return; } if (ticket !== epoch.current) return; const duplicates = definitions.filter( (item) => item.definition.name.trim().toLowerCase() === nameKey, ); const shared = duplicates.find((item) => !item.owned); if (shared) { if (ticket === epoch.current) setError( `已存在由 ${shared.owner} 共享的同名列「${shared.definition.name}」,请先更换列名。`, ); return; } const owned = duplicates.find((item) => item.owned); const saved = await api( `/datasets/${ds.id}/formula-definitions`, "POST", { definition: column, ...(owned ? { revision: owned.revision } : {}), }, ); if (ticket !== epoch.current) return; const latest = ctx.custom_columns || []; const index = latest.findIndex( (item) => item.name.trim().toLowerCase() === nameKey, ); if (index < 0 && latest.length >= 10) { setError( `已保存定义「${saved.definition.name}」,但当前分析列已达 10 列,请先移除一列后再确认。`, ); return; } const next = index >= 0 ? latest.map((item, i) => (i === index ? saved.definition : item)) : [...latest, saved.definition]; onColumnsApplied(next); setDesign(null); setMode("auto"); setNotice(`已添加分析列:${saved.definition.name},分析结果已刷新。`); } catch (e) { if (ticket === epoch.current) setError(`保存定义失败:${(e as Error).message}`); } finally { if (ticket === epoch.current) { savingRef.current = false; setSaving(false); } } } return (