1302 lines
58 KiB
TypeScript
1302 lines
58 KiB
TypeScript
"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<string, unknown> | 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<string, unknown> | 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<TaskContext | null>(() => {
|
||
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<string[]>(
|
||
() => pendingKnowledgeSelection.current ?? [],
|
||
);
|
||
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>(
|
||
() => pendingAssistantKnowledgeSelection.current ?? [],
|
||
);
|
||
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
|
||
() => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null,
|
||
);
|
||
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
|
||
const knowledgeSelectionThread = useRef<string | null>(null);
|
||
const knowledgeSelectionAgent = useRef(agent_id);
|
||
const activeThreadIdRef = useRef<string | null | undefined>(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<string, unknown> | 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<string | null>(() => {
|
||
if (typeof window === "undefined") return null;
|
||
try {
|
||
return sessionStorage.getItem(skillSavedNotifyKey);
|
||
} catch {
|
||
return null;
|
||
}
|
||
});
|
||
useEffect(() => {
|
||
if (!taskBind || thread.isLoading) return;
|
||
const importCallIds = new Set<string>();
|
||
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<TemplateRenderResult | null>(
|
||
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<string, unknown>) => {
|
||
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<string | null>(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 (
|
||
<ThreadContext.Provider
|
||
value={{
|
||
thread: displayThread,
|
||
knowledgeReferencesEnabled:
|
||
knowledgeSelectionReady && selectedKnowledgeBaseIds.length > 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. */}
|
||
<div className="flex size-full min-h-0 overflow-hidden workspace-chat-bg">
|
||
{/* 最左:智能体列表栏(仅独立整页问答开启)。顶部下拉切换 我的/广场。 */}
|
||
{showAgentList && (
|
||
<div
|
||
data-agent-guide="chat-sidebar"
|
||
aria-hidden={!agentSidebarOpen}
|
||
className={cn(
|
||
"flex h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
||
agentSidebarOpen ? "w-(--sidebar-width)" : "pointer-events-none w-0",
|
||
)}
|
||
>
|
||
<AgentChatSidebar activeAgentId={agent_id} />
|
||
</div>
|
||
)}
|
||
<div className="min-h-0 min-w-0 flex-1">
|
||
<ChatBox threadId={threadId}>
|
||
{/* Main conversation column. `relative` hosts the floating
|
||
question navigator overlay. */}
|
||
<main className="relative flex size-full min-h-0 min-w-0 flex-col">
|
||
{/* Header in normal flow, fixed height. */}
|
||
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
|
||
{/* 智能体列表栏的本地收展入口。 */}
|
||
{showAgentList && !isEmbedded && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={() => setAgentSidebarOpen((open) => !open)}
|
||
title={agentSidebarOpen ? "收起左侧栏" : "展开左侧栏"}
|
||
aria-label={agentSidebarOpen ? "收起左侧栏" : "展开左侧栏"}
|
||
aria-expanded={agentSidebarOpen}
|
||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||
>
|
||
{agentSidebarOpen ? (
|
||
<PanelLeftCloseIcon className="size-4" />
|
||
) : (
|
||
<PanelLeftOpenIcon className="size-4" />
|
||
)}
|
||
</Button>
|
||
)}
|
||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||
<span
|
||
className="max-w-[200px] truncate text-xs font-medium"
|
||
title={agentDisplayName}
|
||
>
|
||
{agentDisplayName}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex min-w-0 flex-1 items-center overflow-hidden text-sm font-medium">
|
||
<ThreadTitle threadId={threadId} thread={thread} />
|
||
</div>
|
||
|
||
<div className="flex shrink-0 items-center">
|
||
{/* 智能体使用引导(chat 阶段)手动重放入口。 */}
|
||
{agentGuideEligible && (
|
||
<Tooltip content="重新播放智能体使用引导">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="text-muted-foreground hover:text-foreground mr-1"
|
||
onClick={() => {
|
||
// 引导的 chat-detail 步骤会高亮右侧面板,重放前先展开。
|
||
setDetailCollapsed(false);
|
||
startAgentGuide("chat");
|
||
}}
|
||
>
|
||
<Compass className="size-4" />
|
||
使用引导
|
||
</Button>
|
||
</Tooltip>
|
||
)}
|
||
{/* 「模板数据转换器」专属:选页面模板 + 渲染预览(调试该智能体效果)。 */}
|
||
{isTemplateBuilder && (
|
||
<div className="mr-2 flex items-center gap-1.5">
|
||
<Select
|
||
value={debugTemplateId || undefined}
|
||
onValueChange={setDebugTemplateId}
|
||
>
|
||
<SelectTrigger className="h-8 w-[150px] text-xs">
|
||
<SelectValue placeholder="选择页面模板" />
|
||
</SelectTrigger>
|
||
<SelectContent position="popper">
|
||
{debugTemplates.map((tpl) => (
|
||
<SelectItem key={tpl.id} value={tpl.id}>
|
||
{tpl.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Tooltip content="用最近一条回复里的 JSON 填进所选模板并预览">
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
disabled={!debugTemplateId || debugBusy}
|
||
onClick={runDebugPreview}
|
||
>
|
||
{debugBusy ? "渲染中…" : "渲染预览"}
|
||
</Button>
|
||
</Tooltip>
|
||
</div>
|
||
)}
|
||
{/* 3qfx/xdfx 任务深链:用户主动「新建对话」进问答页后,点此发出任务开场信息开始问答。
|
||
首次进入且无历史(taskAutoStart)会自动发,不显示此按钮。 */}
|
||
{taskStartText && isLandingState && !taskAutoStart && (
|
||
<Button
|
||
size="sm"
|
||
className="mr-1 bg-[var(--color-main)] text-white shadow-sm hover:bg-[var(--brand-950)]"
|
||
onClick={handleTaskStart}
|
||
disabled={isDemoMode}
|
||
>
|
||
开始回答
|
||
</Button>
|
||
)}
|
||
{/* 右侧智能体详情面板收起时的「展开」入口:仅收起态显示
|
||
(展开态的「收起」按钮在面板右上角)。桌面端专用(lg+),
|
||
窄屏面板走抽屉交互;嵌入态面板固定渲染,不显示该开关。 */}
|
||
{!isEmbedded && detailCollapsed && (
|
||
<Tooltip content="展开智能体信息">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="text-muted-foreground hover:text-foreground hidden lg:inline-flex"
|
||
onClick={() => setDetailCollapsed(false)}
|
||
aria-label="展开智能体信息"
|
||
aria-expanded={false}
|
||
>
|
||
<PanelRightOpenIcon className="size-4" />
|
||
</Button>
|
||
</Tooltip>
|
||
)}
|
||
<TokenUsageIndicator
|
||
enabled={tokenUsageEnabled}
|
||
messages={displayThread.messages}
|
||
/>
|
||
<Tooltip content={t.agents.newChat}>
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
onClick={() => {
|
||
// 嵌入态:保持停留在通用问答右侧,开同一智能体的新对话;
|
||
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
|
||
router.push(
|
||
isEmbedded
|
||
? `/page/workspace/chats/new?agent=${agent_id}`
|
||
: `${agentChatsBase}/new`,
|
||
);
|
||
}}
|
||
>
|
||
<PlusSquare /> {t.agents.newChat}
|
||
</Button>
|
||
</Tooltip>
|
||
<ExportTrigger threadId={threadId} />
|
||
<ArtifactTrigger />
|
||
</div>
|
||
</header>
|
||
|
||
{showTaskGate && (
|
||
<TaskPurposePanel
|
||
taskId={taskId}
|
||
onStart={handleGateStart}
|
||
// rwfx:仅「首次进入且无聊天对话历史」(taskAutoStart) 时,目的树为空才自动开始;
|
||
// 用户主动新建对话则需手动点「开始问答」。目的树有数据始终弹「覆盖并开始问答」确认。
|
||
autoStartWhenEmpty={!!autoStartWhenPurposeEmpty && taskAutoStart}
|
||
/>
|
||
)}
|
||
|
||
{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. */
|
||
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 pb-10 pt-16 sm:pt-24">
|
||
<AgentWelcome
|
||
agent={agent}
|
||
agentName={agentDisplayName}
|
||
className="mb-7"
|
||
/>
|
||
<div className="w-full max-w-(--container-width-md)">
|
||
<div data-agent-guide="chat-questions" className="mb-3">
|
||
<AgentQuestionChips
|
||
questions={commonQuestions}
|
||
disabled={isDemoMode}
|
||
onAsk={(text) => handleSubmit({ text, files: [] })}
|
||
/>
|
||
</div>
|
||
<div
|
||
className="min-w-0 flex-1"
|
||
data-agent-guide="chat-input"
|
||
>
|
||
<InputBox
|
||
className="w-full"
|
||
isNewThread
|
||
hideAdminRecommendedQuestions
|
||
threadId={threadId}
|
||
assistantId={agent_id}
|
||
autoFocus
|
||
status={status}
|
||
context={settings.context}
|
||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||
onContextChange={(context) =>
|
||
setSettings("context", context)
|
||
}
|
||
// 任务深链工作区:受控场景,隐藏「写作/记忆/参考文献」与「知识空间」。
|
||
hideContextTools={taskCtxOn}
|
||
knowledgeSelector={
|
||
taskCtxOn ? undefined : (
|
||
<LlmWikiKnowledgeSelector
|
||
source="agent"
|
||
agentId={agent_id}
|
||
value={selectedKnowledgeBaseIds}
|
||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||
onChange={changeKnowledgeSelection}
|
||
onAssistantChange={changeAssistantKnowledgeSelection}
|
||
autoSelectAll={
|
||
isNewThread &&
|
||
pendingKnowledgeSelection.current === null &&
|
||
pendingAssistantKnowledgeSelection.current === null
|
||
}
|
||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||
label="知识空间"
|
||
/>
|
||
)
|
||
}
|
||
onFollowupsVisibilityChange={setShowFollowups}
|
||
onSubmit={handleSubmit}
|
||
onStop={handleStop}
|
||
/>
|
||
{isDemoMode && (
|
||
<div className="text-muted-foreground/67 mt-3 w-full text-center text-xs">
|
||
{t.common.notAvailableInDemoMode}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* Conversation: messages scroll, input
|
||
docked at the bottom of the main column only. */
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
{/* Question index pinned to this conversation column; the
|
||
bottom inset keeps it clear of the docked input box. */}
|
||
<QuestionNavigator
|
||
messages={displayThread.messages}
|
||
className="top-14 right-0 bottom-40 left-0"
|
||
/>
|
||
<MessageList
|
||
className="min-h-0 flex-1"
|
||
threadId={threadId}
|
||
thread={displayThreadWithImportCard}
|
||
paddingBottom={showFollowups ? 96 : 24}
|
||
tokenUsageEnabled={tokenUsageEnabled}
|
||
// 输入框在流内(MessageList 下方),按钮贴对话区底部即可。
|
||
scrollButtonClassName="bottom-2"
|
||
onClarificationSubmit={handleClarificationSubmit}
|
||
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
||
// agentfx「入库」协助卡:消息流尾部注入的虚拟消息经此插槽渲染。
|
||
taskReportImportSlot={(message) =>
|
||
hasTaskReportImportApproval(message) && showReportImportCard && taskId ? (
|
||
<TaskReportImportCard
|
||
taskId={taskId}
|
||
threadId={threadId}
|
||
jsonPath={AGENTFX_REPORT_JSON_OUTPUT_PATH}
|
||
onImported={markReportImported}
|
||
onDismiss={() => setReportImportDismissed(true)}
|
||
/>
|
||
) : null
|
||
}
|
||
/>
|
||
|
||
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
|
||
<div
|
||
className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)"
|
||
data-agent-guide="chat-input"
|
||
>
|
||
<TodoList
|
||
className="bg-background/5 mb-2"
|
||
todos={thread.values.todos ?? []}
|
||
hidden={
|
||
!thread.values.todos ||
|
||
thread.values.todos.length === 0
|
||
}
|
||
/>
|
||
<InputBox
|
||
className="w-full"
|
||
isNewThread={false}
|
||
threadId={threadId}
|
||
assistantId={agent_id}
|
||
status={status}
|
||
context={settings.context}
|
||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||
onContextChange={(context) =>
|
||
setSettings("context", context)
|
||
}
|
||
// 任务深链工作区:受控场景,隐藏「写作/记忆/参考文献」与「知识空间」。
|
||
hideContextTools={taskCtxOn}
|
||
knowledgeSelector={
|
||
taskCtxOn ? undefined : (
|
||
<LlmWikiKnowledgeSelector
|
||
source="agent"
|
||
agentId={agent_id}
|
||
value={selectedKnowledgeBaseIds}
|
||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||
onChange={changeKnowledgeSelection}
|
||
onAssistantChange={changeAssistantKnowledgeSelection}
|
||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||
readOnly
|
||
label="知识空间"
|
||
/>
|
||
)
|
||
}
|
||
onFollowupsVisibilityChange={setShowFollowups}
|
||
onSubmit={handleSubmit}
|
||
onStop={handleStop}
|
||
/>
|
||
{isDemoMode && (
|
||
<div className="text-muted-foreground/67 mt-2 w-full text-center text-xs">
|
||
{t.common.notAvailableInDemoMode}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</ChatBox>
|
||
</div>
|
||
|
||
{/* Right: agent detail panel — always the rightmost column. */}
|
||
<div
|
||
data-agent-guide="chat-detail"
|
||
className="flex h-full shrink-0"
|
||
>
|
||
<AgentDetailPanel
|
||
agent={agent}
|
||
agentName={agentDisplayName}
|
||
disabled={thread.isLoading || isDemoMode}
|
||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||
collapsed={detailCollapsed}
|
||
onCollapsedChange={setDetailCollapsed}
|
||
showRecentChats={!taskCtxOn}
|
||
compactAppearanceActions={compactAppearanceActions}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<AgentGuide stage="chat" eligible={agentGuideEligible} />
|
||
|
||
{/* 「模板数据转换器」调试预览弹窗(仅此 agent)。 */}
|
||
{isTemplateBuilder && (
|
||
<Dialog open={debugPreviewOpen} onOpenChange={setDebugPreviewOpen}>
|
||
<DialogContent className="flex h-[88vh] w-[92vw] max-w-[92vw] flex-col gap-0 p-0">
|
||
<DialogHeader className="border-b px-4 py-2">
|
||
<DialogTitle className="text-sm">
|
||
模板填充预览 · 渲染结果
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="max-h-28 overflow-y-auto border-b px-4 py-2 text-xs">
|
||
{debugBusy && (
|
||
<span className="text-muted-foreground">正在渲染…</span>
|
||
)}
|
||
{!debugBusy && debugPreview && (
|
||
<div className="flex flex-col gap-1">
|
||
<span
|
||
className={
|
||
debugPreview.html
|
||
? debugPreview.ok
|
||
? "text-emerald-600"
|
||
: "text-amber-600"
|
||
: "text-destructive"
|
||
}
|
||
>
|
||
{debugPreview.display_only
|
||
? "该模板为「只展示」,已原样输出现有数据"
|
||
: debugPreview.html
|
||
? debugPreview.ok
|
||
? "✓ 结构校验通过"
|
||
: "⚠ 结构校验有问题(已照常填充,可对照下方修正后再让智能体重答)"
|
||
: "✗ 渲染失败"}
|
||
</span>
|
||
{debugPreview.errors.length > 0 && (
|
||
<span className="text-destructive">
|
||
错误:{debugPreview.errors.join(";")}
|
||
</span>
|
||
)}
|
||
{debugPreview.gaps.length > 0 && (
|
||
<span className="text-muted-foreground">
|
||
缺失字段:{debugPreview.gaps.slice(0, 20).join("、")}
|
||
{debugPreview.gaps.length > 20 ? " …" : ""}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="min-h-0 flex-1 bg-white">
|
||
{debugPreview?.html ? (
|
||
<iframe
|
||
title="模板填充预览"
|
||
srcDoc={debugPreview.html}
|
||
sandbox="allow-scripts"
|
||
className="size-full border-0"
|
||
/>
|
||
) : (
|
||
<div className="text-muted-foreground flex size-full items-center justify-center text-sm">
|
||
{debugBusy ? "正在渲染…" : "无可预览内容"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)}
|
||
</ThreadContext.Provider>
|
||
);
|
||
}
|
||
|