Merge AI-generated analysis and inventory drilldowns into main
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
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
@@ -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" : ""}
|
||||
|
||||
@@ -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, /<script>不能执行<\/script>/);
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user