feat(asset-ai): generate contextual presentations and drilldown plans
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
shishengliang
2026-09-23 17:52:33 +08:00
parent b5a48b6417
commit dc0923b566
33 changed files with 6193 additions and 1165 deletions
@@ -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,
);