feat(ui): Copilot 多轮对话、列定义预览确认与来源标识

This commit is contained in:
Jayway Liu
2026-11-01 04:44:48 +00:00
committed by jianwel
parent fb9dd2b77e
commit 661f65c593
6 changed files with 910 additions and 43 deletions
+33 -6
View File
@@ -48,6 +48,7 @@ import {
labels,
roleLabels,
type Analysis,
type AssistantReply,
type Context,
type Dataset,
type FormulaColumn,
@@ -514,6 +515,9 @@ export function App() {
if (!session.user) return <Login onLogin={loadSession} />;
const user = session.user;
const canUpload = user.role !== "viewer" && !user.departments.length;
const canDesign =
(user.role === "admin" || user.role === "analyst") &&
!user.departments.length;
return (
<div className="app">
<aside className={`sidebar ${mobileNav ? "open" : ""}`}>
@@ -1428,15 +1432,29 @@ export function App() {
ds={ds}
ctx={ctx}
ai={session.ai_enabled}
canDesign={canDesign}
onClose={() => setChat(false)}
onApply={(reply) => {
setCtx(reply.context);
onApply={(reply: AssistantReply) => {
// 普通回答与列设计不改变页面;只有显式查询动作才导航。
if (reply.kind === "column_design" || !reply.context) return;
if (
!["overview", "anomalies", "drill", "evidence"].includes(
reply.intent?.action,
)
)
return;
const next: Context = {
...reply.context,
custom_columns:
reply.context?.custom_columns ?? ctx.custom_columns,
};
setCtx(next);
setPath(
Object.entries(reply.context.filters).map(
Object.entries(next.filters).map(
([key, value], index, entries) => ({
label: value,
ctx: {
...reply.context,
...next,
group: key,
filters: Object.fromEntries(entries.slice(0, index)),
},
@@ -1450,8 +1468,17 @@ export function App() {
? "anomalies"
: "investigate",
);
if (reply.intent.action === "evidence")
setEvidenceCtx(reply.context);
if (reply.intent.action === "evidence") setEvidenceCtx(next);
}}
onColumnsApplied={(columns: FormulaColumn[]) => {
setCtx((current) =>
current ? { ...current, custom_columns: columns } : current,
);
api<{ items: SavedFormulaDefinition[] }>(
`/datasets/${ds.id}/formula-definitions`,
)
.then((data) => setFormulaDefinitions(data.items))
.catch(report);
}}
/>
)}
+438 -31
View File
@@ -12,6 +12,10 @@ import {
labels,
roleLabels,
type AIConfig,
type AssistantDesign,
type AssistantMessage,
type AssistantMode,
type AssistantReply,
type Context,
type Dataset,
type FormulaColumn,
@@ -24,6 +28,67 @@ import {
type User = NonNullable<Session["user"]>;
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);
@@ -796,57 +861,374 @@ export function Copilot({
ds,
ctx,
ai,
canDesign,
onClose,
onApply,
onColumnsApplied,
}: {
ds: Dataset;
ctx: Context;
ai: boolean;
canDesign: boolean;
onClose: () => void;
onApply: (reply: { context: Context; intent: { action: string } }) => void;
onApply: (reply: AssistantReply) => void;
onColumnsApplied: (columns: FormulaColumn[]) => void;
}) {
const [question, setQuestion] = useState(""),
[consent, setConsent] = useState(false),
[useModel, setUseModel] = useState(false),
[answer, setAnswer] = useState(""),
[mode, setMode] = useState<AssistantMode>("auto"),
[messages, setMessages] = useState<ChatMessage[]>([]),
[inFlight, setInFlight] = useState<string>(""),
[design, setDesign] = useState<AssistantDesign | null>(null),
[notice, setNotice] = useState(""),
[error, setError] = useState(""),
[busy, setBusy] = useState(false);
[busy, setBusy] = useState(false),
[saving, setSaving] = useState(false);
const epoch = useRef(0);
const savingRef = useRef(false);
const scopeRef = useRef<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(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 r = await api<{
answer: string;
context: Context;
intent: { action: string };
}>(`/datasets/${ds.id}/assistant`, "POST", {
question,
context: ctx,
use_model: useModel,
consent,
});
setAnswer(r.answer);
onApply(r);
const reply = await api<AssistantReply>(
`/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) {
setError((e as Error).message);
if (ticket === epoch.current) setError((e as Error).message);
} finally {
setBusy(false);
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<SavedFormulaDefinition>(
`/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 (
<aside className="copilot">
<aside className="copilot" aria-label="财务分析助手">
<header>
<strong>财务分析助手</strong>
<button className="icon" aria-label="关闭" onClick={onClose}>
<X />
</button>
<div className="copilot-title">
<strong>财务分析助手</strong>
<small>
{mode === "column_design" ? "列设计模式" : "普通问答"} · 已完成{" "}
{completedTurns}/6 轮
</small>
</div>
<div className="copilot-actions">
<button className="ghost" onClick={newConversation}>
新对话
</button>
{mode === "column_design" && (
<button className="ghost" disabled={saving} onClick={exitDesign}>
退出列设计
</button>
)}
<button className="icon" aria-label="关闭" onClick={onClose}>
<X />
</button>
</div>
</header>
<div className="context-tag">
当前范围:{Object.values(ctx.filters).join(" / ") || "全部"} · {ds.name}
</div>
<div className="chat-messages" aria-live="polite">
{answer && <p className="message">{answer}</p>}
<div className="chat-messages" aria-live="polite" ref={listRef}>
{!messages.length && !inFlight && !design && (
<p className="muted">
可以询问当前范围的经营问题,或描述要新增的分析列。
</p>
)}
{messages.map((message, index) => (
<div
key={index}
className={message.role === "user" ? "message-user" : "message assistant"}
>
<strong>{message.role === "user" ? "我" : "助手"}</strong>
<p className={message.role === "user" ? "question" : undefined}>
{message.content}
</p>
{message.role === "assistant" && message.source && (
<span className="message-source">
来源:
{message.source === "model"
? `模型回答 · ${message.model || "未命名模型"}`
: "规则摘要"}
</span>
)}
</div>
))}
{inFlight && (
<div className="message-user pending">
<strong>我</strong>
<p className="question">{inFlight}</p>
<span className="message-source">发送中…</span>
</div>
)}
{design && (
<section className="design-preview" aria-label="列定义预览">
{design.complete && design.column ? (
<>
<h3>{design.column.name}</h3>
<p>{design.column.meaning}</p>
<dl>
<dt>计算表达式</dt>
<dd>
{describeExpression(
design.column.expression as FormulaExpression,
)}
</dd>
<dt>适用范围</dt>
<dd>{design.column.scope}</dd>
<dt>缺失/零分母</dt>
<dd>{design.column.missing}</dd>
</dl>
<div className="design-actions">
{canDesign ? (
<button
className="primary"
disabled={saving || busy}
onClick={confirmDesign}
>
{saving ? "添加中…" : "确认并添加分析列"}
</button>
) : (
<p className="muted">
当前账号无列设计权限,无法添加分析列。
</p>
)}
<button disabled={saving || busy} onClick={continueEditing}>
继续修改
</button>
</div>
{saving && (
<p role="status" className="muted">
正在保存定义并刷新分析结果…
</p>
)}
</>
) : (
<>
<h3>列设计待补充</h3>
{design.questions.length ? (
<ul>
{design.questions.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
) : (
<p>请补充列名、计算含义和适用范围。</p>
)}
<button disabled={saving || busy} onClick={exitDesign}>
退出列设计
</button>
</>
)}
</section>
)}
{notice && (
<p role="status" className="notice">
{notice}
</p>
)}
{error && (
<p role="alert" className="error">
{error}
@@ -857,10 +1239,17 @@ export function Copilot({
<label>
描述要分析的问题
<textarea
ref={inputRef}
required
maxLength={2000}
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
</label>
{ai && (
@@ -869,7 +1258,14 @@ export function Copilot({
<input
type="checkbox"
checked={useModel}
onChange={(e) => setUseModel(e.target.checked)}
disabled={busy || saving}
onChange={(e) => {
setUseModel(e.target.checked);
if (!e.target.checked) {
setMode("auto");
setDesign(null);
}
}}
/>
使用已配置的 AI 模型理解问题
</label>
@@ -878,18 +1274,29 @@ export function Copilot({
<input
type="checkbox"
checked={consent}
disabled={busy || saving}
onChange={(e) => setConsent(e.target.checked)}
/>
同意发送问题和筛选上下文
同意将本次对话、当前筛选、指标定义和权限范围内的汇总分析结果发送到已配置的模型服务
</label>
)}
</>
)}
<button
className="primary"
disabled={busy || !question.trim() || (useModel && !consent)}
>
{busy ? "分析中…" : "发送"}
{busy && (
<p role="status" className="copilot-status">
正在分析…
</p>
)}
{limitReached && (
<p role="status" className="notice">
已达到 6 轮对话上限,请开始新对话。
</p>
)}
{mode === "column_design" && !(useModel && consent) && (
<p className="notice">列设计需要启用模型并同意发送。</p>
)}
<button className="primary" disabled={!canSend}>
{busy ? "发送中…" : "发送"}
</button>
</form>
</aside>
+140 -4
View File
@@ -1204,6 +1204,7 @@ dialog footer > span {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 17px 18px;
border-bottom: 1px solid var(--line);
}
@@ -1211,6 +1212,34 @@ dialog footer > span {
display: block;
margin-top: 5px;
}
.copilot-title {
flex: 1;
min-width: 0;
}
.copilot-title strong {
display: block;
overflow-wrap: anywhere;
}
.copilot-title small {
color: var(--muted);
font-size: 10px;
line-height: 1.4;
}
.copilot-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 4px;
}
.copilot-actions .ghost {
min-height: 0;
padding: 4px 7px;
font-size: 10px;
}
.copilot-actions .icon {
min-width: 32px;
}
.chat-messages {
flex: 1;
overflow: auto;
@@ -1232,17 +1261,106 @@ dialog footer > span {
font-size: 12px;
margin-bottom: 7px;
}
.message {
.message,
.message-user {
padding-block: 10px;
border-top: 1px solid var(--line);
font-size: 12px;
line-height: 1.9;
}
.message p {
.message > strong,
.message-user > strong {
display: block;
margin-bottom: 5px;
font-size: 10px;
color: #7b9282;
}
.message.assistant > strong {
color: #1d584a;
}
.message-user.pending .question {
opacity: 0.7;
}
.message-source {
display: block;
margin-top: 6px;
font-size: 10px;
color: #7b9282;
overflow-wrap: anywhere;
}
.message-user.pending .message-source {
color: #9a7a2e;
}
.copilot-status {
margin: 0 0 8px;
font-size: 10px;
color: var(--muted);
}
.copilot .notice,
.copilot .error {
margin: 10px 0 0;
padding: 8px 10px;
font-size: 11px;
line-height: 1.6;
}
.design-preview {
margin-top: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: #f8faf9;
font-size: 12px;
line-height: 1.7;
}
.design-preview h3 {
margin: 0 0 6px;
font-size: 13px;
overflow-wrap: anywhere;
}
.design-preview p {
margin: 0 0 6px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.message .question {
.design-preview ul {
margin: 0 0 8px;
padding-left: 18px;
}
.design-preview li {
margin-bottom: 4px;
overflow-wrap: anywhere;
}
.design-preview dl {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 4px 10px;
margin: 8px 0 10px;
}
.design-preview dt {
color: #7b9282;
}
.design-preview dd {
margin: 0;
overflow-wrap: anywhere;
}
.design-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.design-actions button {
font-size: 11px;
white-space: normal;
text-align: center;
}
.message p,
.message-user p {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.message .question,
.message-user .question {
background: #e9f2ec;
padding: 10px 12px;
border-radius: 5px;
@@ -1258,8 +1376,17 @@ dialog footer > span {
border-top: 1px solid var(--line);
}
.copilot .check {
display: flex;
align-items: flex-start;
gap: 7px;
margin: 0 0 10px;
font-size: 10px;
margin-bottom: 10px;
line-height: 1.5;
white-space: normal;
}
.copilot .check input {
flex: none;
margin-top: 2px;
}
.chat-input {
display: flex;
@@ -1585,6 +1712,15 @@ details {
width: calc(100% - 20px);
height: calc(100dvh - 75px);
}
.copilot header {
padding: 12px 14px;
}
.copilot .chat-messages {
padding: 14px;
}
.design-actions button {
flex: 1 1 100%;
}
dialog,
.dialog.wide {
width: calc(100% - 16px);
+30
View File
@@ -125,6 +125,34 @@ export interface SavedFormulaDefinition {
owner: string;
history: { revision: number; definition: FormulaColumn; updated_at: string }[];
}
export interface AssistantMessage {
role: "user" | "assistant";
content: string;
}
export type AssistantMode = "auto" | "column_design";
export interface AssistantDesign {
complete: boolean;
questions: string[];
column: FormulaColumn | null;
}
export interface AssistantIntent {
action: string;
metric?: Metric;
group?: string;
filters?: Record<string, string>;
}
export interface AssistantReply {
answer: string;
intent: AssistantIntent;
context: Context;
version: string;
provider: string;
evidence?: unknown[];
kind: "answer" | "column_design";
source: "model" | "rules";
model: string | null;
design?: AssistantDesign;
}
export interface Value {
current: Numeric;
baseline: Numeric;
@@ -174,6 +202,8 @@ export interface Session {
departments: string[];
} | null;
ai_enabled: boolean;
ai_model?: string | null;
ai_provider?: "openai" | "local" | null;
}
export const format = (v: Numeric | undefined, metric?: string) =>
v == null