216 lines
9.7 KiB
TypeScript
216 lines
9.7 KiB
TypeScript
import type { Message } from "@langchain/langgraph-sdk";
|
||
import { useEffect, useState } from "react";
|
||
import {
|
||
ArrowRight,
|
||
BrainCircuit,
|
||
Compass,
|
||
GitBranch,
|
||
Loader2,
|
||
Medal,
|
||
MessagesSquare,
|
||
} from "lucide-react";
|
||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { reasoningPlugins } from "@/core/streamdown";
|
||
import {
|
||
splitStrategySections,
|
||
splitStrategySubsections,
|
||
pendingStrategyClarification,
|
||
strategyRounds,
|
||
STRATEGY_COLUMNS,
|
||
} from "@/core/analysis-projects/strategy";
|
||
|
||
const descriptions = [
|
||
"确认 AI 理解的问题、目标、约束、变量与启用视角",
|
||
"各 Lens 独立判断并形成策略,再显式分析观点冲突",
|
||
"形成可导出、可执行的策略文档,包含路线图、资源、风险与评估机制",
|
||
];
|
||
const icons = [Compass, GitBranch, Medal];
|
||
export function StrategyBoard({
|
||
messages,
|
||
loading,
|
||
lenses,
|
||
onClarificationSubmit,
|
||
}: {
|
||
messages: Message[];
|
||
loading: boolean;
|
||
lenses: string[];
|
||
onClarificationSubmit: (answer: string) => void | Promise<void>;
|
||
}) {
|
||
const rounds = strategyRounds(messages);
|
||
const clarification = pendingStrategyClarification(messages);
|
||
const [customAnswer, setCustomAnswer] = useState("");
|
||
const [submitting, setSubmitting] = useState(false);
|
||
useEffect(() => setCustomAnswer(""), [clarification?.question]);
|
||
const answerClarification = async (answer: string) => {
|
||
if (!answer.trim() || submitting) return;
|
||
setSubmitting(true);
|
||
try {
|
||
await onClarificationSubmit(answer.trim());
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
const displayed = rounds.length
|
||
? rounds
|
||
: [{ id: "empty", question: "从一个问题开始策略分析", answer: "" }];
|
||
return (
|
||
<div
|
||
className="h-full overflow-y-auto px-4 pt-4 pb-56"
|
||
data-testid="strategy-board"
|
||
>
|
||
{displayed.map((round, index) => {
|
||
const parts = splitStrategySections(round.answer);
|
||
const active = loading && index === displayed.length - 1;
|
||
return (
|
||
<article key={round.id} className="mb-6">
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<span className="rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground">
|
||
{index + 1}
|
||
</span>
|
||
<h2 className="min-w-0 flex-1 whitespace-pre-wrap break-words text-sm font-medium">
|
||
{round.question}
|
||
</h2>
|
||
{active && (
|
||
<Loader2 className="size-4 shrink-0 animate-spin text-primary" />
|
||
)}
|
||
</div>
|
||
<div className="grid items-start gap-3 lg:grid-cols-[minmax(220px,0.75fr)_minmax(340px,1.35fr)_minmax(320px,1.2fr)]">
|
||
{STRATEGY_COLUMNS.map((title, column) => {
|
||
const Icon = icons[column]!;
|
||
const subsections = splitStrategySubsections(parts[column]!);
|
||
return (
|
||
<section
|
||
key={title}
|
||
aria-label={title}
|
||
className="min-w-0 overflow-hidden rounded-xl border bg-card"
|
||
>
|
||
<header className="border-b bg-muted/30 px-4 py-3">
|
||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||
<Icon className="size-4 text-primary" />
|
||
{title}
|
||
</h3>
|
||
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||
{descriptions[column]}
|
||
</p>
|
||
</header>
|
||
<div className="min-h-44 break-words p-4 text-sm">
|
||
{column === 0 && clarification && (
|
||
<div className="mb-4 rounded-lg border border-amber-300/70 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-950/30">
|
||
<p className="text-xs font-medium text-amber-800 dark:text-amber-300">
|
||
待确认条件 · 回答后继续多视角推演
|
||
</p>
|
||
{clarification.context && (
|
||
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||
{clarification.context}
|
||
</p>
|
||
)}
|
||
<p className="mt-2 text-sm font-medium">{clarification.question}</p>
|
||
<div className="mt-3 grid gap-2">
|
||
{clarification.options.map((option) => (
|
||
<Button
|
||
key={option.id}
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-auto justify-start whitespace-normal bg-background py-2 text-left"
|
||
disabled={submitting || loading}
|
||
onClick={() => void answerClarification(option.label)}
|
||
>
|
||
{option.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
{clarification.allow_custom && (
|
||
<div className="mt-2 flex gap-2">
|
||
<Input
|
||
aria-label="补充澄清条件"
|
||
className="h-8 bg-background text-xs"
|
||
value={customAnswer}
|
||
onChange={(event) => setCustomAnswer(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") void answerClarification(customAnswer);
|
||
}}
|
||
placeholder="输入其他情况"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
className="h-8"
|
||
disabled={!customAnswer.trim() || submitting || loading}
|
||
onClick={() => void answerClarification(customAnswer)}
|
||
>
|
||
继续
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{column === 1 && (
|
||
<div className="mb-4 flex flex-wrap items-center gap-1.5 rounded-lg bg-muted/40 p-2 text-[11px] text-muted-foreground">
|
||
<BrainCircuit className="size-3.5 text-primary" />
|
||
<span>当前问题</span>
|
||
<ArrowRight className="size-3" />
|
||
<span>Lens Manager</span>
|
||
<ArrowRight className="size-3" />
|
||
{lenses.map((lens) => (
|
||
<span key={lens} className="rounded-full border bg-background px-2 py-0.5">
|
||
{lens}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{parts[column] ? (
|
||
column === 1 && subsections.length ? (
|
||
<div className="grid gap-3 xl:grid-cols-2">
|
||
{subsections.map((section) => {
|
||
const debate = /策略辩论/.test(section.title);
|
||
return (
|
||
<section
|
||
key={section.title}
|
||
className={debate ? "rounded-lg border border-primary/20 bg-primary/5 p-3 xl:col-span-2" : "rounded-lg border bg-background p-3"}
|
||
>
|
||
<h4 className="mb-2 flex items-center gap-2 font-medium">
|
||
{debate ? <MessagesSquare className="size-4 text-primary" /> : <Compass className="size-4 text-primary" />}
|
||
{section.title.replace(/^Lens[::]\s*/, "")}
|
||
</h4>
|
||
<MarkdownContent
|
||
content={section.content}
|
||
isLoading={active}
|
||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||
className="!h-auto !w-full min-w-0 text-xs [&_table]:text-xs"
|
||
/>
|
||
</section>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<MarkdownContent
|
||
content={parts[column]!}
|
||
isLoading={active}
|
||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||
className="!h-auto !w-full min-w-0 text-sm [&_table]:text-xs"
|
||
/>
|
||
)
|
||
) : (
|
||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||
{active
|
||
? "等待本层分析结果…"
|
||
: rounds.length
|
||
? "本轮尚未输出该层内容"
|
||
: "在下方输入问题后开始"}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|