"use client"; import { BotIcon, Compass, PanelLeftCloseIcon, PanelLeftOpenIcon, PanelRightOpenIcon, PlusSquare, } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { useLocation } from "react-router-dom"; import { useRouteBase } from "@/core/route-base"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { Button } from "@/components/ui/button"; import { AgentChatSidebar } from "@/components/workspace/agents/agent-chat-sidebar"; import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel"; import { AgentQuestionChips } from "@/components/workspace/agents/agent-question-chips"; import { AgentGuide, isAgentGuideEnvironment, startAgentGuide, } from "@/components/workspace/agents/agent-guide"; import { AgentWelcome } from "@/components/workspace/agent-welcome"; import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { listThreadArtifacts } from "@/core/artifacts/listing"; import { ChatBox, useThreadChat } from "@/components/workspace/chats"; import { ExportTrigger } from "@/components/workspace/export-trigger"; import { InputBox } from "@/components/workspace/input-box"; import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector"; import { MessageList } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; import { QuestionNavigator } from "@/components/workspace/question-navigator"; import { ThreadTitle } from "@/components/workspace/thread-title"; import { TodoList } from "@/components/workspace/todo-list"; import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; import { Tooltip } from "@/components/workspace/tooltip"; import { normalizeAgentQuestions, useAgent } from "@/core/agents"; import { getAPIClient } from "@/core/api"; import { useTheme } from "@/contexts/ThemeContext"; import { useI18n } from "@/core/i18n/hooks"; import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit"; import { useModels } from "@/core/models/hooks"; import { useNotification } from "@/core/notification/hooks"; import { saveLastAgentId, useThreadSettings } from "@/core/settings"; import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks"; import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed"; import { clearResearchReportStream } from "@/core/threads/use-research-report-stream"; import { textOfMessage } from "@/core/threads/utils"; import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; import { OptType, writeYLog } from "@/core/log/y-log"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { renderTemplateFill, useHtmlTemplates, type TemplateRenderResult, } from "@/core/scheduled-tasks"; import { env } from "@/env"; import { readTaskContext, taskThreadMetadata, type TaskContext, } from "@/core/auth/task-purpose"; import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel"; import { desensitize } from "@/lib/desensitize"; import { buildTaskOpeningText, readActionDetail, readCopTaskDetail, } from "@/core/auth/task-deeplink"; import { hasTaskReportImportApproval } from "@/core/messages/utils"; import type { RwfxSqGateStartContext } from "@/strategy-components/components/task-components/rwfx-sq-gate"; import { TaskReportImportCard } from "@/strategy-components/components/task-components/TaskReportImportCard"; import { AGENTFX_REPORT_JSON_OUTPUT_PATH, notifyTaskReportSaved, } from "@/strategy-components/lib/agentfx-report-schema"; import { cn } from "@/lib/utils"; import type { Message } from "@langchain/langgraph-sdk"; /** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */ const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder"; /** 实验性页面研报智能体:发送当前界面明暗模式供其选择同色系 HTML 骨架。 */ const PAGE_REPORT_DESIGNER_AGENT_ID = "page-report-designer"; const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000; const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000; function isTaskReportsJsonPath(path: string | undefined): boolean { const normalized = (path ?? "").replace(/\\/g, "/"); return ( normalized === "task-reports.json" || normalized.endsWith("/task-reports.json") ); } function readPendingKnowledgeSelection(agentId: string | undefined): string[] | null { if (typeof window === "undefined" || !agentId) return null; try { const raw = sessionStorage.getItem("pendingAgentMessage"); if (!raw) return null; const parsed = JSON.parse(raw) as { agentId?: string; knowledgeBaseIds?: unknown; }; if (parsed.agentId !== agentId || !Array.isArray(parsed.knowledgeBaseIds)) { return null; } return parsed.knowledgeBaseIds .map((item) => String(item).trim()) .filter(Boolean); } catch { return null; } } function readPendingAssistantKnowledgeSelection(agentId: string | undefined): string[] | null { if (typeof window === "undefined" || !agentId) return null; try { const raw = sessionStorage.getItem("pendingAgentMessage"); if (!raw) return null; const parsed = JSON.parse(raw) as { agentId?: string; assistantKnowledgeBaseIds?: unknown; }; if (parsed.agentId !== agentId || !Array.isArray(parsed.assistantKnowledgeBaseIds)) { return null; } return parsed.assistantKnowledgeBaseIds .map((item) => String(item).trim()) .filter(Boolean); } catch { return null; } } function stringifyForHiddenContext(value: unknown): string { if (typeof value === "string") return value; try { const text = JSON.stringify(value, null, 2); return typeof text === "string" ? text : ""; } catch { return String(value ?? ""); } } function truncateMiddle(text: string, maxChars: number): string { if (text.length <= maxChars) return text; const marker = "\n\n...[内容过长,已截断中间部分]...\n\n"; const tailChars = Math.min(RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS, Math.floor(maxChars / 4)); const headChars = Math.max(0, maxChars - marker.length - tailChars); return `${text.slice(0, headChars)}${marker}${text.slice(-tailChars)}`; } function compactSqReportForContext(report: unknown): unknown { if (report == null) return null; const serialized = stringifyForHiddenContext(report); if (serialized.length <= RWFX_SQ_REPORT_CONTEXT_MAX_CHARS) return report; return { truncated: true, originalChars: serialized.length, maxChars: RWFX_SQ_REPORT_CONTEXT_MAX_CHARS, content: truncateMiddle(serialized, RWFX_SQ_REPORT_CONTEXT_MAX_CHARS), }; } function rwfxSqReportRunContext( gateContext?: RwfxSqGateStartContext, ): Record | undefined { if (!gateContext?.sqReportStatus) return undefined; return { task_external_context: { kind: "rwfx_sq_report", status: gateContext.sqReportStatus, title: "三情分析(3q)研判情报报告", usage: gateContext.sqReportStatus === "complete" ? "这是任务分析(rwfx)问答的隐藏业务上下文。回答时应参考这些已完成的 3q 结果;如 data.truncated=true,说明内容已截断,只能基于可见片段分析。不要向用户说明页面注入了隐藏上下文,除非用户明确询问。" : "3q 研判情报报告当前未提供有效结果。回答时不要编造 3q 结果,可仅基于任务信息继续分析。", data: compactSqReportForContext(gateContext.sqReport), }, }; } /** * 任务工作区(taskBind:3qfx/xdfx/agentfx)每次运行的隐藏任务上下文:把任务名称/ * 内容/id(含开场白原文)经 ``task_external_context`` 随 run context 发给后端, * 由 TaskExternalContextMiddleware 在每次模型调用前注入为隐藏消息(不进聊天记录)。 * 解决「新建对话后消息历史里没有开场信息、模型不知道任务id」的问题——任何新对话、 * 旧对话续聊、上下文自恢复后模型都能拿到任务id,不需要向用户询问。 */ function taskBindRunContext( taskContext: TaskContext | null, ): Record | undefined { if (!taskContext?.taskId) return undefined; const detail = readCopTaskDetail(); return { task_external_context: { kind: "task_bind_opening", title: "任务开场信息", usage: "这是任务工作区随每次运行提供的隐藏任务上下文,不是用户的新输入。当前任务的" + "任务id(task_id)以此为准,直接使用,不要向用户询问、确认或让用户补充任务id;" + "回答时结合该任务背景。不要向用户说明页面注入了隐藏上下文。", data: { 任务名称: detail?.taskName ?? "", 任务内容: (detail?.taskContent ?? "") || (detail?.overview ?? ""), 任务id: taskContext.taskId, 开场信息原文: taskContext.openingText, }, }, }; } export default function AgentChatPage({ embeddedAgentId, chatBasePath, taskGate, taskBind, autoStartWhenPurposeEmpty, showAgentList, compactAppearanceActions, }: { /** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。 * 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */ embeddedAgentId?: string; /** 智能体对话所在的路由前缀(到 `/chats` 为止,不含 thread_id)。 * 默认 `${routeBase}/agents/${agent_id}/chats`;任务深链把对话嵌进「任务管理」布局时, * 传入如 `/page/strategy/task-management/plan-task-sentiment/agent/${id}/chats`, * 使开新会话 / 首条消息后的 URL 改写都停留在任务管理上下文(侧边栏保持对应业务高亮)。 */ chatBasePath?: string; /** rwfx 任务深链闸门:新建对话时顶部展示目的树面板(覆盖确认后才开始问答), * 并给新建 thread 打 `metadata.taskId` 标签(任务↔对话 1:N)。见 task-purpose。 */ taskGate?: boolean; /** 3qfx / xdfx 任务深链:仅做任务↔对话 1:N 绑定(打 `metadata.taskId` 标签 + 切换会话停旧 run), * 不展示目的树闸门;新建对话直接把任务开场信息自动发出。与 `taskGate` 互斥使用。 */ taskBind?: boolean; /** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */ autoStartWhenPurposeEmpty?: boolean; /** 是否在最左侧展示「智能体列表」栏(顶部下拉切换 我的/广场,点击进入该智能体对话)。 * 仅独立的智能体问答整页开启;任务工作区 / 模块页 / 嵌入态不传(各有自己的左栏)。 */ showAgentList?: boolean; /** 简洁模式下,挂到右侧智能体信息面板收起按钮左侧的外观控件。 */ compactAppearanceActions?: ReactNode; } = {}) { const routeBase = useRouteBase(); const { t } = useI18n(); const { baseMode } = useTheme(); const [showFollowups, setShowFollowups] = useState(false); const router = useRouter(); // 独立智能体页的左列是 AgentChatSidebar,而不是外层 WorkspaceSidebar; // 由本页维护其收展状态,避免顶部按钮切到了不可见的另一层侧栏。 const [agentSidebarOpen, setAgentSidebarOpen] = useState(true); const { agent_id: agentIdFromPath } = useParams<{ agent_id: string; }>(); const agent_id = embeddedAgentId ?? agentIdFromPath; const isEmbedded = !!embeddedAgentId; // 对话路由前缀:调用方可覆盖(任务深链嵌入态),否则用当前 routeBase 下的独立智能体对话路径。 const agentChatsBase = chatBasePath ?? `${routeBase}/agents/${agent_id}/chats`; // 任务深链上下文(taskId + 开场信息):闸门(rwfx)与纯绑定(3qfx/xdfx)都要读取。 const taskCtxOn = !!(taskGate || taskBind); // 右侧智能体详情面板折叠态(受控):普通问答默认展开,任务工作区默认收起。 // 收起时整列宽度动画关闭;展开态的「收起」按钮在面板右上角,收起态的 // 「展开」按钮在顶部 header「新对话」按钮右侧。 const [detailCollapsed, setDetailCollapsed] = useState(taskCtxOn); const isXdfxTaskBind = !!chatBasePath?.includes("/plan-task-action/"); const taskContext = useMemo(() => { if (!taskCtxOn) return null; const stored = readTaskContext(); if (stored && stored.agentId === agent_id) return stored; // 3qfx:若任务上下文偶发丢失,但登录深链留下的任务详情仍在,则用它前端兜底重建 // taskId + openingText,避免 landing 既不自动开始、也没有“开始回答”按钮。 if (taskBind && !taskGate) { const detail = readCopTaskDetail(); const fallbackTaskId = detail?.id == null ? "" : String(detail.id).trim(); if (detail && fallbackTaskId && agent_id) { const actionDetail = isXdfxTaskBind ? readActionDetail() : null; const actionId = actionDetail?.id ? String(actionDetail.id).trim() : ""; const actionTaskId = actionDetail?.taskId ? String(actionDetail.taskId).trim() : ""; const hasMatchingAction = !!actionId && !!actionTaskId && actionTaskId === fallbackTaskId; const actionInfo = hasMatchingAction ? { actionId, actionName: actionDetail?.actionName, narration: actionDetail?.narration, } : undefined; return { taskId: fallbackTaskId, agentId: agent_id, openingText: buildTaskOpeningText(detail, fallbackTaskId, actionInfo), ...(hasMatchingAction ? { actionId, partitionByAction: true } : {}), }; } } return stored; }, [agent_id, isXdfxTaskBind, taskBind, taskCtxOn, taskGate]); const taskId = taskCtxOn && taskContext && taskContext.agentId === agent_id ? taskContext.taskId : ""; // 纯绑定(taskBind,无闸门,3qfx/xdfx):新建对话进问答页后,右上角「开始回答」按钮点击时 // 发出的任务开场信息。不再自动发(避免新建对话竞态重复发送),改由用户手动点按钮触发。 // rwfx 走目的树闸门「开始问答」,不在此列。 const taskStartText = taskBind && !taskGate && taskContext && taskContext.agentId === agent_id ? (taskContext.openingText?.trim() ?? "") : ""; // 「首次进入且该任务无历史」信号:由 TaskFxIndexResolver 在「无聊天对话 → /new」时注入 // (用户主动点「新建对话」不带)。3qfx/xdfx 据此自动发出开场词、跳过「开始回答」按钮; // rwfx 据此决定目的树为空时是否自动开始(仍由 TaskPurposePanel 在有数据时弹覆盖确认)。 const location = useLocation(); const taskAutoStart = !!(location.state as { taskAutoStart?: boolean } | null)?.taskAutoStart; // If ChatPage stashed an opening message for this agent, skip the landing // screen on first render so the user doesn't see a flash of the welcome UI // before the auto-sent message takes over. const hasPendingForThisAgent = () => { if (typeof window === "undefined" || !agent_id) return false; try { const raw = sessionStorage.getItem("pendingAgentMessage"); if (!raw) return false; const parsed = JSON.parse(raw); return parsed?.agentId === agent_id && !!parsed?.text; } catch { return false; } }; const [hasStartedConversation, setHasStartedConversation] = useState( () => hasPendingForThisAgent(), ); const { agent, isLoading: isAgentLoading } = useAgent(agent_id); const agentDisplayName = desensitize( agent?.name ?? (isAgentLoading ? t.common.loading : ""), ); const commonQuestions = useMemo( () => normalizeAgentQuestions(agent?.common_questions), [agent?.common_questions], ); const { threadId, setThreadId, isNewThread, setIsNewThread } = useThreadChat(); const [settings, setSettings] = useThreadSettings(threadId); const { tokenUsageEnabled } = useModels(); const pendingKnowledgeSelection = useRef( readPendingKnowledgeSelection(agent_id), ); const pendingAssistantKnowledgeSelection = useRef( readPendingAssistantKnowledgeSelection(agent_id), ); const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState( () => pendingKnowledgeSelection.current ?? [], ); const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState( () => pendingAssistantKnowledgeSelection.current ?? [], ); const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState( () => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null, ); const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false); const knowledgeSelectionThread = useRef(null); const knowledgeSelectionAgent = useRef(agent_id); const activeThreadIdRef = useRef(threadId); const changeKnowledgeSelection = useCallback((ids: string[]) => { setSelectedKnowledgeBaseIds(ids); setKnowledgeSelectionReady(true); }, []); const changeAssistantKnowledgeSelection = useCallback((ids: string[]) => { setSelectedAssistantKnowledgeBaseIds(ids); setKnowledgeSelectionReady(true); }, []); useEffect(() => { activeThreadIdRef.current = threadId; }, [threadId]); useEffect(() => { if (knowledgeSelectionAgent.current === agent_id) return; knowledgeSelectionAgent.current = agent_id; const pending = readPendingKnowledgeSelection(agent_id); const pendingAssistant = readPendingAssistantKnowledgeSelection(agent_id); pendingKnowledgeSelection.current = pending; pendingAssistantKnowledgeSelection.current = pendingAssistant; knowledgeSelectionThread.current = null; setSelectedKnowledgeBaseIds(pending ?? []); setSelectedAssistantKnowledgeBaseIds(pendingAssistant ?? []); setKnowledgeSelectionReady(pending !== null || pendingAssistant !== null); }, [agent_id]); useEffect(() => { if (isNewThread) { if (knowledgeSelectionThread.current !== "__new__") { setSelectedKnowledgeBaseIds(pendingKnowledgeSelection.current ?? []); setSelectedAssistantKnowledgeBaseIds(pendingAssistantKnowledgeSelection.current ?? []); setKnowledgeSelectionReady( pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null, ); } knowledgeSelectionThread.current = "__new__"; setKnowledgeSelectionLoading(false); return; } if (!threadId || knowledgeSelectionThread.current === threadId) return; knowledgeSelectionThread.current = threadId; setKnowledgeSelectionLoading(true); void getAPIClient() .threads.get(threadId) .then((record) => { if (knowledgeSelectionThread.current !== threadId) return; const metadata = record.metadata as Record | undefined; const raw = metadata?.llmwiki_knowledge_base_ids; const assistantRaw = metadata?.assistant_knowledge_base_ids; const llmwikiIds = Array.isArray(raw) ? raw.map((item) => String(item).trim()).filter(Boolean) : []; const assistantIds = Array.isArray(assistantRaw) ? assistantRaw.map((item) => String(item).trim()).filter(Boolean) : []; if (Array.isArray(raw)) { setSelectedKnowledgeBaseIds(llmwikiIds); } else { setSelectedKnowledgeBaseIds([]); } if (Array.isArray(assistantRaw)) { setSelectedAssistantKnowledgeBaseIds(assistantIds); } else { setSelectedAssistantKnowledgeBaseIds([]); } setKnowledgeSelectionReady(Array.isArray(raw) || Array.isArray(assistantRaw)); }) .catch(() => { if (knowledgeSelectionThread.current === threadId) { setSelectedKnowledgeBaseIds([]); setSelectedAssistantKnowledgeBaseIds([]); setKnowledgeSelectionReady(false); } }) .finally(() => { if (knowledgeSelectionThread.current === threadId) { setKnowledgeSelectionLoading(false); } }); }, [isNewThread, threadId]); useEffect(() => { if (isNewThread) { setHasStartedConversation(hasPendingForThisAgent()); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isNewThread, threadId, agent_id]); const { showNotification } = useNotification(); const conversationMetadata = useMemo( () => ({ ...(taskId ? taskThreadMetadata(taskContext) : {}), ...(knowledgeSelectionReady ? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds, assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds, } : {}), }), [knowledgeSelectionReady, selectedAssistantKnowledgeBaseIds, selectedKnowledgeBaseIds, taskContext, taskId], ); // 任务工作区(taskBind):每次运行都带隐藏任务上下文(任务id/名称/内容), // 新建对话的消息历史里没有开场信息时模型也能拿到任务id(见 taskBindRunContext)。 // useMemo 缓存:流式期间 render 频繁,避免每次都读 sessionStorage + JSON.parse。 const taskBindRunContextValue = useMemo( () => (taskBind && !taskGate ? taskBindRunContext(taskContext) : undefined), [taskBind, taskGate, taskContext], ); const [thread, sendMessage] = useThreadStream({ threadId: isNewThread ? undefined : threadId, assistantId: agent_id, context: { ...settings.context, agent_id, ...(knowledgeSelectionReady ? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds, assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds, } : {}), ...(taskBindRunContextValue ?? {}), }, // 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤; // xdfx 额外打 metadata.actionId 标签,按「行动id」划分聊天记录(见 taskThreadMetadata)。 threadMetadata: Object.keys(conversationMetadata).length > 0 ? conversationMetadata : undefined, onStart: (createdThreadId) => { knowledgeSelectionThread.current = createdThreadId; activeThreadIdRef.current = createdThreadId; setThreadId(createdThreadId); setIsNewThread(false); // Keep the current component mounted while updating the hash route. history.replaceState( null, "", `${window.location.pathname}${window.location.search}#${agentChatsBase}/${createdThreadId}`, ); }, onFinish: (state) => { // 操作日志(Y-Log):专家代理问答完成(成功)后,用最终汇总文本上报一条。 try { const msgs = state.messages ?? []; const lastAi = [...msgs].reverse().find((m) => (m as { type?: string }).type === "ai"); const lastHuman = [...msgs].reverse().find((m) => (m as { type?: string }).type === "human"); writeYLog({ optType: OptType.other, optDetail: "CM助手-全量-专家代理问答", url: `/api/threads/${threadId ?? ""}/runs/stream`, reqParam: { agent_id, question: lastHuman ? textOfMessage(lastHuman) : "" }, returnParam: { data: lastAi ? textOfMessage(lastAi) : "" }, }); } catch { /* 上报绝不影响问答 */ } queueLlmWikiConversationDeposit({ threadId: activeThreadIdRef.current, messages: state.messages, assistantId: agent_id, knowledgeBaseIds: selectedKnowledgeBaseIds, }); if (document.hidden || !document.hasFocus()) { let body = "Conversation finished"; const lastMessage = state.messages[state.messages.length - 1]; if (lastMessage) { const textContent = textOfMessage(lastMessage); if (textContent) { body = textContent.length > 200 ? textContent.substring(0, 200) + "..." : textContent; } } showNotification(state.title, { body }); } }, }); // 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md // 流式 stub),成功且真实产物落地后自动撤出。 const { displayThread } = useResearchWritingFeed(threadId, thread); // ── agentfx「入库」协助卡 ────────────────────────────────────────────────── // 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且 // 本轮流式结束后,向消息流尾部注入「是否入库」卡片。本地沙箱下 artifacts 可能 // 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。转换脚本已函数合并 // 写出该文件,即使模型中断、没跑 present_files,也再扫一遍 outputs 目录。 // 任务已有详情**不抑制**卡片(入库是整体替换,由卡片文案说明覆盖语义); // 已导入按 thread 记忆在 sessionStorage(刷新后不再重复询问)、 // 「暂不入库」为本次会话内记忆。 const reportImportSessionKey = threadId ? `agentfx.reportImport.done.${threadId}` : ""; const [reportImportHidden] = useState(() => { if (!reportImportSessionKey || typeof window === "undefined") return false; try { return sessionStorage.getItem(reportImportSessionKey) === "1"; } catch { return false; } }); const [reportImportDismissed, setReportImportDismissed] = useState(false); const listedReportJsonQuery = useQuery({ queryKey: ["agentfx-task-reports", threadId], queryFn: ({ signal }) => listThreadArtifacts(threadId, signal), enabled: Boolean( taskBind && !taskGate && threadId && threadId !== "new" && !thread.isLoading, ), retry: false, }); const reportJsonArtifactReady = (thread.values.artifacts ?? []).some(isTaskReportsJsonPath) || (listedReportJsonQuery.data?.files ?? []).some((file) => isTaskReportsJsonPath(file.path) || file.name === "task-reports.json", ); const showReportImportCard = taskBind && !taskGate && !!taskId && reportJsonArtifactReady && !thread.isLoading && !reportImportHidden && !reportImportDismissed; const markReportImported = useCallback(() => { // 只写会话标记不改本地 state:导入成功的卡片保持挂载展示「已入库」, // 刷新/重进后因标记存在不再重复出现。 if (!reportImportSessionKey) return; try { sessionStorage.setItem(reportImportSessionKey, "1"); } catch { /* noop */ } }, [reportImportSessionKey]); // ── 技能入库成功 → 通知外部宿主(iframe 嵌入场景)────────────────────────── // 技能路径的入库在后端 bash 里执行(卡片路径由 TaskReportImportCard 直接通知)。 // 这里从消息流检测 task_report_import.py 的成功 stdout(含 "success": true 与 // task_id),代为发送 window.top.postMessage。**只在回合结束后检测**(流式过程中 // thread.messages 每个增量都会变,全量扫描会拖垮页面);只看线程里**最后一次** // 成功的技能入库:新一次入库会取代它触发新通知;刷新/重进旧对话时它已被记忆 // (sessionStorage 按 thread 存最近已通知的消息 id),不会重复通知。 const skillSavedNotifyKey = `agentfx.reportSavedNotified.${threadId}`; const [notifiedSkillMsgId, setNotifiedSkillMsgId] = useState(() => { if (typeof window === "undefined") return null; try { return sessionStorage.getItem(skillSavedNotifyKey); } catch { return null; } }); useEffect(() => { if (!taskBind || thread.isLoading) return; const importCallIds = new Set(); for (const message of thread.messages) { if (message.type !== "ai" || !message.tool_calls) continue; for (const call of message.tool_calls) { const command = (call.args as { command?: unknown } | undefined)?.command; if ( typeof command === "string" && command.includes("task_report_import.py") && call.id ) { importCallIds.add(call.id); } } } if (importCallIds.size === 0) return; let latest: { key: string; taskId: string } | null = null; for (const message of thread.messages) { if (message.type !== "tool" || !message.tool_call_id) continue; if (!importCallIds.has(message.tool_call_id)) continue; const content = typeof message.content === "string" ? message.content : ""; if (!/"success"\s*:\s*true/.test(content)) continue; const taskIdMatch = content.match(/"task_id"\s*:\s*"([^"]+)"/); latest = { key: String(message.id ?? message.tool_call_id), taskId: taskIdMatch?.[1] ?? taskId ?? "", }; } if (!latest || latest.key === notifiedSkillMsgId) return; notifyTaskReportSaved(latest.taskId); setNotifiedSkillMsgId(latest.key); try { sessionStorage.setItem(skillSavedNotifyKey, latest.key); } catch { /* noop */ } }, [ taskBind, thread.isLoading, thread.messages, taskId, notifiedSkillMsgId, skillSavedNotifyKey, ]); const displayThreadWithImportCard = useMemo(() => { if (!showReportImportCard) return displayThread; const cardMessage = { id: `agentfx-report-import-${threadId}`, type: "ai", content: "", additional_kwargs: { task_report_import_approval: { taskId, jsonPath: AGENTFX_REPORT_JSON_OUTPUT_PATH, }, }, } as unknown as Message; const messages = [...displayThread.messages, cardMessage]; return { ...displayThread, values: { ...displayThread.values, messages }, messages, } as typeof displayThread; }, [displayThread, showReportImportCard, taskId, threadId]); // 写作模式续写轮:仅当已有写作模式产物 report.md 时回传 writing_artifact_path, // 后端据此走「直接编辑画布」分支而不是再次挂载深度研究写报告管线。其它 leftover // .md 不得跳过管线(与 ChatPage 对齐)。 const writingArtifactPath = useMemo( () => resolveWritingModeReportPath(thread.values.artifacts), [thread.values.artifacts], ); // ── 「模板数据转换器」对话页专属调试入口(仅此 agent)────────────────────────── // 选一个页面模板,问答跑完后点「渲染预览」:取最近一条 AI 回复里的 JSON 填进模板, // 弹出 HTML 预览 + 结构校验信息,用于调试该智能体的转换效果。 const isTemplateBuilder = agent_id === TEMPLATE_BUILDER_AGENT_ID; const { data: debugTemplates = [] } = useHtmlTemplates(isTemplateBuilder); const [debugTemplateId, setDebugTemplateId] = useState(""); const [debugPreviewOpen, setDebugPreviewOpen] = useState(false); const [debugBusy, setDebugBusy] = useState(false); const [debugPreview, setDebugPreview] = useState( null, ); const runDebugPreview = useCallback(async () => { if (!debugTemplateId) return; const lastAi = [...thread.messages] .reverse() .find((m) => (m as { type?: string }).type === "ai"); const text = (lastAi ? textOfMessage(lastAi) : "") ?? ""; setDebugBusy(true); setDebugPreview(null); setDebugPreviewOpen(true); try { const result = await renderTemplateFill(debugTemplateId, text); setDebugPreview(result); } catch (error) { setDebugPreview({ ok: false, errors: [error instanceof Error ? error.message : "渲染失败"], gaps: [], html: null, }); } finally { setDebugBusy(false); } }, [debugTemplateId, thread.messages]); const handleSubmit = useCallback( (message: PromptInputMessage, extraContext?: Record) => { setHasStartedConversation(true); // Remember this agent so the new-chat landing screen pre-selects it next // time (covers both direct agent chats and the ChatPage handoff replay). saveLastAgentId(agent_id); window.dispatchEvent( new CustomEvent("workspace:sandbox-resume-autoscroll"), ); // 新一轮提问:清掉上一轮深度研究报告的注入状态(中断态步骤条等)。 if (threadId) clearResearchReportStream(threadId); const writingExtraContext = settings.context.writing_mode && writingArtifactPath ? { writing_artifact_path: writingArtifactPath } : {}; const pageReportThemeContext = agent_id === PAGE_REPORT_DESIGNER_AGENT_ID ? { presentation_theme: baseMode } : {}; void sendMessage(threadId, message, { ...writingExtraContext, ...(extraContext ?? {}), agent_id, ...pageReportThemeContext, }); }, [sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode], ); // 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。 // 任务工作区(rwfx 闸门 + 3qfx/xdfx 纯绑定)启用——普通问答页保留后台续跑行为不变。 const prevTaskThreadRef = useRef(null); useEffect(() => { if (!taskCtxOn) return; const prev = prevTaskThreadRef.current; if (prev && prev !== threadId) void stopThreadRun(prev); prevTaskThreadRef.current = threadId ?? null; }, [taskCtxOn, threadId]); useEffect(() => { // 离开任务问答页(卸载)时也停掉当前会话的服务端 run。 return () => { if (taskCtxOn) void stopThreadRun(prevTaskThreadRef.current); }; }, [taskCtxOn]); // 任务闸门通过(无目的数据 / 覆盖成功):发出开场信息开始问答;无开场信息则 // 仅切到对话态(隐藏面板),让用户手动输入。 const handleGateStart = useCallback((gateContext?: RwfxSqGateStartContext) => { const text = taskContext?.openingText?.trim(); if (text) handleSubmit({ text, files: [] }, rwfxSqReportRunContext(gateContext)); else setHasStartedConversation(true); }, [handleSubmit, taskContext]); // ChatPage stashes the opening message in sessionStorage when the user picks // an agent on the landing screen, then redirects here. Replay it once so the // user doesn't have to re-type the question on the agent chat page. const pendingMessageHandled = useRef(false); useEffect(() => { if (pendingMessageHandled.current) return; if (!agent_id) return; let pending: { agentId?: string; threadId?: string; text?: string } | null = null; try { const raw = sessionStorage.getItem("pendingAgentMessage"); if (raw) pending = JSON.parse(raw); } catch { pending = null; } if (!pending || pending.agentId !== agent_id || !pending.text) return; if (pending.threadId) { if (pending.threadId !== threadId) return; } else if (!isNewThread) { return; } pendingMessageHandled.current = true; sessionStorage.removeItem("pendingAgentMessage"); handleSubmit({ text: pending.text, files: [] }); }, [agent_id, threadId, isNewThread, handleSubmit]); // 纯绑定任务深链(3qfx/xdfx):用户主动「新建对话」进 landing 后,由 header 右上角「开始回答」 // 按钮手动触发发出开场信息(见下方 header)。手动场景不自动发,避免新建对话竞态导致的重复发送。 const handleTaskStart = useCallback(() => { if (taskStartText) handleSubmit({ text: taskStartText, files: [] }); }, [handleSubmit, taskStartText]); // 首次进入且该任务无历史(taskAutoStart):3qfx/xdfx 直接自动发出开场信息,无需手动点「开始回答」。 // autoStartedRef 一次性、不随 threadId 抖动重置,故只发一次(首次进入是干净 mount,无上一会话之忧)。 const autoStartedRef = useRef(false); useEffect(() => { if (!taskAutoStart) return; if (!taskStartText) return; if (!isNewThread) return; if (autoStartedRef.current) return; autoStartedRef.current = true; handleSubmit({ text: taskStartText, files: [] }); }, [taskAutoStart, taskStartText, isNewThread, handleSubmit]); const handleClarificationSubmit = useCallback( async (answer: string) => { setHasStartedConversation(true); await stopActiveThreadBeforeClarificationReply(threadId, thread); await sendMessage(threadId, { text: answer, files: [] }, { agent_id, ...(agent_id === PAGE_REPORT_DESIGNER_AGENT_ID ? { presentation_theme: baseMode } : {}), }); }, [sendMessage, thread, threadId, agent_id, baseMode], ); const handleStop = useCallback(async () => { await thread.stop(); }, [thread]); const isLandingState = isNewThread && !hasStartedConversation; // 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。 // 3qfx/xdfx(taskBind)不展示闸门,新建对话进问答页后由右上角「开始回答」按钮手动发出开场信息。 const showTaskGate = !!taskGate && !!taskId && isNewThread && !hasStartedConversation; const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"; // 智能体使用引导(chat 阶段):仅普通智能体对话页 —— 排除通用问答原地嵌入 // (embeddedAgentId)、任务深链工作区(taskGate/taskBind)等复用本页的场景。 const agentGuideEligible = !!showAgentList && !isEmbedded && !taskCtxOn && isAgentGuideEnvironment(); const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready"; return ( 0, }} > {/* Root: fixed-height horizontal split. ChatBox (chat + artifact preview) lives in the left flex cell; the agent detail panel is a separate flex sibling OUTSIDE ChatBox, so the artifact preview always opens between the chat and the detail panel — the detail panel stays rightmost. */}
{/* 最左:智能体列表栏(仅独立整页问答开启)。顶部下拉切换 我的/广场。 */} {showAgentList && (
)}
{/* Main conversation column. `relative` hosts the floating question navigator overlay. */}
{/* Header in normal flow, fixed height. */}
{/* 智能体列表栏的本地收展入口。 */} {showAgentList && !isEmbedded && ( )}
{agentDisplayName}
{/* 智能体使用引导(chat 阶段)手动重放入口。 */} {agentGuideEligible && ( )} {/* 「模板数据转换器」专属:选页面模板 + 渲染预览(调试该智能体效果)。 */} {isTemplateBuilder && (
)} {/* 3qfx/xdfx 任务深链:用户主动「新建对话」进问答页后,点此发出任务开场信息开始问答。 首次进入且无历史(taskAutoStart)会自动发,不显示此按钮。 */} {taskStartText && isLandingState && !taskAutoStart && ( )} {/* 右侧智能体详情面板收起时的「展开」入口:仅收起态显示 (展开态的「收起」按钮在面板右上角)。桌面端专用(lg+), 窄屏面板走抽屉交互;嵌入态面板固定渲染,不显示该开关。 */} {!isEmbedded && detailCollapsed && ( )}
{showTaskGate && ( )} {isLandingState ? ( /* Home: centered hero, suggested questions, and input. Everything lives inside the scrollable main column. No fixed, no absolute, no viewport centering, so the avatar can't touch the header and the input can't cover the sidebar. */
handleSubmit({ text, files: [] })} />
setSettings("context", context) } // 任务深链工作区:受控场景,隐藏「写作/记忆/参考文献」与「知识空间」。 hideContextTools={taskCtxOn} knowledgeSelector={ taskCtxOn ? undefined : ( ) } onFollowupsVisibilityChange={setShowFollowups} onSubmit={handleSubmit} onStop={handleStop} /> {isDemoMode && (
{t.common.notAvailableInDemoMode}
)}
) : ( /* Conversation: messages scroll, input docked at the bottom of the main column only. */
{/* Question index pinned to this conversation column; the bottom inset keeps it clear of the docked input box. */} handleSubmit({ text, files: [] })} // agentfx「入库」协助卡:消息流尾部注入的虚拟消息经此插槽渲染。 taskReportImportSlot={(message) => hasTaskReportImportApproval(message) && showReportImportCard && taskId ? ( setReportImportDismissed(true)} /> ) : null } />
)}
{/* Right: agent detail panel — always the rightmost column. */}
handleSubmit({ text, files: [] })} collapsed={detailCollapsed} onCollapsedChange={setDetailCollapsed} showRecentChats={!taskCtxOn} compactAppearanceActions={compactAppearanceActions} />
{/* 「模板数据转换器」调试预览弹窗(仅此 agent)。 */} {isTemplateBuilder && ( 模板填充预览 · 渲染结果
{debugBusy && ( 正在渲染… )} {!debugBusy && debugPreview && (
{debugPreview.display_only ? "该模板为「只展示」,已原样输出现有数据" : debugPreview.html ? debugPreview.ok ? "✓ 结构校验通过" : "⚠ 结构校验有问题(已照常填充,可对照下方修正后再让智能体重答)" : "✗ 渲染失败"} {debugPreview.errors.length > 0 && ( 错误:{debugPreview.errors.join(";")} )} {debugPreview.gaps.length > 0 && ( 缺失字段:{debugPreview.gaps.slice(0, 20).join("、")} {debugPreview.gaps.length > 20 ? " …" : ""} )}
)}
{debugPreview?.html ? (