feat(ui): Copilot 多轮对话、列定义预览确认与来源标识
This commit is contained in:
+33
-6
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user