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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user