662 lines
30 KiB
TypeScript
662 lines
30 KiB
TypeScript
"use client";
|
||
|
||
import { BotIcon, 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 } from "react";
|
||
|
||
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 { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||
import { InputBox } from "@/components/workspace/input-box";
|
||
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 { useI18n } from "@/core/i18n/hooks";
|
||
import { useModels } from "@/core/models/hooks";
|
||
import { useNotification } from "@/core/notification/hooks";
|
||
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
||
import { stopThreadRun, useThreadStream } from "@/core/threads/hooks";
|
||
import { textOfMessage } from "@/core/threads/utils";
|
||
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 } from "@/core/auth/task-purpose";
|
||
import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel";
|
||
import { desensitize } from "@/lib/desensitize";
|
||
import { buildTaskOpeningText, readCopTaskDetail } from "@/core/auth/task-deeplink";
|
||
|
||
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
|
||
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
|
||
|
||
export default function AgentChatPage({
|
||
embeddedAgentId,
|
||
chatBasePath,
|
||
taskGate,
|
||
taskBind,
|
||
autoStartWhenPurposeEmpty,
|
||
showAgentList,
|
||
}: {
|
||
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 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;
|
||
} = {}) {
|
||
const routeBase = useRouteBase();
|
||
const { t } = useI18n();
|
||
const [showFollowups, setShowFollowups] = useState(false);
|
||
const router = useRouter();
|
||
|
||
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);
|
||
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) {
|
||
return {
|
||
taskId: fallbackTaskId,
|
||
agentId: agent_id,
|
||
openingText: buildTaskOpeningText(detail, fallbackTaskId),
|
||
};
|
||
}
|
||
}
|
||
return stored;
|
||
}, [agent_id, 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();
|
||
|
||
useEffect(() => {
|
||
if (isNewThread) {
|
||
setHasStartedConversation(hasPendingForThisAgent());
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isNewThread, threadId, agent_id]);
|
||
|
||
const { showNotification } = useNotification();
|
||
const [thread, sendMessage] = useThreadStream({
|
||
threadId: isNewThread ? undefined : threadId,
|
||
assistantId: agent_id,
|
||
context: { ...settings.context, agent_id },
|
||
// 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤;
|
||
// xdfx 额外打 metadata.actionId 标签,按「行动id」划分聊天记录(见 taskThreadMetadata)。
|
||
threadMetadata: taskId ? taskThreadMetadata(taskContext) : undefined,
|
||
onStart: (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 {
|
||
/* 上报绝不影响问答 */
|
||
}
|
||
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 });
|
||
}
|
||
},
|
||
});
|
||
|
||
// ── 「模板数据转换器」对话页专属调试入口(仅此 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) => {
|
||
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"),
|
||
);
|
||
void sendMessage(threadId, message, { agent_id });
|
||
},
|
||
[sendMessage, threadId, agent_id],
|
||
);
|
||
|
||
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 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(() => {
|
||
const text = taskContext?.openingText?.trim();
|
||
if (text) handleSubmit({ text, files: [] });
|
||
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(
|
||
(answer: string) => {
|
||
setHasStartedConversation(true);
|
||
void sendMessage(threadId, { text: answer, files: [] }, { agent_id });
|
||
},
|
||
[sendMessage, threadId, agent_id],
|
||
);
|
||
|
||
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";
|
||
|
||
const status = thread.error
|
||
? "error"
|
||
: thread.isLoading
|
||
? "streaming"
|
||
: "ready";
|
||
|
||
return (
|
||
<ThreadContext.Provider value={{ thread }}>
|
||
{/* 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 && <AgentChatSidebar activeAgentId={agent_id} />}
|
||
<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">
|
||
<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 text-sm font-medium">
|
||
<ThreadTitle threadId={threadId} thread={thread} />
|
||
</div>
|
||
|
||
<div className="flex items-center">
|
||
{/* 「模板数据转换器」专属:选页面模板 + 渲染预览(调试该智能体效果)。 */}
|
||
{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>
|
||
)}
|
||
<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>
|
||
<TokenUsageIndicator
|
||
enabled={tokenUsageEnabled}
|
||
messages={thread.messages}
|
||
/>
|
||
<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)">
|
||
<AgentQuestionChips
|
||
className="mb-3"
|
||
questions={commonQuestions}
|
||
disabled={isDemoMode}
|
||
onAsk={(text) => handleSubmit({ text, files: [] })}
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<InputBox
|
||
className="w-full"
|
||
isNewThread
|
||
hideAdminRecommendedQuestions
|
||
threadId={threadId}
|
||
assistantId={agent_id}
|
||
autoFocus
|
||
status={status}
|
||
context={settings.context}
|
||
disabled={isDemoMode}
|
||
onContextChange={(context) =>
|
||
setSettings("context", context)
|
||
}
|
||
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={thread.messages}
|
||
className="top-14 right-0 bottom-40 left-0"
|
||
/>
|
||
<MessageList
|
||
className="min-h-0 flex-1"
|
||
threadId={threadId}
|
||
thread={thread}
|
||
paddingBottom={showFollowups ? 96 : 24}
|
||
tokenUsageEnabled={tokenUsageEnabled}
|
||
// 输入框在流内(MessageList 下方),按钮贴对话区底部即可。
|
||
scrollButtonClassName="bottom-2"
|
||
onClarificationSubmit={handleClarificationSubmit}
|
||
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
||
/>
|
||
|
||
<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)">
|
||
<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}
|
||
onContextChange={(context) =>
|
||
setSettings("context", context)
|
||
}
|
||
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. */}
|
||
<AgentDetailPanel
|
||
agent={agent}
|
||
agentName={agentDisplayName}
|
||
disabled={thread.isLoading || isDemoMode}
|
||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||
defaultCollapsed={taskCtxOn}
|
||
showRecentChats={!taskCtxOn}
|
||
/>
|
||
</div>
|
||
|
||
{/* 「模板数据转换器」调试预览弹窗(仅此 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>
|
||
);
|
||
}
|
||
|