Merge AI-generated analysis and inventory drilldowns into main
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
shishengliang
2026-09-23 17:54:28 +08:00
50 changed files with 6739 additions and 1279 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,302 @@
import { useState } from "react";
import { ArrowUpRight, Check, Copy, RotateCcw } from "lucide-react";
import type {
AssistantAction,
AssistantResult,
} from "../../../shared/assets/assistant";
import { ResultView, type ResultViewProps } from "./ResultView";
import { GenerativeResultView } from "./GenerativeResultView";
import { copyResultText } from "./result-presentation";
export type CompoundResultViewProps = Omit<
ResultViewProps,
"ask" | "operate" | "activate" | "display"
> & {
activeResultId?: string;
correctionFor?: (
resultId: string,
) => { message?: string; resultId?: string } | undefined;
ask: (text: string, base: AssistantResult) => void;
operate: (
action: AssistantAction,
label: string,
base: AssistantResult,
) => void;
activate: (result: AssistantResult) => void;
};
export function CompoundResultView({
result,
activeResultId,
correctionFor,
ask,
operate,
activate,
...props
}: CompoundResultViewProps) {
const [selection, setSelection] = useState<{
sectionId: string;
observedActiveId?: string;
}>();
const [notice, setNotice] = useState("");
const sections = result.sections || [];
if (!sections.length) {
const Renderer = result.presentation ? GenerativeResultView : ResultView;
return (
<Renderer
{...props}
result={result}
ask={(text) => ask(text, result)}
operate={(action, label) => operate(action, label, result)}
activate={() => activate(result)}
/>
);
}
// A failed section has no server result ID. Keep explicit user selection while
// the active context is unchanged; follow a different context after inline updates.
const locallySelected = sections.find(
(section) => section.id === selection?.sectionId,
);
const externallyActive = activeResultId
? sections.find(
(section) =>
!!section.result?.resultId &&
section.result.resultId === activeResultId,
)
: undefined;
const selected =
(selection?.observedActiveId === activeResultId
? locallySelected
: externallyActive) ||
locallySelected ||
externallyActive ||
sections[0];
const selectedIndex = sections.indexOf(selected);
const stacked = result.layout === "stack";
const renderSection = (section: typeof selected) => {
const child = section.result;
const correction = child?.resultId
? correctionFor?.(child.resultId)
: undefined;
const Renderer = child?.presentation ? GenerativeResultView : ResultView;
return child ? (
<Renderer
{...props}
key={section.id}
result={child}
display={section.display}
active={activeResultId === child.resultId}
ask={(text) => ask(text, child)}
operate={(action, label) => operate(action, label, child)}
activate={() => activate(child)}
disabled={props.disabled || !!correction}
correctionMessage={correction?.message}
correctionResultId={correction?.resultId}
/>
) : (
<div className="aq-section-error" role="status">
<strong>本项未完成</strong>
<p>{section.error || "这项分析暂未返回结果,可以重新尝试。"}</p>
<button
disabled={props.disabled}
onClick={() => ask(section.question, result)}
>
<RotateCcw size={13} />
重新查询此项
</button>
</div>
);
};
const panelId = `aq-section-panel-${result.resultId || "current"}`;
const tabId = (sectionId: string) =>
`aq-section-tab-${result.resultId || "current"}-${sectionId}`;
const select = (section: typeof selected) => {
setSelection({ sectionId: section.id, observedActiveId: activeResultId });
if (section.result) activate(section.result);
};
return (
<section className="aq-compound-result" aria-label="多项分析结果">
<header
className="aq-compound-heading"
id={result.resultId ? `aq-query-${result.resultId}` : undefined}
>
<div>
<strong>{result.title || "综合分析"}</strong>
<span>{sections.length}项分析</span>
</div>
<p id={result.resultId ? `aq-answer-${result.resultId}` : undefined}>
{result.summary}
</p>
</header>
{props.correctionMessage && (
<div className="aq-correction" role="status">
<strong>此回答已撤回</strong>
<p>{props.correctionMessage}</p>
{props.correctionResultId && (
<button
onClick={() => props.showCorrection(props.correctionResultId!)}
>
查看纠正后的结果 <ArrowUpRight size={13} />
</button>
)}
</div>
)}
{result.correction && (
<p className="aq-compound-notice" role="status">
已纠正上一条回答:{result.correction.message}
</p>
)}
{result.outcome === "partial" && (
<p className="aq-compound-notice">
已返回可查结果,其余问题在下方单独说明。
</p>
)}
{!!result.unanswered?.length && (
<div className="aq-compound-unanswered">
<strong>尚未回答</strong>
{result.unanswered.map((item) => (
<p key={item.question}>
<span>{item.question}</span>:{item.reason}
</p>
))}
</div>
)}
{result.warning && (
<p className="aq-compound-notice" role="status">
{result.warning}
</p>
)}
{result.gap && (
<p className="aq-compound-notice">
{result.gap.recorded
? "未满足的需求已记录,可在待完善需求中查看。"
: "未满足的需求暂未保存。"}
</p>
)}
{!stacked && (
<div
className="aq-section-overview"
role="tablist"
aria-label="选择分析项目"
aria-orientation="vertical"
>
{sections.map((section, index) => {
const chosen = section.id === selected.id;
return (
<button
key={section.id}
id={tabId(section.id)}
role="tab"
aria-selected={chosen}
aria-controls={panelId}
tabIndex={chosen ? 0 : -1}
disabled={props.disabled}
onClick={() => select(section)}
onKeyDown={(event) => {
const direction =
event.key === "ArrowDown" || event.key === "ArrowRight"
? 1
: event.key === "ArrowUp" || event.key === "ArrowLeft"
? -1
: 0;
if (!direction && event.key !== "Home" && event.key !== "End")
return;
event.preventDefault();
const nextIndex =
event.key === "Home"
? 0
: event.key === "End"
? sections.length - 1
: (index + direction + sections.length) %
sections.length;
select(sections[nextIndex]);
document
.getElementById(tabId(sections[nextIndex].id))
?.focus();
}}
>
<span className="aq-section-number">{index + 1}</span>
<span className="aq-section-preview">
<strong>{section.title}</strong>
<span>
{section.result?.summary || section.error || "本项暂无结果"}
</span>
</span>
{chosen ? <Check size={14} /> : <ArrowUpRight size={14} />}
</button>
);
})}
</div>
)}
<div className="aq-compound-toolbar">
<span>
{stacked ? (
`共 ${sections.length} 项分析 · 点击任一项继续追问`
) : (
<>
当前项目 {selectedIndex + 1}/{sections.length} ·{" "}
<strong>{selected.title}</strong>
</>
)}
</span>
<button
onClick={() => {
void navigator.clipboard
.writeText(
copyResultText(result, props.subject, props.correctionMessage),
)
.then(() => setNotice("已复制全部分析项目、范围和口径。"))
.catch(() => setNotice("复制失败,请稍后重试。"));
}}
>
<Copy size={13} />
复制整份回答
</button>
</div>
{notice && (
<p className="aq-compound-notice" role="status">
{notice}
</p>
)}
{stacked ? (
<div className="aq-section-stack">
{sections.map((section, index) => (
<section
key={section.id}
className={`aq-stacked-section${!!section.result?.resultId && section.result.resultId === activeResultId ? " is-active" : ""}`}
aria-label={section.title}
data-section-id={section.id}
onClickCapture={() => {
if (
section.result &&
!props.disabled &&
!correctionFor?.(section.result.resultId || "")
)
activate(section.result);
}}
>
<header className="aq-stacked-heading">
<span className="aq-section-number">{index + 1}</span>
<strong>{section.title}</strong>
{!!section.result?.resultId &&
section.result.resultId === activeResultId && (
<span className="aq-stacked-active">当前追问范围</span>
)}
</header>
<div className="aq-section-panel">{renderSection(section)}</div>
</section>
))}
</div>
) : (
<div
className="aq-section-panel"
role="tabpanel"
id={panelId}
aria-labelledby={tabId(selected.id)}
>
{renderSection(selected)}
</div>
)}
</section>
);
}
@@ -0,0 +1,682 @@
import { memo, useState } from "react";
import {
ArrowDownToLine,
ArrowUpRight,
ChevronLeft,
ChevronRight,
Copy,
} from "lucide-react";
import {
ResponsiveContainer,
BarChart,
Bar,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
Legend,
PieChart,
Pie,
Cell,
LineChart,
Line,
} from "recharts";
import type {
AssistantResult,
PresentationBlock,
PresentationInteraction,
} from "../../../shared/assets/assistant";
import { fetchAuthenticated } from "../../../auth/api-client";
import type { ResultViewProps } from "./ResultView";
import {
compactAnalysisPath,
copyResultText,
displayTime,
queryPath,
resultScope,
} from "./result-presentation";
import {
presentationAction,
presentationData,
presentationFields,
presentationMetrics,
presentationSeries,
presentationValue,
type PresentationDatum,
} from "./generative-presentation";
const COLORS = [
"#4e806a",
"#527da5",
"#b18439",
"#8870a4",
"#39948e",
"#ae6970",
];
const datumName = (row: PresentationDatum) =>
String(row.name ?? row.date ?? row.label ?? row.plate ?? "");
type DataBlockProps = {
block: PresentationBlock;
result: AssistantResult;
interaction?: PresentationInteraction;
operate: ResultViewProps["operate"];
disabled: boolean;
};
function DataTable({
block,
result,
interaction,
operate,
disabled,
}: DataBlockProps) {
const [page, setPage] = useState(0);
const data = presentationData(result, block.dataset);
const columns = presentationFields(block);
const currentPage = Math.min(
page,
Math.max(0, Math.ceil(data.length / 8) - 1),
);
return (
<>
<div className="aq-table-scroll">
<table>
<caption className="aq-sr-only">{block.title || "分析数据"}</caption>
<thead>
<tr>
{columns.map((column) => (
<th key={column.field} scope="col">
{column.label}
</th>
))}
{interaction && <th scope="col">继续分析</th>}
</tr>
</thead>
<tbody>
{data
.slice(currentPage * 8, currentPage * 8 + 8)
.map((row, index) => {
const action = presentationAction(interaction, row);
return (
<tr
key={String(
row.id ?? row.name ?? row.date ?? row.label ?? index,
)}
>
{columns.map((column) => (
<td key={column.field}>
{presentationValue(row[column.field], column.field)}
</td>
))}
{interaction && (
<td>
{action ? (
<button
disabled={disabled}
onClick={() =>
operate(
action,
`${datumName(row)} · ${interaction.label}`,
)
}
>
{interaction.label}
<ArrowUpRight size={12} />
</button>
) : (
"—"
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
{data.length > 8 && (
<div className="aq-generated-pagination">
<span>
第 {currentPage * 8 + 1}–
{Math.min(data.length, currentPage * 8 + 8)} 条,共 {data.length} 条
</span>
<button
aria-label="上一页"
disabled={currentPage === 0}
onClick={() => setPage(currentPage - 1)}
>
<ChevronLeft size={15} />
</button>
<button
aria-label="下一页"
disabled={(currentPage + 1) * 8 >= data.length}
onClick={() => setPage(currentPage + 1)}
>
<ChevronRight size={15} />
</button>
</div>
)}
</>
);
}
function DataChart(props: DataBlockProps) {
const { block, result, interaction, operate, disabled } = props;
const [expanded, setExpanded] = useState(false);
const allData = presentationData(result, block.dataset);
const data =
expanded || block.chart === "line" || block.chart === "donut"
? allData
: allData.slice(0, 5);
const series = presentationSeries(block);
const labelField =
block.dataset === "daily"
? "date"
: block.dataset === "comparison"
? "label"
: "name";
const explore = (datum: PresentationDatum) => {
const action = presentationAction(interaction, datum);
if (action && !disabled)
operate(action, `${datumName(datum)} · ${interaction!.label}`);
};
const clickChart = (state: {
activeTooltipIndex?: string | number | null;
}) => {
const index = Number(state.activeTooltipIndex);
if (state.activeTooltipIndex != null && data[index]) explore(data[index]);
};
if (!series.length) return <DataTable {...props} />;
const common = { data, margin: { top: 8, right: 16, bottom: 0, left: 0 } };
return (
<>
<div
className="aq-generated-chart"
aria-label={`${block.title || "数据图表"},${allData.length}项数据`}
>
<ResponsiveContainer width="100%" height="100%">
{block.chart === "donut" ? (
<PieChart>
<Pie
data={data}
dataKey={series[0].field}
nameKey={labelField}
innerRadius="48%"
outerRadius="76%"
paddingAngle={2}
isAnimationActive={false}
onClick={(entry) => explore(entry.payload as PresentationDatum)}
>
{data.map((_, index) => (
<Cell key={index} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
formatter={(value) => presentationValue(value, series[0].field)}
/>
<Legend wrapperStyle={{ maxHeight: 54, overflowY: "auto" }} />
</PieChart>
) : block.chart === "line" ? (
<LineChart {...common} onClick={clickChart}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey={labelField}
tick={{ fontSize: 11 }}
minTickGap={25}
/>
<YAxis width={42} tick={{ fontSize: 11 }} allowDecimals={series.some(item => ["rate", "share"].includes(item.field))} />
<Tooltip />
<Legend />
{series.map((item, index) => (
<Line
key={item.field}
type="monotone"
dataKey={item.field}
name={item.label}
stroke={COLORS[index % COLORS.length]}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
isAnimationActive={false}
/>
))}
</LineChart>
) : (
<BarChart {...common} onClick={clickChart}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey={labelField}
tick={{ fontSize: 11 }}
interval={0}
/>
<YAxis width={42} tick={{ fontSize: 11 }} allowDecimals={series.some(item => ["rate", "share"].includes(item.field))} />
<Tooltip />
<Legend />
{series.map((item, index) => (
<Bar
key={item.field}
dataKey={item.field}
name={item.label}
fill={COLORS[index % COLORS.length]}
radius={[3, 3, 0, 0]}
maxBarSize={42}
isAnimationActive={false}
/>
))}
</BarChart>
)}
</ResponsiveContainer>
</div>
{interaction && (
<div
className="aq-generated-drill-targets"
aria-label={interaction.label}
>
{data.map((row, index) => (
<button
key={index}
disabled={disabled || !presentationAction(interaction, row)}
onClick={() => explore(row)}
>
{datumName(row)}
<ArrowUpRight size={12} />
</button>
))}
</div>
)}
{allData.length > 5 &&
block.chart !== "line" &&
block.chart !== "donut" && (
<button
className="aq-generated-expand"
onClick={() => setExpanded(!expanded)}
>
{expanded
? "收起分组"
: `当前展示前5项 · 查看全部${allData.length}项`}
</button>
)}
<details className="aq-generated-data">
<summary>查看图表数据</summary>
<DataTable
{...props}
block={{
...block,
columns: block.columns || [
{
field: labelField,
label: block.dataset === "daily" ? "日期" : "项目",
},
...series,
],
}}
/>
</details>
</>
);
}
function Block({
block,
result,
operate,
disabled,
}: Omit<DataBlockProps, "interaction">) {
const interaction = result.presentation?.interactions.find(
(item) => item.id === block.interactionId,
);
const data = presentationData(result, block.dataset);
return (
<section
className={`aq-generated-block aq-generated-block-${block.kind}`}
data-block-id={block.id}
>
{block.title && <h4>{block.title}</h4>}
{block.kind === "summary" ? (
<p
className="aq-answer"
id={
result.resultId &&
result.presentation?.blocks.find((item) => item.kind === "summary")
?.id === block.id
? `aq-answer-${result.resultId}`
: undefined
}
>
{block.text || result.summary}
</p>
) : block.kind === "narrative" ? (
<p className="aq-generated-narrative">{block.text}</p>
) : block.kind === "metrics" ? (
<div className="aq-generated-metrics">
{presentationMetrics(result, block.metrics).map((metric) => (
<div key={metric.label}>
<span>{metric.label}</span>
<strong>
{metric.value.toLocaleString()}
<small>{metric.unit}</small>
</strong>
</div>
))}
</div>
) : (
<>
{interaction && (
<p className="aq-generated-interaction">
点击{block.dataset === "daily" ? "日期" : "数据项"} →{" "}
{interaction.label}
</p>
)}
{data.length ? (
block.kind === "chart" ? (
<DataChart
block={block}
result={result}
interaction={interaction}
operate={operate}
disabled={disabled}
/>
) : (
<DataTable
block={block}
result={result}
interaction={interaction}
operate={operate}
disabled={disabled}
/>
)
) : (
<p className="aq-generated-empty">当前范围暂无匹配数据。</p>
)}
</>
)}
</section>
);
}
function GenerativeResultViewContent({
result,
operate,
restore,
active,
activate,
subject,
disabled,
correctionMessage,
correctionResultId,
showCorrection,
}: ResultViewProps) {
const [noticeByResult, setNoticeByResult] = useState<{
key: string;
text: string;
}>();
const [saving, setSaving] = useState(false);
const [flaggedResult, setFlaggedResult] = useState<string>();
const resultKey = result.resultId || result.fetchedAt;
const notice = noticeByResult?.key === resultKey ? noticeByResult.text : "";
const flagged = flaggedResult === resultKey;
const setNotice = (text: string) =>
setNoticeByResult({ key: resultKey, text });
const scope = resultScope(result, subject);
const path = queryPath(result.plan);
const breadcrumbs = compactAnalysisPath(result.breadcrumbs);
const currentLabel = result.breadcrumbs?.find(
(item) => item.resultId === result.resultId,
)?.label;
const parent = breadcrumbs
.filter(
(item) =>
item.resultId !== result.resultId && item.label !== currentLabel,
)
.at(-1);
const presentation = result.presentation!;
const followups = presentation.interactions.filter(
(item) => item.kind === "followup",
);
const save = async (kind: "feedback" | "export") => {
setSaving(true);
setNotice("");
try {
const response = await fetchAuthenticated(
`/api/asset-assistant/${kind}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: result.sessionId,
resultId: result.resultId,
subject,
}),
},
);
if (!response.ok) throw new Error();
if (kind === "feedback") {
setFlaggedResult(resultKey);
setNotice("已记录,我们会据此完善问答能力。");
} else {
const url = URL.createObjectURL(await response.blob());
const link = document.createElement("a");
link.href = url;
link.download = "资产分析明细.csv";
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
setNotice("已按当前范围导出明细。");
}
} catch {
setNotice(
kind === "feedback"
? "需求未保存,请稍后重试。"
: "导出失败,请重新查询后重试。",
);
} finally {
setSaving(false);
}
};
return (
<div className="aq-result aq-generative-result">
<header
className="aq-query-heading"
id={result.resultId ? `aq-query-${result.resultId}` : undefined}
>
<div className="aq-current-path" aria-label="当前查询范围">
{path.map((item, index) => (
<span
key={index}
className={
index === path.length - 1 ? "aq-path-current" : undefined
}
>
{item}
</span>
))}
</div>
<div className="aq-query-navigation">
<span>
{scope.company}
{result.plan.kind === "flow" ? ` · ${scope.period}` : ""}
</span>
{parent && (
<button
disabled={disabled}
onClick={() => restore(parent.resultId)}
>
<ChevronLeft size={13} />
返回上层
</button>
)}
{breadcrumbs.length > 1 && (
<details className="aq-path-history">
<summary>分析路径</summary>
<nav>
{breadcrumbs.map((item) => (
<button
key={item.resultId}
disabled={disabled || item.resultId === result.resultId}
onClick={() => restore(item.resultId)}
>
{item.label}
</button>
))}
</nav>
</details>
)}
</div>
</header>
{correctionMessage && (
<div className="aq-correction" role="status">
<strong>此回答已撤回</strong>
<p>{correctionMessage}</p>
{correctionResultId && (
<button onClick={() => showCorrection(correctionResultId)}>
查看纠正后的结果
<ArrowUpRight size={13} />
</button>
)}
</div>
)}
{result.correction && (
<p className="aq-generated-notice">
已纠正上一条回答:{result.correction.message}
</p>
)}
{presentation.title && (
<h3 className="aq-generated-title">{presentation.title}</h3>
)}
{result.outcome === "empty" && (
<p className="aq-generated-notice">
当前条件下无匹配数据,可调整范围后继续查询。
</p>
)}
{result.outcome === "partial" && (
<p className="aq-generated-notice">
已返回可查结果,其余问题单独说明。
</p>
)}
{result.outcome === "unsupported" && (
<p className="aq-generated-notice">本次需求暂不能完整生成。</p>
)}
{presentation.blocks.map((block) => (
<Block
key={block.id}
block={block}
result={result}
operate={operate}
disabled={disabled || !!correctionMessage}
/>
))}
{!!result.unanswered?.length && (
<div className="aq-compound-unanswered">
<strong>尚未回答</strong>
{result.unanswered.map((item) => (
<p key={item.question}>
{item.question}:{item.reason}
</p>
))}
</div>
)}
{result.warning && (
<p className="aq-generated-notice" role="status">
{result.warning}
</p>
)}
{result.presentationWarning && (
<p className="aq-generated-notice" role="status">
{result.presentationWarning}
</p>
)}
{result.gap && (
<p className="aq-generated-notice">
{result.gap.recorded
? "未满足的需求已记录。"
: "未满足的需求暂未保存。"}
</p>
)}
<footer className="aq-result-footer">
<details className="aq-result-definitions">
<summary>统计口径、权限与数据时间</summary>
{!presentation.blocks.some((block) => block.kind === "summary") && (
<p>{result.summary}</p>
)}
<p>
公司:{scope.company} · 权限:{scope.permission}
</p>
<p>生效条件:{scope.filters.join(" · ") || "无额外筛选"}</p>
<p>
分组:{scope.grouping} · 期间:{scope.period}
</p>
<p>
源数据更新时间:{displayTime(scope.sourceUpdatedAt)} · 查询时间:
{displayTime(result.fetchedAt)}
</p>
{scope.definitions.map((item) => (
<p key={item}>{item}</p>
))}
{result.contextNote && <p>{result.contextNote}</p>}
<p>来源:{result.source}</p>
</details>
<div className="aq-answer-actions">
<button
disabled={disabled || saving}
onClick={() => void save("export")}
>
<ArrowDownToLine size={14} />
导出明细
</button>
<button
disabled={disabled || active || !!correctionMessage}
onClick={activate}
>
{active ? "正在基于此结果追问" : "基于此结果追问"}
</button>
<button
disabled={saving || flagged}
onClick={() => void save("feedback")}
>
{flagged ? "已记录" : "未解决我的问题"}
</button>
<button
onClick={() => {
void navigator.clipboard
.writeText(copyResultText(result, subject, correctionMessage))
.then(() => setNotice("已复制完整结果与统计口径。"))
.catch(() => setNotice("复制失败,请手动选择文字。"));
}}
>
<Copy size={14} />
复制完整结果
</button>
</div>
{notice && (
<p className="aq-notice" role="status">
{notice}
</p>
)}
</footer>
{!!followups.length && (
<div className="aq-followups">
<span>继续分析</span>
{followups.map((item) => (
<button
key={item.id}
disabled={disabled || !!correctionMessage}
onClick={() =>
operate({ type: "explore", interactionId: item.id }, item.label)
}
>
{item.label}
<ArrowUpRight size={13} />
</button>
))}
</div>
)}
</div>
);
}
export const GenerativeResultView = memo(
GenerativeResultViewContent,
(previous, next) =>
previous.result === next.result &&
previous.active === next.active &&
previous.subject === next.subject &&
previous.disabled === next.disabled &&
previous.correctionMessage === next.correctionMessage &&
previous.correctionResultId === next.correctionResultId,
);
File diff suppressed because it is too large Load Diff
+20 -4
View File
@@ -8,11 +8,15 @@ export function ThinkingState({
working,
started,
failed,
stopped,
outcome,
}: {
steps: string[];
working: boolean;
started: number;
failed?: boolean;
stopped?: boolean;
outcome?: string;
}) {
const [manualExpanded, setExpanded] = useState<boolean | null>(null);
const [elapsed, setElapsed] = useState(0);
@@ -35,11 +39,23 @@ export function ThinkingState({
<span className={working ? "aq-shimmer" : ""}>
{working
? steps.at(-1) || "正在分析"
: failed
? "本次未完成"
: "分析完成"}
: stopped
? "已由用户停止"
: failed
? "查询失败"
: outcome === "unsupported"
? "暂不支持该指标"
: outcome === "empty"
? "查询完成 · 暂无匹配数据"
: outcome === "partial"
? "已完成可查部分"
: outcome === "clarification"
? "等待补充条件"
: outcome === "help"
? "已回答"
: "分析完成"}
</span>
<span className="aq-time">{elapsed.toFixed(1)}s</span>
{elapsed > 0 && <span className="aq-time">{elapsed.toFixed(1)}s</span>}
<ChevronDown
size={14}
className={expanded ? "aq-rotated" : ""}
+441 -1
View File
@@ -30,6 +30,24 @@
font-size: 13px;
box-shadow: 0 30px 100px #0002;
}
.aq-shell.aq-page {
position: relative;
z-index: auto !important;
width: 100% !important;
max-width: none !important;
height: calc(100dvh - 48px);
max-height: none;
margin-top: 48px;
border: 0;
border-radius: 0 !important;
box-shadow: none;
}
@media (max-width: 760px) {
.aq-shell.aq-page {
height: calc(100dvh - 64px - env(safe-area-inset-bottom));
margin-top: 0;
}
}
.aq-shell.aq-dark {
--aq-bg: #1a1b1a;
--aq-sidebar: #151615;
@@ -230,8 +248,9 @@
gap: 10px;
align-items: center;
}
.aq-header strong {
.aq-header h1 {
font-weight: 600;
font-size: 13px;
}
.aq-header-divider {
height: 13px;
@@ -1568,3 +1587,424 @@
border-color: var(--aq-line);
background: var(--aq-inset);
}
/* Each answer remains understandable independently of the conversation. */
.aq-primary-metrics {
padding: 14px 0;
margin: 0 0 10px;
border-bottom: 1px solid var(--aq-line);
}
.aq-primary-metrics:has(> div:nth-child(4):last-child) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.aq-result-scope {
color: var(--aq-muted);
font-size: 11px;
line-height: 1.75;
margin: 10px 0;
}
.aq-result-scope > div,
.aq-result-scope > p {
display: flex;
align-items: baseline;
gap: 7px;
flex-wrap: wrap;
margin: 3px 0;
overflow-wrap: anywhere;
}
.aq-result-scope > div > span,
.aq-result-scope > p > span {
color: var(--aq-muted);
flex-shrink: 0;
}
.aq-result-scope strong {
font-weight: 500;
color: var(--aq-ink);
}
.aq-result-scope summary {
cursor: pointer;
}
.aq-result-scope details p {
margin: 4px 0;
}
.aq-unanswered {
padding: 12px 14px;
margin: 12px 0;
background: var(--aq-inset);
border-radius: 10px;
color: var(--aq-muted);
font-size: 12px;
}
.aq-unanswered > strong {
color: var(--aq-ink);
}
.aq-unanswered p {
margin-top: 8px;
line-height: 1.75;
}
.aq-unanswered p > span {
display: block;
font-weight: 500;
color: var(--aq-ink);
}
.aq-correction {
padding: 12px 14px;
border: 1px solid var(--aq-line);
background: var(--aq-inset);
border-radius: 10px;
margin-bottom: 12px;
font-size: 12px;
line-height: 1.8;
}
.aq-correction p {
color: var(--aq-muted);
}
.aq-correction button {
margin-top: 7px;
gap: 5px;
}
.aq-corrected-tag {
display: inline-block;
color: var(--aq-muted);
font-size: 10px;
border: 1px solid var(--aq-line);
border-radius: 4px;
padding: 0 4px;
margin-right: 6px;
}
.aq-outcome {
font-size: 12px;
font-weight: 600;
color: var(--aq-muted);
margin-bottom: 8px;
}
.aq-briefing-inventory {
padding: 0 18px 18px;
}
.aq-briefing-inventory h4 {
font-size: 13px;
margin-bottom: 8px;
}
.aq-briefing-inventory > p {
font-size: 12px;
color: var(--aq-muted);
line-height: 1.8;
}
.aq-briefing-groups {
margin: 12px 0;
}
.aq-briefing-groups > div {
display: grid;
grid-template-columns: minmax(0, 1fr) 60px 55px;
padding: 9px 0;
border-bottom: 1px solid var(--aq-line);
font-size: 12px;
gap: 8px;
}
.aq-briefing-groups > div > :not(:first-child) {
text-align: right;
}
.aq-briefing-inventory > button {
border: 1px solid var(--aq-line);
padding: 8px 12px;
gap: 6px;
font-size: 12px;
}
.aq-latest-answer {
display: flex;
margin: -2px 0 6px auto;
gap: 4px;
color: var(--aq-muted);
font-size: 11px;
padding: 3px;
}
.aq-question {
margin-bottom: 12px;
}
.aq-assistant-label {
margin-bottom: 6px;
}
.aq-thinking {
margin-bottom: 8px;
}
.aq-sidebar {
min-height: 0;
}
.aq-sidebar > :not(.aq-history) {
flex-shrink: 0;
}
.aq-history {
scrollbar-width: thin;
overscroll-behavior: contain;
}
.aq-history button {
min-height: 38px;
}
.aq-card-foot {
flex-wrap: wrap;
gap: 5px 12px;
}
@media (max-height: 750px) and (min-width: 901px) {
.aq-sidebar {
padding-top: 18px;
}
.aq-brand {
padding-bottom: 16px;
}
.aq-new {
margin-bottom: 16px;
}
.aq-history-label {
margin-top: 18px;
}
.aq-side-bottom {
padding-top: 10px;
}
.aq-side-bottom p {
margin-top: 8px;
}
}
@media (max-width: 600px) {
.aq-primary-metrics:has(> div:nth-child(4):last-child) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.aq-primary-metrics {
padding: 12px 0;
}
.aq-result-scope {
font-size: 11px;
}
.aq-card-foot > span:last-child {
display: inline;
}
.aq-briefing-inventory {
padding: 0 12px 12px;
}
}
.aq-scope-summary {
color: var(--aq-muted);
}
.aq-scope-summary:has(> strong) {
padding: 8px 10px;
border-left: 3px solid var(--aq-accent);
background: var(--aq-soft);
border-radius: 4px;
color: var(--aq-ink);
}
.aq-scope-summary strong {
margin-right: 7px;
}
.aq-extra-metrics,
.aq-secondary-details {
margin: 6px 0;
color: var(--aq-muted);
font-size: 11px;
}
.aq-extra-metrics summary,
.aq-secondary-details summary {
display: list-item;
min-height: 40px;
padding: 10px 14px;
cursor: pointer;
}
.aq-extra-metrics > div {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
padding: 4px 14px 12px;
}
.aq-extra-metrics strong {
color: var(--aq-ink);
}
.aq-secondary-details .aq-insights {
padding-top: 0;
}
.aq-secondary-details .aq-exceptions {
padding-bottom: 12px;
}
.aq-card-top {
padding-top: 14px;
}
.aq-insights {
line-height: 1.6;
}
.aq-data-times { font-size: 10px; color: var(--aq-muted); line-height: 1.7; overflow-wrap: anywhere; }
/* Keep the reading area dominant; grow only when the question needs more room. */
.aq-composer textarea { min-height: 0; height: 42px; max-height: 132px; padding: 10px 14px 5px; overflow-y: auto; }
.aq-composer-bottom { padding: 0 9px 7px; }
.aq-composer-area { padding-top: 8px; padding-bottom: 10px; }
.aq-composer-foot { padding-top: 6px; }
.aq-history button[aria-current="true"] { background: var(--aq-soft); color: var(--aq-ink); font-weight: 600; }
.aq-history button .aq-history-number { flex: 0 0 19px; font-size: 10px; font-variant-numeric: tabular-nums; color: var(--aq-muted); }
.aq-result-scope .aq-scope-tags { display: flex; flex-wrap: wrap; gap: 5px; margin: 0 0 6px; }
.aq-scope-tags > span { padding: 3px 7px; background: var(--aq-inset); border: 1px solid var(--aq-line); border-radius: 5px; max-width: 100%; overflow-wrap: anywhere; }
.aq-result-definitions { margin: 14px 0 8px; font-size: 11px; color: var(--aq-muted); line-height: 1.8; }
.aq-result-definitions summary { cursor: pointer; padding: 8px 0; }
.aq-result-definitions p { margin: 4px 0; }
.aq-result-footer { border-top: 1px solid var(--aq-line); margin-top: 12px; padding-top: 8px; }
.aq-assistant-label > span:not(.aq-small-mark) { font-size: 10px; }
@media (max-width: 600px) {
.aq-composer textarea { font-size: 16px; }
.aq-composer-area { padding-bottom: max(10px, env(safe-area-inset-bottom)); }
}
/* Compact analysis cards: preserve the active slice while drilling in place. */
.aq-result { animation: none; }
.aq-query-heading { position: sticky; top: 0; z-index: 3; padding: 7px 0 5px; margin-bottom: 7px; border-bottom: 1px solid var(--aq-line); background: var(--aq-bg); }
.aq-current-path { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 7px; font-size: 11px; line-height: 1.6; color: var(--aq-ink); }
.aq-current-path > span { display: inline-flex; align-items: center; gap: 4px; overflow-wrap: anywhere; }
.aq-current-path > .aq-path-current { order: -1; padding: 3px 7px; border-radius: 5px; background: var(--aq-soft); color: var(--aq-accent); font-weight: 650; }
.aq-query-navigation { display: flex; align-items: center; gap: 6px; color: var(--aq-muted); font-size: 10px; min-height: 30px; }
.aq-query-navigation > span { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.aq-query-navigation > button { gap: 3px; font-size: 11px; padding: 0 6px; min-height: 36px; }
.aq-path-history { position: relative; flex-shrink: 0; }
.aq-path-history > summary { display: flex; align-items: center; min-height: 36px; cursor: pointer; padding: 0 5px; font-size: 10px; }
.aq-path-history nav { position: absolute; right: 0; top: 100%; z-index: 5; display: flex; flex-direction: column; width: min(300px, 75vw); max-height: 180px; overflow: auto; border: 1px solid var(--aq-line); border-radius: 8px; background: var(--aq-bg); padding: 5px; box-shadow: 0 4px 18px #0001; }
.aq-path-history nav button { text-align: left; justify-content: flex-start; min-height: 36px; padding: 5px 8px; font-size: 11px; white-space: normal; }
.aq-result .aq-answer { font-size: 13px; line-height: 1.6; margin-bottom: 4px; }
.aq-result .aq-primary-metrics { padding: 7px 0 9px; margin: 0 0 5px; gap: 8px; }
.aq-result .aq-metrics strong { font-size: 24px; line-height: 1.2; margin-top: 3px; letter-spacing: -.6px; }
.aq-result .aq-metrics span, .aq-result .aq-metrics small { font-size: 10px; }
.aq-result .aq-metrics > div + div { padding-left: 10px; }
.aq-result .aq-extra-metrics { margin: 0 0 3px; }
.aq-result .aq-extra-metrics summary { min-height: 36px; padding: 8px 0; }
.aq-result .aq-result-card { border-radius: 9px; }
.aq-result .aq-advanced-controls, .aq-result .aq-secondary-details { display: inline-block; vertical-align: top; max-width: 100%; margin: 0; padding: 0; border-top: 0; }
.aq-result .aq-advanced-controls[open], .aq-result .aq-secondary-details[open] { display: block; width: 100%; }
.aq-result .aq-advanced-controls > summary, .aq-result .aq-secondary-details > summary { margin: 0; padding: 8px 10px; min-height: 36px; font-size: 11px; line-height: 20px; }
.aq-result .aq-analysis-controls { padding: 0 10px; margin: 5px 0 8px; }
.aq-result .aq-result-toolbar { padding: 3px 10px; gap: 6px; }
.aq-result .aq-view-tabs { padding: 1px; }
.aq-result .aq-view-tabs button { min-height: 36px; padding: 3px 7px; }
.aq-result .aq-bars { padding: 5px 10px 4px; }
.aq-result .aq-bar-row { min-height: 36px; padding: 5px 0; gap: 8px !important; }
.aq-result .aq-bar-track { height: 14px; }
.aq-result .aq-chart-caption { margin-top: 5px; padding: 0; font-size: 10px; }
.aq-result .aq-more { min-height: 36px; }
.aq-result .aq-table-scroll { max-height: 260px; overflow: auto; overscroll-behavior: contain; }
.aq-result .aq-table-scroll td, .aq-result .aq-table-scroll th { padding: 6px 10px; }
.aq-result .aq-table-scroll td button { width: 36px; height: 36px; min-height: 36px; }
.aq-result .aq-table-scroll th { z-index: 1; }
.aq-result .aq-record-toolbar { padding: 7px 10px; }
.aq-result .aq-pagination { padding: 4px 10px; }
.aq-result .aq-pagination button { min-width: 36px; min-height: 36px; }
.aq-result .aq-flow-chart { height: 220px; min-height: 0; }
.aq-result .aq-daily-details > summary { cursor: pointer; padding: 8px 10px; min-height: 36px; font-size: 11px; color: var(--aq-muted); }
.aq-result .aq-briefing-inventory { padding: 9px 10px; }
.aq-result .aq-briefing-inventory h4 { margin-bottom: 4px; }
.aq-result .aq-briefing-groups { margin: 5px 0; }
.aq-result .aq-briefing-groups > div { padding: 6px 0; }
.aq-result .aq-result-footer { margin-top: 6px; padding-top: 3px; }
.aq-result .aq-result-definitions { margin: 0; }
.aq-result .aq-result-definitions > summary { min-height: 36px; padding: 7px 0; }
.aq-result .aq-answer-actions { gap: 4px 8px; margin-top: 2px; }
.aq-result .aq-answer-actions button { min-height: 36px; padding: 3px 0; font-size: 10px; }
.aq-result .aq-followups { margin-top: 5px; gap: 4px; }
.aq-result .aq-followups > button { min-height: 36px; padding: 5px 8px; font-size: 11px; }
.aq-result .aq-unanswered, .aq-result .aq-correction { padding: 8px 10px; margin: 6px 0; }
.aq-result .aq-outcome, .aq-result .aq-gap-note { margin: 4px 0; }
.aq-inline-progress { display: flex; gap: 6px; align-items: center; padding: 5px 8px; margin: 3px 0; min-height: 30px; background: var(--aq-soft); color: var(--aq-accent); border-radius: 6px; font-size: 11px; line-height: 1.5; }
@media (max-width: 600px) {
.aq-query-heading { padding-top: 5px; margin-bottom: 5px; }
.aq-current-path { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: thin; padding-bottom: 3px; }
.aq-current-path > span { flex-shrink: 0; white-space: nowrap; }
.aq-current-path > .aq-path-current { position: sticky; left: 0; z-index: 1; background: var(--aq-inset); }
.aq-query-navigation { gap: 3px; }
.aq-result .aq-primary-metrics:has(> div:nth-child(4):last-child) { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.aq-result .aq-metrics strong { font-size: 21px; }
.aq-result .aq-metrics > div + div { padding-left: 7px; }
.aq-result .aq-metrics small { margin-left: 3px; font-size: 9px; }
.aq-result .aq-metrics span { font-size: 10px; line-height: 1.4; }
.aq-result .aq-table-scroll { max-height: 220px; }
.aq-result .aq-flow-chart { height: 180px; }
.aq-result .aq-bar-row { min-height: 36px; grid-template-columns: 63px minmax(25px, 1fr) 29px 35px 12px; }
.aq-result .aq-followups > span { width: auto; margin-bottom: 0; }
.aq-result .aq-query-navigation > span { font-size: 10px; }
}
/* Updating one card must not move its content or its scroll anchor. */
.aq-turn { position: relative; }
.aq-turn .aq-inline-progress { position: absolute; top: 0; right: 0; z-index: 7; width: fit-content; max-width: min(360px, 85%); margin: 0; box-shadow: 0 2px 7px #0001; }
.aq-turn .aq-inline-progress[role="alert"] { max-width: min(440px, 100%); white-space: normal; }
.aq-result .aq-flow-chart > div { height: 100% !important; min-height: 0; }
@media (max-width: 600px) { .aq-result .aq-metrics strong { font-size: clamp(17px, 4.8vw, 21px); } }
/* Scroll position is managed for questions and in-place actions, not by changing table rows. */
.aq-conversation { overflow-anchor: none; }
/* Multiple independent analyses share one compact result workspace. */
.aq-compound-result { margin-left: 35px; min-width: 0; max-width: 100%; }
.aq-compound-heading { margin: 5px 0 9px; }
.aq-compound-heading > div { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.aq-compound-heading > div > span { color: var(--aq-muted); font-size: 10px; }
.aq-compound-heading > p { margin-top: 5px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.aq-section-overview { display: grid; gap: 5px; margin: 9px 0; }
.aq-section-overview > button { display: flex; gap: 8px; align-items: flex-start; width: 100%; min-width: 0; min-height: 44px; border: 1px solid var(--aq-line); border-radius: 8px; padding: 7px 9px; text-align: left; background: var(--aq-bg); }
.aq-section-overview > button[aria-selected="true"] { background: var(--aq-soft); border-color: var(--aq-accent); }
.aq-section-number { flex-shrink: 0; font-size: 10px; width: 18px; min-height: 18px; display: grid; place-items: center; color: var(--aq-muted); border: 1px solid var(--aq-line); border-radius: 50%; }
.aq-section-preview { flex: 1; min-width: 0; }
.aq-section-preview > strong { display: block; font-size: 12px; font-weight: 600; line-height: 1.5; }
.aq-section-preview > span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; color: var(--aq-muted); font-size: 11px; line-height: 1.6; margin-top: 2px; }
.aq-section-overview > button > svg { flex-shrink: 0; margin-top: 3px; color: var(--aq-accent); }
.aq-compound-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 4px 10px; border-top: 1px solid var(--aq-line); padding-top: 3px; font-size: 11px; color: var(--aq-muted); }
.aq-compound-toolbar > span { min-width: 0; overflow-wrap: anywhere; }
.aq-compound-toolbar strong { color: var(--aq-ink); font-weight: 500; }
.aq-compound-toolbar button { min-height: 36px; gap: 5px; padding: 3px 5px; font-size: 11px; }
.aq-section-panel { min-width: 0; }
.aq-section-panel > .aq-result { margin-left: 0; }
.aq-compound-notice { color: var(--aq-muted); font-size: 11px; line-height: 1.7; margin: 5px 0; overflow-wrap: anywhere; }
.aq-compound-unanswered { padding: 7px 10px; border-radius: 7px; background: var(--aq-inset); font-size: 11px; line-height: 1.7; margin: 6px 0; }
.aq-compound-unanswered > p { color: var(--aq-muted); margin-top: 3px; overflow-wrap: anywhere; }
.aq-compound-unanswered > p > span { color: var(--aq-ink); }
.aq-section-error { padding: 14px; border: 1px solid var(--aq-line); border-radius: 8px; margin-top: 8px; font-size: 12px; line-height: 1.8; }
.aq-section-error > p { color: var(--aq-muted); margin: 5px 0; }
.aq-section-error > button { min-height: 36px; gap: 5px; padding: 4px 8px; font-size: 12px; }
@media (max-width: 600px) {
.aq-compound-result { margin-left: 0; }
.aq-section-overview > button { padding: 7px; gap: 6px; }
.aq-compound-heading > p { font-size: 12px; }
.aq-compound-toolbar { font-size: 10px; }
}
/* Model-selected semantic blocks reuse the compact assistant shell. */
.aq-generative-result { min-width: 0; max-width: 100%; }
.aq-generative-result .aq-generated-title { margin: 8px 0 5px; font-size: 14px; font-weight: 650; color: var(--aq-ink); }
.aq-generated-block { min-width: 0; max-width: 100%; margin: 7px 0; overflow-wrap: anywhere; }
.aq-generated-block h4 { margin: 0 0 5px; font-size: 12px; font-weight: 650; color: var(--aq-ink); }
.aq-generated-narrative { white-space: pre-wrap; font-size: 13px; line-height: 1.65; margin: 4px 0; }
.aq-generated-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(105px, 1fr)); gap: 6px; }
.aq-generated-metrics > div { min-width: 0; padding: 8px 10px; background: var(--aq-inset); border: 1px solid var(--aq-line); border-radius: 8px; }
.aq-generated-metrics span { display: block; color: var(--aq-muted); font-size: 11px; }
.aq-generated-metrics strong { display: block; margin-top: 2px; font-size: 24px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.25; }
.aq-generated-metrics small { margin-left: 3px; font-size: 10px; font-weight: 400; color: var(--aq-muted); }
.aq-generated-chart { width: 100%; min-width: 0; height: 230px; margin-top: 4px; }
.aq-generated-chart .recharts-legend-wrapper { font-size: 11px; }
.aq-generated-chart .recharts-default-tooltip { background: var(--aq-bg) !important; border-color: var(--aq-line) !important; border-radius: 7px; font-size: 12px; }
.aq-generated-interaction, .aq-generated-notice, .aq-generated-empty { font-size: 11px; color: var(--aq-muted); line-height: 1.6; margin: 5px 0; }
.aq-generated-drill-targets { display: flex; flex-wrap: wrap; gap: 4px; max-height: 120px; overflow: auto; }
.aq-generated-drill-targets button, .aq-generated-expand, .aq-generated-pagination button, .aq-generated-data summary, .aq-generative-result td button { min-height: 36px; display: inline-flex; align-items: center; gap: 4px; padding: 5px 8px; font-size: 11px; color: var(--aq-accent); border-radius: 6px; }
.aq-generated-drill-targets button { background: var(--aq-inset); }
.aq-generated-drill-targets button:hover, .aq-generated-expand:hover { background: var(--aq-hover); }
.aq-generated-drill-targets button:disabled { opacity: .55; cursor: default; }
.aq-generated-data { margin-top: 2px; }
.aq-generated-data summary { cursor: pointer; display: list-item; list-style-position: inside; }
.aq-generated-pagination { display: flex; align-items: center; justify-content: flex-end; gap: 6px; color: var(--aq-muted); font-size: 11px; }
.aq-generated-pagination > span { margin-right: auto; }
.aq-generative-result .aq-table-scroll { width: 100%; max-width: 100%; }
.aq-generative-result table { width: 100%; border-collapse: collapse; font-size: 12px; white-space: nowrap; }
.aq-generative-result th, .aq-generative-result td { padding: 7px 9px; text-align: left; border-bottom: 1px solid var(--aq-line); }
.aq-generative-result th { background: var(--aq-inset); }
.aq-generative-result .aq-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
.aq-section-stack { display: grid; grid-template-columns: minmax(0,1fr); gap: 12px; }
.aq-stacked-section { min-width: 0; border: 1px solid var(--aq-line); border-radius: 10px; padding: 8px 10px; }
.aq-stacked-section.is-active { border-color: var(--aq-accent); }
.aq-stacked-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding-bottom: 6px; font-size: 12px; }
.aq-stacked-heading strong { flex: 1; min-width: 80px; }
.aq-stacked-active { color: var(--aq-accent); font-size: 10px; }
.aq-stacked-section .aq-query-heading { top: 0; }
@media (max-width: 600px) {
.aq-generated-chart { height: 205px; }
.aq-generated-metrics { grid-template-columns: repeat(auto-fit, minmax(76px, 1fr)); gap: 4px; }
.aq-generated-metrics > div { padding: 7px 6px; }
.aq-generated-metrics strong { font-size: 21px; }
.aq-stacked-section { padding: 7px; }
}
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import { conversationTitle } from "./conversation-presentation.js";
import type { AssistantResult } from "../../../shared/assets/assistant.js";
test("history titles describe the actual result and keep long questions readable",()=>{
const base={plan:{kind:"assets",category:"inventory",groupBy:"type",filters:{},view:"chart"}} as AssistantResult;
assert.equal(conversationTitle("a long question",base),"车型库存");
assert.equal(conversationTitle("a long question",{...base,plan:{...base.plan,category:"pending",view:"list"}}),"待交车清单");
assert.ok(conversationTitle("无法识别的很长很长很长很长很长的问题内容").length<=14);
});
test("in-place drilldown keeps its question and previous results available for returning to a parent",async()=>{
const {findConversationResult,replaceAnalysisResult}=await import("./conversation-presentation.js");
const base={resultId:"root",plan:{kind:"assets",category:"inventory",groupBy:"region",filters:{},view:"chart"}} as AssistantResult;
const drilled={...base,resultId:"east",plan:{...base.plan,groupBy:"city" as const,filters:{region:["华东"]}}};
const turn={id:1,question:"库存在哪里?",result:base};
const next=replaceAnalysisResult(turn,drilled);
assert.equal(next.id,1);assert.equal(next.question,turn.question);
assert.equal(next.result,drilled);assert.equal(findConversationResult([next],"root"),base);
const restored=replaceAnalysisResult(next,{...base,resultId:"restored"});
assert.equal(findConversationResult([restored],"east"),drilled);
assert.equal(restored.resultHistory?.length,2);
assert.equal(findConversationResult([restored],"missing"),undefined);
});
test("compound drilldown replaces only its section and can return to older child scopes", async () => {
const {findConversationResult,replaceAnalysisResult}=await import("./conversation-presentation.js");
const plan={kind:"assets",category:"inventory",groupBy:"region",view:"chart",filters:{}} as const;
const stock={resultId:"stock",plan} as AssistantResult;
const pending={resultId:"pending",plan:{...plan,category:"pending"}} as AssistantResult;
const root={...stock,resultId:"bundle",sections:[
{id:"a",title:"库存",question:"库存多少",display:"chart" as const,result:stock},
{id:"b",title:"待交车",question:"待交车多少",display:"metrics" as const,result:pending},
]};
const drill={...stock,resultId:"east",plan:{...stock.plan,groupBy:"city" as const,filters:{region:["华东"]}}};
const next=replaceAnalysisResult({id:1,result:root},drill,"stock");
assert.equal(next.result?.resultId,"bundle");
assert.equal(next.result?.sections?.[0].result,drill);
assert.equal(next.result?.sections?.[1].result,pending);
assert.equal(findConversationResult([next],"stock"),stock);
const deeper={...drill,resultId:"city",plan:{...drill.plan,view:"list" as const}};
const deep=replaceAnalysisResult(next,deeper,"east");
assert.equal(deep.result?.sections?.[0].display,"list");
assert.equal(findConversationResult([deep],"east"),drill);
const returned=replaceAnalysisResult(deep,{...stock,resultId:"restored"},"stock");
assert.equal(returned.result?.sections?.[0].result?.resultId,"restored");
assert.equal(returned.result?.sections?.[1].result,pending);
assert.equal(returned.id,1);
});
@@ -0,0 +1,55 @@
import { CATEGORY_LABELS, GROUP_LABELS, type AssistantResult } from "../../../shared/assets/assistant";
export const BUSINESS_QUESTIONS = [
"现在有多少车在运营、多少车在库存?整体运营率是多少?",
"库存主要集中在哪些车型和区域?按数量从多到少统计。",
"最近7天交车、还车分别多少?其中替换交车多少?",
"查看当前异动车辆,显示异动类型和预计完成时间。",
] as const;
export function conversationTitle(question: string, result?: AssistantResult): string {
if (result?.sections?.length) return `${result.sections.length} 项关联分析`;
if (result?.presentation?.title) return result.presentation.title.slice(0, 24);
if (!result || result.plan.kind === "clarify") {
if (/历史|上个?月月底|损失|成本/.test(question)) return "历史与成本分析";
return question.replace(/[\s,。?!;:、]/g, "").slice(0, 14) || "资产分析";
}
const p = result.plan;
if (p.analysis === "briefing") return "资产总览";
if (p.kind === "flow") return p.comparePrevious ? "交还车周期对比" : "交还车统计";
if (p.view === "list") return `${CATEGORY_LABELS[p.category].replace("车辆", "")}清单`;
const category = {all:"资产",inventory:"库存",available:"可运营库存",awaiting:"待运营库存",operating:"运营",pending:"待交车",abnormal:"异动"}[p.category];
return `${p.groupBy === "none" ? "" : GROUP_LABELS[p.groupBy]}${category}`;
}
type ResultTurn = { result?: AssistantResult; resultHistory?: AssistantResult[] };
export function findConversationResult(turns: ResultTurn[], resultId: string) {
for (const turn of turns) {
if (turn.result?.resultId === resultId) return turn.result;
const current = turn.result?.sections?.find(section => section.result?.resultId === resultId)?.result;
if (current) return current;
for (const historical of [...(turn.resultHistory || [])].reverse()) {
if (historical.resultId === resultId) return historical;
const child = historical.sections?.find(section => section.result?.resultId === resultId)?.result;
if (child) return child;
}
}
}
export function replaceAnalysisResult<T extends ResultTurn>(turn: T, result: AssistantResult, baseResultId?: string): T & ResultTurn {
const history = [...(turn.resultHistory || [])];
const sectionId = baseResultId ? [turn.result, ...history.slice().reverse()]
.flatMap(item => item?.sections || [])
.find(section => section.result?.resultId === baseResultId)?.id : undefined;
if (turn.result?.resultId && (turn.result.sections || !history.some(item => item.resultId === turn.result!.resultId))) history.push(turn.result);
if (sectionId && turn.result?.sections) {
return { ...turn, result: {
...turn.result,
sections: turn.result.sections.map(section => section.id === sectionId ? {
...section, result, error: undefined,
display: result.plan.groupBy === "none" && result.plan.view === "chart" && result.plan.kind === "assets" ? "metrics" : result.plan.view,
} : section),
}, resultHistory: history };
}
return { ...turn, result, resultHistory: history };
}
@@ -0,0 +1,253 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { GenerativeResultView } from "./GenerativeResultView";
import { CompoundResultView } from "./CompoundResultView";
import {
presentationAction,
presentationData,
presentationFields,
presentationMetrics,
} from "./generative-presentation";
import type {
AssistantResult,
PresentationInteraction,
} from "../../../shared/assets/assistant";
const result: AssistantResult = {
resultId: "generated-result",
sessionId: "demo",
plan: {
kind: "assets",
category: "inventory",
groupBy: "region",
view: "chart",
filters: {},
},
title: "库存分布",
summary: "华东库存12台,华南8台。",
source: "演示台账",
fetchedAt: "2026-09-23T08:00:00Z",
rows: [],
metrics: [
{ label: "总库存", value: 20, unit: "台" },
{ label: "异动", value: 2, unit: "台" },
],
groups: [
{
name: "华东",
count: 12,
operating: 0,
rate: 0,
keys: { region: "华东" },
},
{ name: "华南", count: 8, operating: 0, rate: 0, keys: { region: "华南" } },
],
daily: [
{ date: "2026-09-23", delivered: 1, returned: 2, replaced: 0, total: 3 },
],
presentation: {
title: "库存情况",
blocks: [
{ id: "conclusion", kind: "summary" },
{ id: "selected", kind: "metrics", metrics: ["异动", "总库存"] },
{ id: "narrative", kind: "narrative", text: "<script>不能执行</script>" },
{
id: "regions",
kind: "table",
dataset: "groups",
columns: [
{ field: "count", label: "库存数量" },
{ field: "name", label: "所属区域" },
],
interactionId: "cities",
},
],
interactions: [
{
id: "cities",
label: "查看城市分布",
kind: "group",
plan: {
kind: "assets",
category: "inventory",
groupBy: "city",
view: "chart",
filters: {},
},
},
],
},
};
const callbacks = {
ask: () => {},
operate: () => {},
restore: () => {},
activate: () => {},
showCorrection: () => {},
active: true,
subject: "演示公司",
disabled: false,
};
test("model-selected metrics and columns preserve their order and never invent values", () => {
assert.deepEqual(
presentationMetrics(result, ["异动", "不存在", "总库存"]).map(
(item) => item.value,
),
[2, 20],
);
assert.deepEqual(
presentationFields({
id: "table",
kind: "table",
dataset: "groups",
columns: [
{ field: "count", label: "数量" },
{ field: "secret", label: "无此字段" },
],
}),
[{ field: "count", label: "数量" }],
);
assert.deepEqual(presentationData(result, "groups")[0].keys, {
region: "华东",
});
assert.deepEqual(presentationData(result, "comparison"), []);
});
test("exploration uses stored interaction ID and actual clicked keys or date", () => {
const group = result.presentation!.interactions[0];
assert.deepEqual(
presentationAction(group, presentationData(result, "groups")[1]),
{ type: "explore", interactionId: "cities", keys: { region: "华南" } },
);
assert.equal(presentationAction(group, { name: "华东" }), undefined);
const point: PresentationInteraction = { ...group, id: "day", kind: "point" };
assert.deepEqual(
presentationAction(point, presentationData(result, "daily")[0]),
{ type: "explore", interactionId: "day", date: "2026-09-23" },
);
assert.equal(presentationAction(point, { name: "昨天" }), undefined);
assert.deepEqual(presentationAction({ ...group, kind: "followup" }), {
type: "explore",
interactionId: "cities",
});
});
test("semantic render respects block order, real table fields, plain text, scope and answer anchors", () => {
const html = renderToStaticMarkup(
createElement(GenerativeResultView, { ...callbacks, result }),
);
assert.ok(
html.indexOf('data-block-id="selected"') <
html.indexOf('data-block-id="regions"'),
);
assert.ok(html.indexOf(">异动</span>") < html.indexOf(">总库存</span>"));
assert.ok(html.indexOf(">库存数量</th>") < html.indexOf(">所属区域</th>"));
assert.match(html, /&lt;script&gt;不能执行&lt;\/script&gt;/);
assert.doesNotMatch(html, /<script>/);
for (const expected of [
"aq-query-generated-result",
"aq-answer-generated-result",
"查看城市分布",
"源数据更新时间:",
"未提供",
"演示公司",
"华东",
"复制完整结果",
])
assert.ok(html.includes(expected), expected);
});
test("stack layout renders each real section while tabs expand only the active section", () => {
const compound: AssistantResult = {
...result,
resultId: "parent",
layout: "stack",
presentation: undefined,
sections: [
{
id: "first",
title: "库存分析",
question: "库存",
display: "chart",
result,
},
{
id: "second",
title: "异动分析",
question: "异动",
display: "table",
result: { ...result, resultId: "second-child", summary: "异动2台。" },
},
{
id: "failed",
title: "成本分析",
question: "成本",
display: "metrics",
error: "缺少成本数据",
},
],
};
const stacked = renderToStaticMarkup(
createElement(CompoundResultView, {
...callbacks,
result: compound,
activeResultId: "second-child",
}),
);
assert.ok(stacked.includes('id="aq-query-generated-result"'));
assert.ok(stacked.includes('id="aq-query-second-child"'));
assert.ok(stacked.includes('data-section-id="first"'));
assert.match(stacked, /缺少成本数据/);
assert.doesNotMatch(stacked, /role="tablist"/);
const tabbed = renderToStaticMarkup(
createElement(CompoundResultView, {
...callbacks,
result: { ...compound, layout: "tabs" },
activeResultId: "second-child",
}),
);
assert.ok(!tabbed.includes('id="aq-query-generated-result"'));
assert.ok(tabbed.includes('id="aq-query-second-child"'));
assert.match(tabbed, /role="tablist"/);
});
test("AI summary text is honored and metrics-only layouts do not receive an unsolicited visible summary", () => {
const summary = renderToStaticMarkup(
createElement(GenerativeResultView, {
...callbacks,
result: {
...result,
presentation: {
blocks: [{ id: "s", kind: "summary", text: "异动2台,应先处理。" }],
interactions: [],
},
},
}),
);
assert.match(summary, /异动2台,应先处理。/);
const metricsOnly = renderToStaticMarkup(
createElement(GenerativeResultView, {
...callbacks,
result: {
...result,
presentation: {
blocks: [{ id: "m", kind: "metrics", metrics: ["总库存"] }],
interactions: [],
},
},
}),
);
assert.ok(
metricsOnly.indexOf(result.summary) >
metricsOnly.indexOf('<details class="aq-result-definitions">'),
);
assert.ok(!metricsOnly.includes('class="aq-answer"'));
assert.ok(metricsOnly.includes('id="aq-query-generated-result"'));
});
test("generated exploration does not add unrelated fixed followup suggestions", () => {
const markup=renderToStaticMarkup(createElement(GenerativeResultView,{
...callbacks,result:{...result,suggestions:["不应插入的固定城市路径"],presentation:{blocks:[{id:"m",kind:"metrics",metrics:["总库存"]}],interactions:[]}},
}));
assert.doesNotMatch(markup,/不应插入的固定城市路径/);
});
@@ -0,0 +1,129 @@
import type {
AssistantAction,
AssistantResult,
Dimension,
PresentationBlock,
PresentationInteraction,
} from "../../../shared/assets/assistant";
export type PresentationDatum = Record<string, unknown>;
const fields: Record<NonNullable<PresentationBlock["dataset"]>, string[]> = {
groups: [
"name",
"count",
"operating",
"rate",
"abnormal",
"pending",
"share",
],
daily: ["date", "delivered", "returned", "replaced", "total"],
comparison: ["label", "current", "previous", "delta", "rate"],
rows: [
"id",
"plate",
"vehicleClass",
"type",
"model",
"region",
"city",
"department",
"status",
"inventory",
"abnormal",
"pending",
"abnormalType",
"targetCompletionTime",
"actualCompletionTime",
"date",
"event",
],
};
export function presentationData(
result: AssistantResult,
dataset: PresentationBlock["dataset"],
): PresentationDatum[] {
return dataset ? (result[dataset] || []).map((row) => ({ ...row })) : [];
}
export function presentationFields(block: PresentationBlock) {
const allowed = block.dataset ? fields[block.dataset] : [];
const defaults =
block.dataset === "daily"
? [
{ field: "date", label: "日期" },
{ field: "total", label: "车次" },
]
: block.dataset === "comparison"
? [
{ field: "label", label: "指标" },
{ field: "current", label: "本期" },
{ field: "previous", label: "上期" },
]
: block.dataset === "rows"
? [
{ field: "plate", label: "车牌" },
{ field: "status", label: "状态" },
]
: [
{ field: "name", label: "分组" },
{ field: "count", label: "数量" },
];
return (block.columns?.length ? block.columns : defaults).filter((column) =>
allowed.includes(column.field),
);
}
export function presentationSeries(block: PresentationBlock) {
const allowed = block.dataset ? fields[block.dataset] : [];
const fallback =
block.dataset === "daily"
? { field: "total", label: "车次" }
: block.dataset === "comparison"
? { field: "current", label: "本期" }
: { field: "count", label: "数量" };
return (block.series?.length ? block.series : [fallback]).filter(
(series) =>
allowed.includes(series.field) &&
!["name", "date", "label"].includes(series.field),
);
}
export function presentationMetrics(
result: AssistantResult,
labels?: string[],
) {
return labels?.length
? labels.flatMap((label) => {
const metric = result.metrics.find((item) => item.label === label);
return metric ? [metric] : [];
})
: result.metrics;
}
export function presentationValue(value: unknown, field: string) {
if (value === undefined || value === null || value === "") return "—";
if (typeof value === "number")
return Number.isFinite(value)
? `${value.toLocaleString("zh-CN")}${["rate", "share"].includes(field) ? "%" : ""}`
: "—";
if (typeof value === "boolean") return value ? "是" : "否";
return typeof value === "string" ? value : "—";
}
export function presentationAction(
interaction: PresentationInteraction | undefined,
datum?: PresentationDatum,
): AssistantAction | undefined {
if (!interaction) return;
if (interaction.kind === "followup")
return { type: "explore", interactionId: interaction.id };
if (
interaction.kind === "group" &&
datum?.keys &&
typeof datum.keys === "object" &&
Object.keys(datum.keys).length
)
return {
type: "explore",
interactionId: interaction.id,
keys: datum.keys as Partial<Record<Dimension, string>>,
};
if (interaction.kind === "point" && typeof datum?.date === "string")
return { type: "explore", interactionId: interaction.id, date: datum.date };
}
@@ -0,0 +1,219 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
copyResultText,
resultScope,
resultDisplay,
queryPath,
compactAnalysisPath,
} from "./result-presentation.js";
import type { AssistantResult } from "../../../shared/assets/assistant.js";
const result: AssistantResult = {
plan: {
kind: "assets",
category: "available",
groupBy: "region",
view: "table",
filters: { type: ["4.5T"] },
},
title: "可运营库存 · 按区域统计",
summary: "当前范围共有135台可运营库存。",
source: "资产台账",
fetchedAt: "2026-09-23T08:00:00Z",
groups: [],
rows: [],
metrics: [{ label: "可运营库存", value: 135, unit: "台" }],
scope: {
company: "演示公司",
permission: "当前账号可见车辆",
filters: ["4.5T", "可运营库存"],
grouping: "区域",
period: "当前资产台账",
definitions: ["车型按吨位汇总,4.5T包含普货与冷链"],
sourceUpdatedAt: null,
},
};
test("copy preserves facts, scope, definitions and distinguishes unknown source update from query time", () => {
const text = copyResultText(result, null);
for (const expected of [
"135台",
"公司:演示公司",
"权限范围:当前账号可见车辆",
"筛选条件:4.5T;可运营库存",
"分组:区域",
"4.5T包含普货与冷链",
"源数据更新时间:未提供",
"查询时间:",
])
assert.ok(text.includes(expected), expected);
});
test("partial briefing copies missing answers and inventory evidence without inventing a source timestamp", () => {
const partial: AssistantResult = {
...result,
outcome: "partial",
unanswered: [
{ question: "闲置损失多少钱", reason: "未接入成本数据,不估算金额。" },
],
inventoryBreakdown: {
title: "库存集中情况",
summary: "4.5T冷链库存最多。",
groups: [
{ name: "4.5T冷链", count: 155, operating: 0, rate: 0, share: 53.3 },
],
},
};
const text = copyResultText(partial, null);
assert.match(text, /4.5T冷链:155台,占库存53.3%/);
assert.match(text, /尚未回答:[\s\S]*未接入成本数据,不估算金额/);
assert.match(text, /源数据更新时间:未提供/);
});
test("copying a revoked result keeps the revocation explicit", () => {
const text = copyResultText(
result,
null,
"上一条回答的是待运营,不能用于判断待交车。",
);
assert.match(
text,
/【此回答已撤回】上一条回答的是待运营,不能用于判断待交车/,
);
});
test("fallback scope keeps exclusion and selected vehicle filters", () => {
const fallback = resultScope(
{
...result,
scope: undefined,
plan: {
...result.plan,
excludeFilters: { city: ["嘉兴市"] },
vehicleIds: ["example-1"],
exception: "overdue",
},
},
"演示公司",
);
assert.deepEqual(fallback.filters, [
"可运营库存",
"车型:4.5T",
"排除城市:嘉兴市",
"指定车辆:1台",
"已超过预计完成时间",
]);
assert.equal(fallback.sourceUpdatedAt, null);
});
test("copy includes regional facts and daily flow without duplicating groups or copying vehicle details", () => {
const regional: AssistantResult = {
...result,
groups: [
{
name: "华东",
count: 99,
operating: 0,
rate: 0,
share: 73.3,
abnormal: 1,
pending: 0,
},
],
insights: ["华东占当前可运营库存73.3%。"],
rows: [
{
id: "private-vehicle",
plate: "测试车牌不应复制",
vehicleClass: "4.5T普货",
type: "4.5T",
model: "演示型号",
region: "华东",
city: "嘉兴市",
department: "演示部门",
status: "可运营",
},
],
};
const regionalText = copyResultText(regional, null);
assert.match(regionalText, /华东:99台,占比73.3%/);
assert.match(regionalText, /其中异动1台,其中待交车0台/);
assert.match(regionalText, /分析发现:[\s\S]*华东占当前可运营库存73.3%/);
assert.ok(!regionalText.includes("测试车牌不应复制"));
const flowText = copyResultText(
{
...regional,
plan: {
...regional.plan,
kind: "flow",
start: "2026-09-23",
end: "2026-09-23",
},
daily: [
{
date: "2026-09-23",
delivered: 5,
returned: 2,
replaced: 1,
total: 7,
},
],
},
null,
);
assert.match(
flowText,
/2026-09-23:交车5车次,还车2车次,其中替换交车1车次,合计7车次/,
);
assert.match(flowText, /替换交车包含在交车中,不重复相加/);
assert.ok(!flowText.includes("华东:99台"));
assert.ok(!flowText.includes("测试车牌不应复制"));
});
test("single totals use metric cards while comparisons, trends and explicit records retain their display", () => {
const total = {
...result.plan,
groupBy: "none" as const,
view: "chart" as const,
};
assert.equal(resultDisplay(total), "metrics");
assert.equal(resultDisplay({ ...total, groupBy: "region" }), "chart");
assert.equal(resultDisplay({ ...total, secondaryGroupBy: "type" }), "chart");
assert.equal(resultDisplay({ ...total, kind: "flow" }), "chart");
assert.equal(resultDisplay({ ...total, view: "list" }), "list");
assert.equal(resultDisplay({ ...total, view: "table" }), "table");
});
test("current path retains every active slice and highlights the current grouping", () => {
const path = queryPath({
...result.plan,
filters: { region: ["华东"], city: ["嘉兴市"] },
excludeFilters: { model: ["演示型号"] },
groupBy: "vehicleClass",
exception: "overdue",
});
assert.deepEqual(path, [
"可运营库存",
"区域:华东",
"城市:嘉兴市",
"排除型号:演示型号",
"已超期",
"按车型细分统计",
]);
});
test("analysis path collapses display-only steps and preserves the latest node of each scope", () => {
const path = compactAnalysisPath([
{ resultId: "all", label: "全部库存" },
{ resultId: "east-chart", label: "库存 · 华东" },
{ resultId: "east-table", label: "库存 · 华东" },
{ resultId: "jiaxing", label: "库存 · 华东 / 嘉兴" },
]);
assert.deepEqual(
path.map((crumb) => crumb.resultId),
["all", "east-table", "jiaxing"],
);
assert.equal(path.at(-2)?.label, "库存 · 华东");
});
@@ -0,0 +1,194 @@
import {
CATEGORY_LABELS,
GROUP_LABELS,
type AssistantResult,
type Dimension,
type QueryPlan,
} from "../../../shared/assets/assistant";
export function compactAnalysisPath(
breadcrumbs: AssistantResult["breadcrumbs"] = [],
) {
const path: NonNullable<AssistantResult["breadcrumbs"]> = [];
for (const crumb of breadcrumbs) {
if (path.at(-1)?.label === crumb.label) path[path.length - 1] = crumb;
else path.push(crumb);
}
return path;
}
export function queryPath(plan: QueryPlan): string[] {
return [
plan.kind === "flow" ? "交退换车" : CATEGORY_LABELS[plan.category],
...Object.entries(plan.filters).map(
([dimension, values]) =>
`${GROUP_LABELS[dimension as Dimension]}:${values.join("、")}`,
),
...Object.entries(plan.excludeFilters || {}).map(
([dimension, values]) =>
`排除${GROUP_LABELS[dimension as Dimension]}:${values.join("、")}`,
),
...(plan.vehicleIds?.length ? [`已选${plan.vehicleIds.length}台车辆`] : []),
...(plan.exception
? [
plan.exception === "overdue"
? "已超期"
: plan.exception === "dueSoon"
? "即将到期"
: "未填写预计完成时间",
]
: []),
plan.groupBy === "none" && !plan.secondaryGroupBy
? "总量"
: `按${[GROUP_LABELS[plan.groupBy], plan.secondaryGroupBy && GROUP_LABELS[plan.secondaryGroupBy]].filter(Boolean).join(" × ")}统计`,
];
}
export function resultDisplay(
plan: QueryPlan,
): "metrics" | "chart" | "table" | "list" {
if (
plan.kind === "assets" &&
plan.groupBy === "none" &&
!plan.secondaryGroupBy &&
plan.view === "chart"
)
return "metrics";
return plan.view;
}
export function displayTime(value: string | null | undefined) {
if (!value) return "未提供";
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleString("zh-CN", { hour12: false });
}
export function resultScope(result: AssistantResult, subject: string | null) {
if (result.scope) return result.scope;
const { plan } = result;
return {
company: subject || "全部所属公司",
permission: "当前账号可见范围",
filters: [
CATEGORY_LABELS[plan.category],
...Object.entries(plan.filters).map(
([dimension, values]) =>
`${GROUP_LABELS[dimension as Dimension]}:${values.join("、")}`,
),
...Object.entries(plan.excludeFilters || {}).map(
([dimension, values]) =>
`排除${GROUP_LABELS[dimension as Dimension]}:${values.join("、")}`,
),
...(plan.vehicleIds?.length
? [`指定车辆:${plan.vehicleIds.length}台`]
: []),
...(plan.exception
? [
plan.exception === "overdue"
? "已超过预计完成时间"
: plan.exception === "dueSoon"
? "即将到期"
: "未填写预计完成时间",
]
: []),
],
grouping: [
GROUP_LABELS[plan.groupBy],
plan.secondaryGroupBy && GROUP_LABELS[plan.secondaryGroupBy],
]
.filter(Boolean)
.join(" × "),
period: plan.start
? `${plan.start} — ${plan.end || plan.start}`
: "当前资产台账",
definitions: [],
sourceUpdatedAt: null,
};
}
export function copyResultText(
result: AssistantResult,
subject: string | null,
correctionMessage?: string,
): string {
if (result.sections?.length) return [
result.title, result.summary,
...(correctionMessage ? [`【此回答已撤回】${correctionMessage}`] : []),
...result.sections.map((section, index) => `\n${index + 1}. ${section.title}\n${section.result ? copyResultText(section.result, subject) : section.error || "暂未完成"}`),
...(result.unanswered?.length ? ["尚未回答:", ...result.unanswered.map(item => `${item.question}:${item.reason}`)] : []),
].join("\n");
const scope = resultScope(result, subject);
return [
result.presentation?.title || result.title,
...(correctionMessage ? [`【此回答已撤回】${correctionMessage}`] : []),
...(result.correction ? [`【纠正说明】${result.correction.message}`] : []),
result.summary,
...(result.presentation?.blocks.filter(block => block.kind === "narrative" && block.text).map(block => `${block.title ? `${block.title}:` : ""}${block.text}`) || []),
...result.metrics.map(
(metric) =>
`${metric.label}:${metric.value.toLocaleString()}${metric.unit}`,
),
...(result.plan.kind === "assets" &&
result.plan.analysis !== "briefing" &&
result.groups.length
? [
"分组统计:",
...result.groups.map(
(group) =>
`${group.name}:${group.count}台${group.share === undefined ? "" : `,占比${group.share}%`},运营率${group.rate}%${group.abnormal === undefined ? "" : `,其中异动${group.abnormal}台`}${group.pending === undefined ? "" : `,其中待交车${group.pending}台`}`,
),
]
: []),
...(result.plan.kind === "flow" && result.daily?.length
? [
"每日流转(车次):替换交车包含在交车中,不重复相加。",
...result.daily.map(
(day) =>
`${day.date}:交车${day.delivered}车次,还车${day.returned}车次,其中替换交车${day.replaced}车次,合计${day.total}车次`,
),
]
: []),
...(result.insights?.length ? ["分析发现:", ...result.insights] : []),
...(result.inventoryBreakdown
? [
result.inventoryBreakdown.title,
result.inventoryBreakdown.summary,
...result.inventoryBreakdown.groups.map(
(group) =>
`${group.name}:${group.count}台${group.share === undefined ? "" : `,占库存${group.share}%`}`,
),
]
: []),
...(result.comparison?.length
? [
`对比期间:${result.comparisonPeriod?.start || "未提供"} — ${result.comparisonPeriod?.end || "未提供"}`,
...result.comparison.map(
(metric) =>
`${metric.label}:本期${metric.current},上期${metric.previous},差值${metric.delta},变化率${metric.rate === null ? "基期为0,不计算" : `${metric.rate}%`}`,
),
]
: []),
...(result.unanswered?.length
? [
"尚未回答:",
...result.unanswered.map(
(item) => `${item.question}:${item.reason}`,
),
]
: []),
`公司:${scope.company}`,
`权限范围:${scope.permission}`,
`筛选条件:${scope.filters.join(";") || "无额外筛选"}`,
`分组:${scope.grouping}`,
`统计期间:${scope.period}`,
...(result.plan.topN
? [`仅展示前${result.plan.topN}组;总计与明细包含全部匹配车辆`]
: []),
...scope.definitions.map((definition) => `统计口径:${definition}`),
`来源:${result.source}`,
`源数据更新时间:${displayTime(scope.sourceUpdatedAt)}`,
`查询时间:${displayTime(result.fetchedAt)}`,
].join("\n");
}