303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
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>
|
||
);
|
||
}
|