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,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>
);
}