Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
67152f1c83
14 files changed
+1566
-183
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user