This commit is contained in:
董宏凯 committed 2026-05-26 21:10:33 +08:00
commit 67152f1c83
14 files changed
+1566 -183

No files matched your search

+2 -2
View File
@@ -20,13 +20,13 @@ import { NotFoundPage } from "./pages/NotFoundPage";
function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []);
const location = useLocation();
const forcedTheme = location.pathname === "/landing" ? "dark" : undefined;
const forcedTheme = location.pathname === "/landing" ? "light" : undefined;
return (
<NextThemesProvider
attribute="class"
storageKey="strategy-theme"
defaultTheme="dark"
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange
forcedTheme={forcedTheme}
+8 -6
View File
@@ -72,12 +72,14 @@ const menuItems: MenuItem[] = [
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{
id: "qa-roundtable",
label: "圆桌规划",
icon: Bot,
path: "/page/roundtable/planning",
},
// 「圆桌规划」入口暂时隐藏 —— 功能仍在 /page/roundtable/planning,只是不在侧边栏出现。
// 需要重新开放时取消注释即可。
// {
// id: "qa-roundtable",
// label: "圆桌规划",
// icon: Bot,
// path: "/page/roundtable/planning",
// },
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
@@ -63,6 +63,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import type { AgentThreadContext } from "@/core/threads";
import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { AgentSelector } from "@/components/workspace/agents/agent-selector";
@@ -500,7 +501,7 @@ export function InputBox({
setTimeout(() => requestFormSubmit(), 0);
}}
>
{q.title}
{desensitize(q.title)}
</button>
))}
</div>
+3 -3
View File
@@ -86,10 +86,10 @@ const STORAGE_KEY = 'strategy-theme';
function readPersistedTheme(): ThemeType {
if (typeof window === 'undefined') {
return 'dark';
return 'light';
}
const saved = localStorage.getItem(STORAGE_KEY);
return saved === 'light' ? 'light' : 'dark';
return saved === 'dark' ? 'dark' : 'light';
}
// 主题上下文
@@ -125,7 +125,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
if (current !== 'light' && current !== 'dark') {
const fallback =
legacy === 'light' || legacy === 'dark' ? legacy : 'dark';
legacy === 'light' || legacy === 'dark' ? legacy : 'light';
setNextTheme(fallback);
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
setNextTheme(legacy);
+12
View File
@@ -5,6 +5,17 @@
*/
export const DESENSITIZE_MAP: Record<string, string> = {
态势: "TS",
滨海作战体系: "第一张网",
滨海作战部队: "第一张网",
'Taiwan Air Force Flight Units': "第二张网",
台海空军飞行部队: "第二张网",
空军: "KJ",
海军: "HJ",
陆军: "LJ",
陆战队: "LZD",
海巡: "HX",
憲兵: "XB",
資通電軍: "ZTDJ",
情报: "QB",
指挥: "ZH",
信息准备: "XX准备",
@@ -30,6 +41,7 @@ export const DESENSITIZE_MAP: Record<string, string> = {
军: "J",
美军: "MJ",
台军: "TJ",
};
/**
@@ -193,6 +193,13 @@ export interface StreamMultiAgentRequest {
agentType: StreamAgentType;
dAgentThreadId: ThreadIdMap;
agentName: string;
/**
* 人类可读名称(中文/英文)。仅 special 走广播时使用 —— 后端把它拼进
* 「子智能体X完成Y工作,交付内容为:...」里,这样总控 LLM 在下一轮看到自己
* 历史里的交付摘要时能用"语义化的名字"识别,而不是 UUID 字符串(LLM 对
* UUID 不友好,会导致重复派活)。后端缺失时 fallback 到 agentName。
*/
displayName?: string;
newMessage: string;
model?: string;
skillStopNames?: string[];
@@ -209,8 +216,14 @@ export interface StreamMultiAgentRequest {
* Fires whenever the inferred SSE phase changes. The UI can use this to
* show progressive status messages during the inevitable first-token wait.
* `name` is the tool name when phase is `tool_calling` (or empty otherwise).
* `args` 是 tool_calling 阶段的工具入参(取自上游 LangGraph messages-tuple
* 帧的 tool_calls[0].args),供 UI 渲染路径 chip / 代码块等子节点。
*/
onPhaseChange?: (phase: StreamPhase, name?: string) => void;
onPhaseChange?: (
phase: StreamPhase,
name?: string,
args?: Record<string, unknown>,
) => void;
}
export interface ClarificationPayload {
@@ -327,6 +340,8 @@ export async function streamMultiAgent(
agent_type: req.agentType,
d_agent_thread_id: req.dAgentThreadId,
agent_name: req.agentName,
// 仅 special 用到;后端缺失时 fallback 到 agent_name,旧前端也不破坏。
display_name: req.displayName,
new_message: req.newMessage,
model: req.model,
skill_stop_names: req.skillStopNames ?? [],
@@ -366,10 +381,17 @@ export async function streamMultiAgent(
// value actually changes (the reader loop sees many frames per phase).
let currentPhase: StreamPhase = "connecting";
req.onPhaseChange?.("connecting");
const setPhase = (next: StreamPhase, name?: string) => {
const setPhase = (
next: StreamPhase,
name?: string,
args?: Record<string, unknown>,
) => {
// 同一个 phase + 同一个 toolName 才认为是"重复",此时不再回调上层;
// 但 args 单独是新的(如同一工具被多次 call 携带不同入参)时也不回调 —— 上层
// appendStepIfNew 用 `phase:toolName` 做 key 去重,本来也不会因 args 变化产生新步骤。
if (next === currentPhase && !name) return;
currentPhase = next;
req.onPhaseChange?.(next, name);
req.onPhaseChange?.(next, name, args);
};
while (true) {
@@ -426,13 +448,22 @@ export async function streamMultiAgent(
setPhase("tool_result");
} else if (hasToolCall) {
// Pick the most informative name we can find in either field.
// 优先用 tool_calls(完整对象,含解析好的 args),tool_call_chunks 的 args
// 通常是局部 JSON 字符串(LangGraph 流式期间逐字符发),没法直接用。
const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
? (toolCalls[0] as Record<string, unknown>)
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
? (toolCallChunks[0] as Record<string, unknown>)
: null;
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
setPhase("tool_calling", toolName);
// 仅当来自 tool_calls(完整对象)时透传 args;tool_call_chunks 的 args 是
// string 拼接中,不传上去给 UI,等下一个完整 chunk 再带。
const fromCompleteToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0;
const args =
fromCompleteToolCalls && firstCall && typeof firstCall.args === "object" && firstCall.args !== null
? (firstCall.args as Record<string, unknown>)
: undefined;
setPhase("tool_calling", toolName, args);
} else if (
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
@@ -368,8 +368,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
)}
</section>
{/* Section 3: Generated files per seat */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
{/* Section 3: Generated files per seat —— 暂时隐藏(无真实产出文件链路) */}
<section className="hidden bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<Download className="w-4 h-4 text-primary" />
生成的报告文件
@@ -1,7 +1,16 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CheckIcon, Loader2Icon, RefreshCwIcon, SparklesIcon } from "lucide-react";
import {
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
DotIcon,
LightbulbIcon,
Loader2Icon,
RefreshCwIcon,
SparklesIcon,
} from "lucide-react";
import {
Dialog,
@@ -12,7 +21,15 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtHeader,
ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { listAgents, type Agent } from "@/core/agents";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { cn } from "@/lib/utils";
import type { IntentSummary } from "../api/intent";
@@ -43,7 +60,7 @@ export interface RecommendAgentsDialogProps {
model?: string;
onCancel: () => void;
/** Called with the user's final selection of agent ids (in display order). */
onConfirm: (agentIds: string[]) => void;
onConfirm: (agents: Array<{ agent_id: string; name: string }>) => void;
}
const MIN_SELECTED = 2;
@@ -71,12 +88,21 @@ type DialogPhase =
| "fallback"
| "error";
interface RecommendStep {
id: string;
key: string;
label: string;
ts: string;
}
interface InternalState {
phase: DialogPhase;
/** Streaming + final rationale text from the recommender. */
rationale: string;
/** Streaming SSE phase from the recommender helper. */
streamPhase: RecommendStreamPhase | null;
/** 推荐流程阶段时间线 —— 调度 / 已连接 / 流式生成 …… 用 ChainOfThought 折叠展示。 */
steps: RecommendStep[];
/** Pre-filtered candidate pool fetched from `/api/agents`. */
candidates: RecommendCandidate[];
/** Reason-by-agent_id, only for picks returned by the recommender. */
@@ -93,6 +119,7 @@ const INITIAL_STATE: InternalState = {
phase: "idle",
rationale: "",
streamPhase: null,
steps: [],
candidates: [],
reasons: {},
recommendedIds: [],
@@ -100,6 +127,24 @@ const INITIAL_STATE: InternalState = {
errorMessage: null,
};
/** 推荐流程的 phase → 中文标签;与 RoundtablePlanningPage::phaseLabel 风格保持一致。 */
function recommendPhaseLabel(phase: RecommendStreamPhase): string {
switch (phase) {
case "connecting":
return "调度推荐智能体";
case "connected":
return "已连接,等待响应";
case "streaming":
return "正在生成推荐";
default:
return phase;
}
}
function nowTimeStr(): string {
return new Date().toLocaleTimeString("zh-CN", { hour12: false });
}
/**
* Resolve which candidate ids should be checked by default given a set of
* recommended ids. The result preserves recommendation order and silently
@@ -193,6 +238,10 @@ export function RecommendAgentsDialog({
candidates,
rationale: "",
streamPhase: "connecting",
// 推荐开始,清空上一轮的步骤时间线并打第一条点。
steps: [
{ id: "0", key: "connecting", label: recommendPhaseLabel("connecting"), ts: nowTimeStr() },
],
}));
try {
@@ -227,7 +276,24 @@ export function RecommendAgentsDialog({
},
onPhaseChange: (phase) => {
if (myGen !== generationRef.current) return;
setState((prev) => ({ ...prev, streamPhase: phase }));
setState((prev) => {
const key = phase;
const last = prev.steps[prev.steps.length - 1];
// 连续重复阶段不重复 push,只在阶段切换时打点。
const steps =
last && last.key === key
? prev.steps
: [
...prev.steps,
{
id: String(prev.steps.length),
key,
label: recommendPhaseLabel(phase),
ts: nowTimeStr(),
},
];
return { ...prev, streamPhase: phase, steps };
});
},
});
if (myGen !== generationRef.current) return;
@@ -346,12 +412,15 @@ export function RecommendAgentsDialog({
// Order: recommended-and-selected first (in recommender order), then
// user-added picks (in pool order). Keeps the leader's "可调度智能体"
// list visually stable.
const ordered: string[] = [];
//
// 传完整对象(含 name)而不是只传 agent_id —— 非内置 agent 的 id 是 UUID,
// 上游若只拿 id,在左侧席位列表就会显示 UUID 而非中文名。
const ordered: Array<{ agent_id: string; name: string }> = [];
for (const c of recommendedCards) {
if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id);
if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name });
}
for (const c of otherCards) {
if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id);
if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name });
}
onConfirm(ordered);
}, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]);
@@ -390,7 +459,7 @@ export function RecommendAgentsDialog({
) : state.phase === "recommending" ? (
<>
{recommendedCards.length > 0 ? (
<Section title={`推荐参会 (${recommendedCards.length})`}>
<Section title={`推荐参会 (${recommendedCards.length})`} collapsible defaultOpen>
{recommendedCards.map((c) => (
<AgentCard
key={c.agent_id}
@@ -419,7 +488,7 @@ export function RecommendAgentsDialog({
)}
{recommendedCards.length > 0 && (
<Section title={`推荐参会 (${recommendedCards.length})`}>
<Section title={`推荐参会 (${recommendedCards.length})`} collapsible defaultOpen>
{recommendedCards.map((c) => (
<AgentCard
key={c.agent_id}
@@ -490,6 +559,24 @@ export function RecommendAgentsDialog({
}
function RationaleBlock({ state }: { state: InternalState }) {
// 默认展开:推荐流程一开始就让用户能看到生成过程。
// 生成完毕后(isStreaming 翻成 false)自动收起 —— 让 UI 聚焦在 picks 选择上。
// userTouchedRef 标记用户是否手动点过按钮:点过就尊重用户偏好,不再自动收起。
const [expanded, setExpanded] = useState(true);
const userTouchedRef = useRef(false);
// 步骤折叠卡:**始终默认收起**,header 上显示最新一步作为预览。
const [stepsOpen, setStepsOpen] = useState(false);
const isStreaming = state.phase === "recommending";
// 流式期间禁用逐字动画——避免不断 re-tokenize 拖慢渲染;完成后回到普通 markdown。
const rehypePlugins = useRehypeSplitWordsIntoSpans(isStreaming);
// 流式状态切换时,如果用户没有手动调过展开/收起,执行默认行为:
// 流式中 → expanded=true,完成后 → expanded=false。
// 步骤折叠卡不自动跟随 —— 始终默认收起。
useEffect(() => {
if (!userTouchedRef.current) setExpanded(isStreaming);
}, [isStreaming]);
if (state.phase === "loading_agents") {
return (
<div className="flex items-center gap-2 text-muted-foreground">
@@ -498,52 +585,178 @@ function RationaleBlock({ state }: { state: InternalState }) {
</div>
);
}
if (state.phase === "recommending") {
const label =
state.streamPhase === "connecting"
if (state.phase === "error") {
return (
<div className="text-xs text-destructive">
推荐失败,请使用下方的"重新推荐"重试,或取消后再次进入。
</div>
);
}
const showRationaleStage =
state.phase === "recommending" ||
state.phase === "ready" ||
state.phase === "fallback";
if (!showRationaleStage) {
return null;
}
const streamingLabel =
state.phase === "recommending"
? state.streamPhase === "connecting"
? "调度推荐智能体..."
: state.streamPhase === "connected"
? "已连接,等待响应..."
: "正在生成推荐...";
return (
<div className="space-y-1">
: "正在生成推荐..."
: null;
const hasRationale = !!state.rationale;
return (
<div className="space-y-1.5">
{streamingLabel && (
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<Loader2Icon className="size-3 animate-spin" />
{label}
{streamingLabel}
</div>
{state.rationale ? (
<p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed">
{state.rationale}
</p>
) : (
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
)}
</div>
);
}
if (state.phase === "ready" || state.phase === "fallback") {
if (!state.rationale) {
return <div className="text-xs text-muted-foreground">推荐完成。</div>;
}
return (
<p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed max-h-32 overflow-y-auto">
{state.rationale}
</p>
);
}
if (state.phase === "error") {
return <div className="text-xs text-destructive">推荐失败,请使用下方的"重新推荐"重试,或取消后再次进入。</div>;
}
return null;
)}
{/* 推荐流程阶段时间线(视觉与 chats/new 消息列表的 ChainOfThought 对齐):
**始终默认收起**,收起 header 上显示最新一步作为预览。
streaming 用 LightbulbIcon,其它阶段用 DotIcon —— 推荐流程没有 tool_calls,
所以不需要 chats 那套完整的工具图标分支。 */}
{state.steps.length > 0 && (() => {
const lastStep = state.steps[state.steps.length - 1];
const LastIcon = lastStep?.key === "streaming" ? LightbulbIcon : DotIcon;
return (
<ChainOfThought
className="rounded-md border border-border/50 bg-muted/30 px-3 py-2"
open={stepsOpen}
onOpenChange={setStepsOpen}
>
<ChainOfThoughtHeader
className="text-[11px]"
icon={
// 仅「生成中 + 收起」时用最新一步的图标作进度预览;完成后用默认脑子图标。
!stepsOpen && isStreaming ? (
<LastIcon className="size-3.5 text-muted-foreground" />
) : undefined
}
>
{stepsOpen
? "步骤"
: isStreaming
? (lastStep?.label ?? `查看 ${state.steps.length} 个步骤`)
: "步骤列表"}
</ChainOfThoughtHeader>
<ChainOfThoughtContent>
{state.steps.map((s, i) => (
<ChainOfThoughtStep
key={s.id}
icon={s.key === "streaming" ? LightbulbIcon : DotIcon}
label={
<span className="text-[12px] text-muted-foreground">
<span className="font-medium text-foreground">{s.label}</span>
<span className="ml-2 text-[10px] font-mono opacity-60">{s.ts}</span>
</span>
}
status={
isStreaming && i === state.steps.length - 1 ? "active" : "complete"
}
/>
))}
</ChainOfThoughtContent>
</ChainOfThought>
);
})()}
{!hasRationale && isStreaming && (
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
)}
{!hasRationale && !isStreaming && (
<div className="text-xs text-muted-foreground">推荐完成。</div>
)}
{hasRationale && (
<>
{/* 按钮在上、内容在下 —— 展开/收起按钮始终留在原位,避免点开时
内容把按钮挤到屏幕外,用户需要滚回去才能再点收起。
收起时完全不渲染内容区,只保留按钮(图标 + "思考过程")。
streamingLabel 已经在上方独立显示阶段进度,这里不重复 spinner。 */}
<button
type="button"
onClick={() => {
userTouchedRef.current = true;
setExpanded((v) => !v);
}}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
{expanded ? (
<ChevronUpIcon className="size-3" />
) : (
<ChevronDownIcon className="size-3" />
)}
思考过程
</button>
{expanded && (
<div className="max-h-96 overflow-y-auto rounded-md border border-border/50 bg-muted/30 px-3 py-2 text-xs text-foreground leading-relaxed">
<MarkdownContent
content={state.rationale}
isLoading={isStreaming}
rehypePlugins={rehypePlugins}
className="text-xs"
/>
</div>
)}
</>
)}
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
function Section({
title,
children,
collapsible = false,
defaultOpen = true,
}: {
title: string;
children: React.ReactNode;
/** True 时 header 可点击折叠/展开;false 保持原行为(始终展开,不可点击)。 */
collapsible?: boolean;
/** 仅当 collapsible=true 时生效。 */
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{title}
</div>
<div className="grid gap-2">{children}</div>
{collapsible ? (
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1 text-xs font-medium text-muted-foreground uppercase tracking-wide hover:text-foreground transition-colors cursor-pointer"
>
{open ? (
<ChevronUpIcon className="size-3" />
) : (
<ChevronDownIcon className="size-3" />
)}
<span>{title}</span>
</button>
) : (
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{title}
</div>
)}
{/* flex flex-col + w-full + items-stretch:替代原 `grid gap-2`,
强制所有 AgentCard 满父宽。grid 在某些浏览器对 <button> 子项的
justify-self: stretch 处理不一致,导致"推荐参会"与"其他候选"
两个 Section 视觉上有几像素宽度差。 */}
{(!collapsible || open) && (
<div className="flex w-full flex-col gap-2">{children}</div>
)}
</div>
);
}
@@ -571,7 +784,9 @@ function AgentCard({
type="button"
onClick={onToggle}
className={cn(
"text-left rounded-md border p-3 transition-colors flex gap-3 items-start",
// w-full:`<button>` 默认 inline-block,在 grid cell 里不一定拉满;
// 显式 w-full 保证推荐区与其他候选区的卡片**宽度严格一致**。
"w-full text-left rounded-md border p-3 transition-colors flex gap-3 items-start",
checked
? "border-primary bg-primary/5 hover:bg-primary/10"
: "border-border hover:bg-muted/40",
@@ -29,43 +29,89 @@ export function useAutoScroll<T extends HTMLElement>(
const atBottomRef = useRef(true);
const [atBottom, setAtBottom] = useState(true);
// Track scroll position. The handler runs for BOTH user-initiated scrolls
// and programmatic `scrollTo` calls — after a programmatic scroll to the
// bottom, the resulting onScroll naturally leaves atBottom=true, so the
// auto-follow effect can keep firing on subsequent deps changes.
// 自动跟随的"门"。区分**程序触发**和**用户触发**的滚动:
// - 程序滚动(`el.scrollTop = el.scrollHeight`)前会临时把 programmaticRef 置 true
// → 接下来一次 scroll 事件不修改 atBottom,避免自己把自己关掉。
// - 任何其它 scroll 事件视为用户主动滚动,按位置判定 atBottom。
// 这是修「鼠标拖滚动条没出现按钮」的关键:之前依赖 wheel/touchmove,
// 但鼠标拖滚动条**只触发 scroll 事件**,wheel/touchmove 完全不会触发。
const programmaticScrollRef = useRef(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
let prevScrollTop = el.scrollTop;
const onScroll = () => {
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
const currentScrollTop = el.scrollTop;
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
const isAtBottom = distanceFromBottom <= threshold;
if (atBottomRef.current !== isAtBottom) {
atBottomRef.current = isAtBottom;
setAtBottom(isAtBottom);
if (programmaticScrollRef.current) {
// 这是我们刚 scrollTop=scrollHeight 触发的回调,跳过,但仍刷新 prev 用于下次比较。
programmaticScrollRef.current = false;
prevScrollTop = currentScrollTop;
// 程序滚动后通常落在底部,把 atBottom 维持 true。
if (!atBottomRef.current && isAtBottom) {
atBottomRef.current = true;
setAtBottom(true);
}
return;
}
// 用户主动滚动:**任何向上滚的 scrollTop 减少都立刻退出自动跟随**,
// 不等位置算到底部 32px 之外才翻。这是按钮"滚动一点点就显示"的核心。
if (currentScrollTop < prevScrollTop && atBottomRef.current) {
atBottomRef.current = false;
setAtBottom(false);
} else if (isAtBottom && !atBottomRef.current) {
// 用户自己又滚回底部 → 恢复自动跟随
atBottomRef.current = true;
setAtBottom(true);
} else if (!isAtBottom && atBottomRef.current) {
// 兜底:scrollTop 没明显减少但距底部已超阈值(如内容增长而用户没动)
atBottomRef.current = false;
setAtBottom(false);
}
prevScrollTop = currentScrollTop;
};
el.addEventListener("scroll", onScroll, { passive: true });
// Initial measurement so we don't start in a stale state if the container
// mounted with content already overflowing.
// 初次测量
onScroll();
return () => el.removeEventListener("scroll", onScroll);
return () => {
el.removeEventListener("scroll", onScroll);
};
}, [ref, threshold]);
// Auto-scroll on deps change — but only if the user hasn't scrolled away.
// 自动跟随 effect。用 instant 同步赋值,避免 smooth 动画与用户拖动叠加打架。
// 在赋值前 **实时检查** scrollTop 位置(不依赖 atBottomRef),防止 ref 滞后于
// 用户刚刚的拖动事件 —— 这是修「拖动期间又被自动滚拽回」的关键。
useEffect(() => {
const el = ref.current;
if (!el) return;
if (!atBottomRef.current) return;
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
// 实时位置检测:即便 ref 还没翻成 false,只要当前 scrollTop 离底部超过阈值,
// 说明用户已经离开,绝不自动跟随。
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distanceFromBottom > threshold) {
// 顺手把 ref/state 同步到位,防止下次 onScroll 还没跑就被 effect 再次拽
if (atBottomRef.current) {
atBottomRef.current = false;
setAtBottom(false);
}
return;
}
programmaticScrollRef.current = true;
el.scrollTop = el.scrollHeight;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
// 按钮点击是用户主动行为,保留 smooth 给视觉反馈;期间用户若立刻又拖动,
// onScroll 的方向检测仍会把 atBottom 翻回 false,不会卡住。
const scrollToBottom = useCallback(() => {
const el = ref.current;
if (!el) return;
// 标记程序滚动:smooth 动画过程中浏览器派发的 scroll 事件不应被误判为用户主动滚动。
programmaticScrollRef.current = true;
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
// Optimistically mark as at-bottom so the next deps change resumes
// auto-follow without waiting for the smooth-scroll's onScroll to land.
atBottomRef.current = true;
setAtBottom(true);
}, [ref]);
@@ -44,6 +44,13 @@ import remarkGfm from "remark-gfm";
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtHeader,
ChainOfThoughtSearchResult,
ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { useModels } from "@/core/models/hooks";
@@ -111,6 +118,15 @@ import {
Loader2,
History,
Trash2,
// 步骤折叠卡的图标 —— 跟 chats/new 的 message-group ToolCall 用的一套保持一致,
// 让圆桌 Step 2 与主聊天里的"步骤"视觉语义统一。
Wrench as WrenchIcon,
Search as SearchIcon,
Lightbulb as LightbulbIcon,
FolderOpen as FolderOpenIcon,
BookOpenText as BookOpenTextIcon,
MessageCircleQuestionMark as MessageCircleQuestionMarkIcon,
Dot as DotIcon,
Pencil,
Upload,
} from "lucide-react";
@@ -251,6 +267,108 @@ function getDisplayNameFor(agent: SelectedAgent): string {
return BUILTIN_AGENT_META[agent.agent_id]?.displayName ?? agent.name;
}
/** 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。
* 数据来自前端 streamMultiAgent 解析 LangGraph messages-tuple 帧的 tool_calls[0].args。
* 与 chats/new 的 message-group ToolCall 子节点保持一致风格。 */
function renderStepArgs(key: string, args?: Record<string, unknown>): React.ReactNode {
if (!args) return null;
const [, toolName] = key.split(":");
if (!toolName) return null;
switch (toolName) {
case "agent_orchestration": {
const agentName = typeof args.agent_name === "string" ? args.agent_name : "";
const task = typeof args.task === "string" ? args.task : "";
if (!agentName && !task) return null;
return (
<>
{agentName && (
<ChainOfThoughtSearchResult>{agentName}</ChainOfThoughtSearchResult>
)}
{task && (
<span className="block text-[11px] text-slate-600 line-clamp-3 mt-0.5">
{task}
</span>
)}
</>
);
}
case "web_search": {
const query = typeof args.query === "string" ? args.query : "";
return query ? <ChainOfThoughtSearchResult>{query}</ChainOfThoughtSearchResult> : null;
}
case "read_file":
case "ls":
case "write_file":
case "str_replace": {
const path = typeof args.path === "string" ? args.path : "";
return path ? <ChainOfThoughtSearchResult>{path}</ChainOfThoughtSearchResult> : null;
}
case "bash": {
const command = typeof args.command === "string" ? args.command : "";
return command ? (
<code className="block bg-slate-100 rounded px-2 py-1 text-[11px] font-mono text-slate-800 mt-0.5 line-clamp-2 break-all">
{command}
</code>
) : null;
}
case "ask_clarification": {
const question = typeof args.question === "string" ? args.question : "";
return question ? (
<span className="block text-[11px] text-slate-600 line-clamp-2 mt-0.5">
{question}
</span>
) : null;
}
default: {
// 兜底:把 args 紧凑展示为 key=value chip
const entries = Object.entries(args).filter(
([, v]) => v !== null && v !== undefined && v !== "",
);
if (entries.length === 0) return null;
return (
<div className="flex flex-wrap gap-1">
{entries.slice(0, 4).map(([k, v]) => (
<ChainOfThoughtSearchResult key={k}>
<span className="opacity-60">{k}:</span>{" "}
{typeof v === "string" ? v : JSON.stringify(v)}
</ChainOfThoughtSearchResult>
))}
</div>
);
}
}
}
/** 根据 step.key(格式 `phase:toolName`)给出 ChainOfThoughtStep 的图标 + 文案,
* 与 chats/new 的 message-group ToolCall 视觉语义对齐 —— 工具调用走 WrenchIcon
* + 「使用 "X" 工具」;特殊工具按截图风格特化(搜索 → SearchIcon、文件 → 文件夹图标…)。
* 非工具阶段(connecting / streaming 等)走 fallback label + 简洁的指示点/灯泡图标。 */
function getStepDisplay(
key: string,
fallbackLabel: string,
): { icon: typeof DotIcon; label: string } {
const [phase, toolName] = key.split(":");
if (phase === "tool_calling" && toolName) {
switch (toolName) {
case "ask_clarification":
return { icon: MessageCircleQuestionMarkIcon, label: "需要你的协助" };
case "present_files":
return { icon: FolderOpenIcon, label: "展示文件" };
case "web_search":
return { icon: SearchIcon, label: "搜索相关信息" };
case "read_file":
return { icon: BookOpenTextIcon, label: "读取文件" };
default:
return { icon: WrenchIcon, label: `使用 "${toolName}" 工具` };
}
}
if (phase === "streaming") {
return { icon: LightbulbIcon, label: fallbackLabel };
}
// connecting / connected / tool_result / 其它 — 一律用小点 + fallback 文案
return { icon: DotIcon, label: fallbackLabel };
}
/**
* Build the seat-template list shown in the right-rail role panel.
* The commander always sits at id=1; agents take id=2..N+1, in the order
@@ -490,6 +608,10 @@ export default function RoundtablePlanningPage() {
// Step 2 dialogue list — populated by the real orchestration loop once the
// user enters the roundtable. Starts empty so we never flash mock content.
const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState<any[]>([]);
// 步骤折叠卡的展开覆盖 dict —— key 是 dialogue.id,value 是用户手动设置的展开状态。
// 未设置(undefined)时由「msg.streaming」决定:streaming 中默认展开,完成后自动收起。
// 用户点击 header 后写入 true/false,从此覆盖默认行为(用户的偏好优先)。
const [openSteps, setOpenSteps] = useState<Record<string | number, boolean>>({});
// Inputs for Step 2 conversation simulation controls
const [isRoundTableRunning, setIsRoundTableRunning] = useState<boolean>(true);
@@ -689,6 +811,49 @@ export default function RoundtablePlanningPage() {
);
}, []);
// 把一条新 phase 步骤追加到 dialogue 的 steps 历史里(参考 chats/new 的
// ChainOfThought 折叠卡)。key 由 phase + toolName 组合,**连续重复时不重复 push**,
// 避免 streaming 帧每来一次都重新打点产生同一行噪声。
// args 是 tool_calling 阶段携带的工具入参(来自上游 LangGraph 的 tool_calls[0].args),
// 渲染层根据工具类型展示路径 chip / 命令代码块 / 搜索词等子节点。
const appendStepIfNew = useCallback((
bubbleId: number,
label: string,
key: string,
args?: Record<string, unknown>,
) => {
setStep2RoundtableDialogues(prev =>
prev.map(d => {
if (d.id !== bubbleId) return d;
const steps: Array<{
id: string;
key: string;
label: string;
ts: string;
args?: Record<string, unknown>;
}> = d.steps ?? [];
const last = steps[steps.length - 1];
// 同 key 已存在 —— 但如果 args 是新拿到的(之前可能只有 toolName,这次有了完整 args),
// 还是要更新这一行;否则直接跳过。
if (last && last.key === key) {
if (args && !last.args) {
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
}
return d;
}
return {
...d,
steps: [
...steps,
{ id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args },
],
};
}),
);
}, []);
// Streaming variant: append a placeholder card and return its id so subsequent
// text deltas can be appended in-place via appendToDialogue.
const appendStreamingDialogue = useCallback((card: {
@@ -708,6 +873,8 @@ export default function RoundtablePlanningPage() {
time: nowTime(),
confidence: card.confidence ?? "正在输出…",
streaming: true,
// ChainOfThought 折叠卡用 —— phase 改变时 appendStepIfNew 往里塞。
steps: [] as Array<{ id: string; key: string; label: string; ts: string }>,
},
]);
return id;
@@ -875,8 +1042,10 @@ export default function RoundtablePlanningPage() {
model: selectedModel || undefined,
signal: leaderAborter.signal,
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName) => {
setDialogueConfidence(leaderBubbleId, phaseLabel(phase, "leader", toolName));
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(leaderBubbleId, label);
appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args);
},
});
} catch (err) {
@@ -952,13 +1121,19 @@ export default function RoundtablePlanningPage() {
agentType: "special",
dAgentThreadId: ids,
agentName: subName,
// 把中文显示名一起传后端,广播回 coordinator thread 时拼进
// 「子智能体X完成...」让 LLM 易识别 —— 非内置 agent 的 id 是
// UUID,只用 id 时总控认不出已完成的人,会无限循环派活。
displayName: meta?.displayName,
newMessage: task,
// 优先级: 席位 Persona 覆盖 → 全局选中模型 → 让后端回退到 config.yaml models[0]
model: perSeatModel || selectedModel || undefined,
signal: subAborter.signal,
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName) => {
setDialogueConfidence(subBubbleId, phaseLabel(phase, "special", toolName));
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(subBubbleId, label);
appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args);
},
});
if (orchestrationCancelRef.current) {
@@ -1001,23 +1176,28 @@ export default function RoundtablePlanningPage() {
clearAllStreamingSpeakers();
setIsOrchestrating(false);
}
}, [appendStreamingDialogue, appendToDialogue, finalizeDialogue, highlightSpeaker, markSpeakerStreaming, markSpeakerIdle, clearAllStreamingSpeakers, isOrchestrating, agentConfigs, agentNameToRole]);
}, [appendStreamingDialogue, appendToDialogue, appendStepIfNew, setDialogueConfidence, phaseLabel, finalizeDialogue, highlightSpeaker, markSpeakerStreaming, markSpeakerIdle, clearAllStreamingSpeakers, isOrchestrating, agentConfigs, agentNameToRole]);
// Called by RecommendAgentsDialog onConfirm: update the participant roster,
// reset step-2 init so the new coordinator is created for the new roster,
// then navigate to Step 2.
const handleConfirmAgents = useCallback((agentIds: string[]) => {
setSelectedAgents(
agentIds.map((id) => ({
agent_id: id,
name: BUILTIN_AGENT_META[id]?.displayName ?? id,
})),
);
step2InitRef.current = false;
setIsRecommendDialogOpen(false);
setCurrentStep(2);
setShowToast("成功确认!已切换至第二阶段圆桌研讨系统");
}, []);
const handleConfirmAgents = useCallback(
(agents: Array<{ agent_id: string; name: string }>) => {
setSelectedAgents(
agents.map(({ agent_id, name }) => ({
agent_id,
// 内置 agent 的中文显示名固定在 BUILTIN_AGENT_META 里(SOUL 也是按这个名);
// 非内置走对话框传过来的 name —— 来自候选池 agent.name,与 /api/agents 一致。
name: BUILTIN_AGENT_META[agent_id]?.displayName ?? name ?? agent_id,
})),
);
step2InitRef.current = false;
setIsRecommendDialogOpen(false);
setCurrentStep(2);
setShowToast("成功确认!已切换至第二阶段圆桌研讨系统");
},
[],
);
// Public stop handler used by the pause/stop button and the step-2 teardown.
// Sets the cancel flag and aborts every in-flight stream so the loop can
@@ -2154,8 +2334,8 @@ export default function RoundtablePlanningPage() {
{currentStep === 3 ? (
<>
{/* Panel 1: 任务成果概览 (Task Outcomes Overview) */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] transition hover:shadow-md">
{/* Panel 1: 任务成果概览 —— 暂时隐藏(数据仍是写死示例,等真实统计接入后再恢复) */}
<div className="hidden bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] transition hover:shadow-md">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Clipboard className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-black text-slate-900 uppercase tracking-widest">任务成果概览</h2>
@@ -2195,13 +2375,25 @@ export default function RoundtablePlanningPage() {
</div>
<div className="space-y-2 overflow-y-auto flex-1 pr-1 custom-scrollbar">
{/* 第一项固定是用户(指挥官);后面动态读 selectedAgents —— 这是用户在
推荐弹窗确认后选定的圆桌席位,跟第二步左侧角色面板一致。
内置 agent 用 BUILTIN_AGENT_META 的中文名 + 颜色;非内置(用户自建)
用 agent.name + FALLBACK_AVATAR_TYPES 轮换上色。 */}
{[
{ id: "human", name: "用户 (指挥官)", role: "人类介入协调", icon: <Man1Avatar size="h-7 w-7" /> },
{ id: "agent2", name: "任务理解与前置专家", role: "理解专家 (Gemini)", icon: <RobotAvatar headColor="#f5222d" size="h-7 w-7" /> },
{ id: "agent3", name: "极端条件信息情报专家", role: "情报情报 (Gemini)", icon: <PurpleRobotAvatar size="h-7 w-7" /> },
{ id: "agent4", name: "算力与热力调度专家", role: "算力评估 (Gemini)", icon: <EmeraldRobotAvatar size="h-7 w-7" /> },
{ id: "agent6", name: "安全合规及风控大管家", role: "安全主管 (Gemini)", icon: <AmberRobotAvatar size="h-7 w-7" /> },
{ id: "agent7", name: "执行序列和路径规划专家", role: "执行规划 (Gemini)", icon: <PinkRobotAvatar size="h-7 w-7" /> },
{
id: "human",
name: "用户 (指挥官)",
role: "人类介入协调",
icon: <Man1Avatar size="h-7 w-7" />,
},
...selectedAgents.map((agent, i) => ({
id: agent.agent_id,
name: getDisplayNameFor(agent),
role: BUILTIN_AGENT_META[agent.agent_id]
? `${BUILTIN_AGENT_META[agent.agent_id].displayName} 席位`
: "圆桌席位",
icon: getAvatar(getAvatarTypeFor(agent.agent_id, i), "h-7 w-7"),
})),
].map((contrib, idx) => (
<div key={idx} className="flex items-center justify-between p-2 rounded-xl bg-slate-50 border border-slate-100 transition hover:border-emerald-100 hover:bg-emerald-50/10">
<div className="flex items-center gap-2.5">
@@ -2224,8 +2416,8 @@ export default function RoundtablePlanningPage() {
</div>
</div>
{/* Panel 3: 版本记录 (Version History) */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] shrink-0">
{/* Panel 3: 版本记录 —— 暂时隐藏(无真实版本数据接入) */}
<div className="hidden bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] shrink-0">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Clock className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-black text-slate-900 uppercase tracking-widest">版本记录</h2>
@@ -2427,8 +2619,10 @@ export default function RoundtablePlanningPage() {
</span>
</div>
{/* Chat list viewport */}
<div className="flex-1 overflow-y-auto p-6 space-y-4 font-normal">
{/* Chat list viewport —— 与第二步消息列表同构:
外层 relative + flex-col 给 ScrollToBottomButton 浮在右下;
内层 step1ScrollRef 直接占满高度滚动,不再嵌套"问答交互区"卡片。 */}
<div className="relative flex-1 min-h-0 flex flex-col font-normal">
{/* Step 1 dialogue is rendered by `interactiveMessages.map()`
inside the 问答交互区 sub-block below. The static demo
@@ -2439,7 +2633,7 @@ export default function RoundtablePlanningPage() {
structured summary; otherwise shows a placeholder with
a disabled "进入下一步" button so the user knows what they
need to wait for. */}
{intentReady ? (
{/* {intentReady ? (
<div className="border border-emerald-200 bg-[#f0fdf4]/60 rounded-2xl p-4.5 shadow-[0_2px_12px_rgba(16,185,129,0.04)]">
<div className="flex items-center justify-between gap-3 border-b border-emerald-100 pb-3 mb-3.5">
<div className="flex items-center gap-2">
@@ -2535,28 +2729,14 @@ export default function RoundtablePlanningPage() {
</div>
)}
</div>
)}
)} */}
{/* --- Q&A INTERACTION AREA (问答交互区) --- */}
<div className="border border-red-100 rounded-2xl bg-slate-50/20 shadow-[0_2px_12px_rgba(245,34,45,0.01)] overflow-hidden">
{/* Inside header banner */}
<div className="px-4 py-2.5 bg-[var(--brand-200)]/40 border-b border-red-100/50 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<HelpCircle className="h-4 w-4 text-primary" strokeWidth={2.5} />
<span className="text-xs font-extrabold text-[var(--brand-950)]">
问答交互区
</span>
</div>
<span className="text-[10px] text-slate-400 font-medium">持续沟通可有效提供细节决策</span>
</div>
{/* Content dialogue lines inside interaction */}
<div className="relative">
<div
ref={step1ScrollRef}
className="p-4.5 space-y-3.5 max-h-[280px] overflow-y-auto"
>
{/* Step 1 消息列表 —— 直接铺在主视区,样式与第二步一致(满高 + 滚动 + 大间距)。
原"问答交互区"外框 + header banner 已移除。 */}
<div
ref={step1ScrollRef}
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white"
>
{interactiveMessages.map((msg) => {
const isStreaming = (msg as { streaming?: boolean }).streaming === true;
const hasContent = (msg.content ?? "").length > 0;
@@ -2581,28 +2761,48 @@ export default function RoundtablePlanningPage() {
parts.before.trim() === (clarification.question || "").trim()
? ""
: parts.before;
const phaseLabel = (msg as { phase?: string }).phase;
return (
<div key={msg.id} className="flex items-start justify-between gap-3 border-b border-slate-100/50 pb-2.5 last:border-0 last:pb-0">
<div className="flex items-start gap-2.5 min-w-0">
{msg.sender === "user" ? (
<Man1Avatar size="h-6 w-6" />
) : (
<RobotAvatar bg="bg-[var(--brand-200)]/50" size="h-6 w-6" />
)}
<div className="min-w-0">
<span className={`text-[10.5px] font-bold block mb-0.5 ${msg.sender === 'user' ? 'text-primary' : 'text-slate-700'}`}>
{msg.senderName}
</span>
{/* Pre-text spinner: shown only when streaming AND no content has arrived.
Caption reflects the inferred SSE phase so users see what the
backend is doing during the first-token wait. */}
{isStreaming && !hasContent ? (
<span className="inline-flex items-center gap-1.5 text-[11px] text-slate-400 italic">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
{(msg as { phase?: string }).phase ?? "正在连接智能体..."}
<div
key={msg.id}
className="flex gap-4 items-start"
>
{msg.sender === "user" ? (
<Man1Avatar size="h-9 w-9 shrink-0" />
) : (
<RobotAvatar bg="bg-[var(--brand-200)]/50" size="h-9 w-9 shrink-0" />
)}
<div className="flex-1 min-w-0 space-y-1">
{/* Header 行:发言人 + 阶段标签 + 时间 —— 与第二步气泡一致。 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`text-xs font-extrabold ${msg.sender === 'user' ? 'text-primary' : 'text-slate-900'}`}>
{msg.senderName}
</span>
) : isAgent ? (
<div className="text-xs text-slate-700 leading-normal font-normal break-words space-y-2">
{isStreaming && phaseLabel && (
<span className="text-[9px] font-mono font-black uppercase text-[var(--brand-900)] bg-[var(--brand-200)] border border-red-200 px-1.5 py-0.2 rounded">
{phaseLabel}
</span>
)}
</div>
<span className="text-[10px] text-slate-400 font-mono font-medium shrink-0">
{msg.time}
</span>
</div>
{/* 内容区:streaming + 无内容 → 大 spinner + 三点动画;有内容 → markdown / 用户文本。 */}
{isStreaming && !hasContent ? (
<div className="flex items-center gap-2 py-1.5 text-xs text-slate-600">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="font-medium">{phaseLabel ?? "正在连接智能体..."}</span>
<span className="inline-flex items-center gap-0.5">
<span className="w-1 h-1 rounded-full bg-primary/70 animate-bounce [animation-delay:-0.3s]" />
<span className="w-1 h-1 rounded-full bg-primary/70 animate-bounce [animation-delay:-0.15s]" />
<span className="w-1 h-1 rounded-full bg-primary/70 animate-bounce" />
</span>
</div>
) : isAgent ? (
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md">
{introText && (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
@@ -2705,22 +2905,16 @@ export default function RoundtablePlanningPage() {
)}
</div>
) : (
<span className="text-xs text-slate-700 leading-normal font-normal whitespace-pre-wrap break-words">
<div className="text-xs text-slate-700 leading-relaxed font-medium whitespace-pre-wrap break-words roundtable-md">
{msg.content}
</span>
</div>
)}
</div>
</div>
<span className="text-[9px] text-slate-400 font-mono shrink-0">{msg.time}</span>
</div>
);
})}
</div>
{!step1AtBottom && <ScrollToBottomButton onClick={scrollStep1ToBottom} />}
</div>
</div>
{!step1AtBottom && <ScrollToBottomButton onClick={scrollStep1ToBottom} />}
</div>
{/* Botton Input Drawer Area (Ask/Reply Box) */}
@@ -2897,7 +3091,7 @@ export default function RoundtablePlanningPage() {
{/* Collapsed handle: when the upper panel is hidden, show a thin
bar at the top of the conversation area so the user can bring
it back. */}
{isUpperPanelCollapsed && (
{false && isUpperPanelCollapsed && (
<div className="shrink-0 px-6 py-2 border-b border-slate-100 bg-white flex items-center justify-center">
<button
onClick={() => setIsUpperPanelCollapsed(false)}
@@ -2911,7 +3105,7 @@ export default function RoundtablePlanningPage() {
)}
{/* Split layout: Roundtable visualizer and Interactive Controllers */}
{!isUpperPanelCollapsed && (
{false && !isUpperPanelCollapsed && (
<div className="shrink-0 px-6 py-5 bg-gradient-to-b from-[var(--brand-200)]/10 to-transparent border-b border-slate-100 bg-white relative">
{/* Close button — collapses this whole upper panel so the
conversation log takes the freed vertical space. */}
@@ -3252,6 +3446,17 @@ export default function RoundtablePlanningPage() {
ref={step2ScrollRef}
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner"
>
{/* 初始化期间(创建协调智能体 + 各席位 thread)对话列表是空的,
会有 1-3 秒 UI 空白让用户感觉卡住 —— 加居中加载占位,
用文案告诉用户后端正在做什么。dialogues.length === 0 收紧条件,
避免后续 reset 时短暂闪过(reset 完会立即 init,有覆盖期)。 */}
{isInitializing && step2RoundtableDialogues.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-slate-500">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<div className="text-sm font-medium text-slate-700">正在初始化圆桌...</div>
<div className="text-xs text-slate-400">正在为各席位建立独立线程,请稍候</div>
</div>
)}
{step2RoundtableDialogues.map((msg) => (
<div
key={msg.id}
@@ -3270,16 +3475,100 @@ export default function RoundtablePlanningPage() {
</div>
<span className="text-[10px] text-slate-400 font-mono font-medium">{msg.time}</span>
</div>
{/* 步骤折叠卡(视觉 + 文案与 chats/new 消息列表的 ChainOfThought 对齐):
位于生成内容**上方**;**始终默认收起**(避免抢占注意力),
收起 header 上显示最新一步的图标 + 文案作为预览。
openSteps[msg.id] 用户手动覆盖优先。
每一步根据 step.key(`phase:toolName`)分配工具图标 + 中文化文案,
例如 `tool_calling:agent_orchestration` → 扳手 + 「使用 "..." 工具」。 */}
{Array.isArray(msg.steps) && msg.steps.length > 0 && (() => {
const stepsOpen = openSteps[msg.id] ?? false;
const lastStep = msg.steps[msg.steps.length - 1];
const lastDisplay = lastStep
? getStepDisplay(lastStep.key, lastStep.label)
: null;
const LastIcon = lastDisplay?.icon;
return (
<ChainOfThought
className="rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2"
open={stepsOpen}
onOpenChange={(v) => setOpenSteps((prev) => ({ ...prev, [msg.id]: v }))}
>
<ChainOfThoughtHeader
className="text-[11px]"
icon={
// 仅在「生成中 + 收起」时显示最新一步的工具图标(作进度预览);
// 完成后用默认脑子图标 + 「步骤列表」标题,提示这是历史时间线。
!stepsOpen && msg.streaming && LastIcon ? (
<LastIcon className="size-3.5 text-slate-500" />
) : undefined
}
>
{stepsOpen
? "步骤"
: msg.streaming
? (lastDisplay?.label ?? `查看 ${msg.steps.length} 个步骤`)
: "步骤列表"}
</ChainOfThoughtHeader>
<ChainOfThoughtContent>
{msg.steps.map(
(
s: {
id: string;
key: string;
label: string;
ts: string;
args?: Record<string, unknown>;
},
i: number,
) => {
const display = getStepDisplay(s.key, s.label);
const argNode = renderStepArgs(s.key, s.args);
return (
<ChainOfThoughtStep
key={s.id}
icon={display.icon}
label={
<span className="text-[12px] text-slate-700">
<span className="font-medium">{display.label}</span>
<span className="ml-2 text-[10px] text-slate-400 font-mono">{s.ts}</span>
</span>
}
status={
msg.streaming && i === msg.steps.length - 1
? "active"
: "complete"
}
>
{argNode}
</ChainOfThoughtStep>
);
},
)}
</ChainOfThoughtContent>
</ChainOfThought>
);
})()}
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words roundtable-md">
{/* Pre-streaming hint — when the bubble is created but
no AI token has arrived yet, show a small spinner
+ the current phase label so the user can see the
backend is doing something (调度中 → 已连接 →
正在派活/调用工具 → 流式输出). */}
{/* 气泡刚创建、token 还没到 —— 显示更显眼的"思考中"动画。
首字节通常 1-2s,之前是 12px 行内小 spinner,用户感受不到
后端在工作。换成稍大 spinner + 跳动点,与气泡正文同色阶。 */}
{msg.streaming && !msg.content && (
<div className="flex items-center gap-2 py-1 text-[11px] text-slate-500 italic">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
<span>{msg.confidence}</span>
<div className="flex items-center gap-2 py-1.5 text-xs text-slate-600">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="font-medium">{msg.confidence}</span>
<span className="inline-flex items-center gap-0.5">
<span className="w-1 h-1 rounded-full bg-primary/70 animate-bounce [animation-delay:-0.3s]" />
<span className="w-1 h-1 rounded-full bg-primary/70 animate-bounce [animation-delay:-0.15s]" />
<span className="w-1 h-1 rounded-full bg-primary/70 animate-bounce" />
</span>
</div>
)}
<ReactMarkdown
@@ -3505,13 +3794,21 @@ export default function RoundtablePlanningPage() {
{/* ========================================================
RIGHT COLUMN (Current Intent Summary, Timeline/Logs)
第三步时所有 panel 都已隐藏 → 整个 right-sidebar 也不渲染,
让中间方案展示区自适应铺满空间。
======================================================== */}
<section id="right-sidebar" className="w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1">
<section
id="right-sidebar"
className={cn(
"w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1",
currentStep === 3 && "hidden",
)}
>
{currentStep === 3 ? (
<>
{/* Panel 1: 最终共识结论 (Final Consensus Conclusion) */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] transition hover:shadow-md">
{/* Panel 1: 最终共识结论 —— 暂时隐藏(等真实研讨结论接入后再恢复) */}
<div className="hidden bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] transition hover:shadow-md">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3.5">
<CheckCircle className="w-4 h-4 text-emerald-500" strokeWidth={2.5} />
<h2 className="text-xs font-black text-slate-900 uppercase tracking-widest">最终共识结论</h2>
@@ -3559,8 +3856,8 @@ export default function RoundtablePlanningPage() {
</div>
</div>
{/* Panel 2: 风险与预算摘要 (Risks & Budget Summary) */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] transition hover:shadow-md flex-1 flex flex-col min-h-[300px]">
{/* Panel 2: 风险与预算摘要 —— 暂时隐藏(写死示例,等真实风险/预算数据接入再恢复) */}
<div className="hidden bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] transition hover:shadow-md flex-1 flex flex-col min-h-[300px]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3 shrink-0">
<AlertTriangle className="w-4 h-4 text-amber-500" strokeWidth={2.5} />
<h2 className="text-xs font-black text-slate-900 uppercase tracking-widest">风险与预算摘要</h2>
@@ -3626,8 +3923,8 @@ export default function RoundtablePlanningPage() {
</div>
</div>
{/* Panel 3: 导出与交付 (Export and Delivery) */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] shrink-0">
{/* Panel 3: 导出与交付 —— 暂时隐藏(无真实导出能力接入) */}
<div className="hidden bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] shrink-0">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Share2 className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-black text-slate-900 uppercase tracking-widest">导出与交付</h2>