feat(asset-ai): generate contextual presentations and drilldown plans
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -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,
|
||||
);
|
||||
Reference in New Issue
Block a user