669 lines
26 KiB
TypeScript
669 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { toast } from "sonner";
|
||
|
||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||
import { getAPIClient } from "@/core/api";
|
||
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
|
||
import { useUserPreferencesQuery } from "@/core/user-preferences";
|
||
import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts";
|
||
import {
|
||
ChatBox,
|
||
useSpecificChatMode,
|
||
useThreadChat,
|
||
} from "@/components/workspace/chats";
|
||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||
import { KnowledgeSaveTrigger } from "@/components/workspace/knowledge-save-trigger";
|
||
import { InputBox } from "@/components/workspace/input-box";
|
||
import {
|
||
MessageList,
|
||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||
} 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 {
|
||
CompactShortcutButtons,
|
||
LayoutModeSwitcher,
|
||
ThemeSwitcher,
|
||
} from "@/components/workspace/appearance-controls";
|
||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
|
||
import { Welcome } from "@/components/workspace/welcome";
|
||
import { CompactWelcome } from "@/components/workspace/compact-welcome";
|
||
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
|
||
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
|
||
import { useSidebar } from "@/components/ui/sidebar";
|
||
import { Button } from "@/components/ui/button";
|
||
import { PanelLeftOpen } from "lucide-react";
|
||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||
import { useI18n } from "@/core/i18n/hooks";
|
||
import { useModels } from "@/core/models/hooks";
|
||
import { useNotification } from "@/core/notification/hooks";
|
||
import {
|
||
getLastAgentId,
|
||
saveLastAgentId,
|
||
useKnowledgeRagEnabled,
|
||
useThreadSettings,
|
||
} from "@/core/settings";
|
||
import { useThreadStream } from "@/core/threads/hooks";
|
||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||
import { useRouteBase } from "@/core/route-base";
|
||
import { textOfMessage } from "@/core/threads/utils";
|
||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||
import { env } from "@/env";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
const BROWSER_CONTEXT_PARAM_KEYS = [
|
||
"browser_context",
|
||
"bc_filename",
|
||
"bc_size",
|
||
"bc_path",
|
||
"bc_prompt",
|
||
] as const;
|
||
|
||
function clearBrowserContextParamsFromHash() {
|
||
if (typeof window === "undefined") return;
|
||
const hash = window.location.hash;
|
||
if (!hash.startsWith("#")) return;
|
||
const hashBody = hash.slice(1);
|
||
const queryStart = hashBody.indexOf("?");
|
||
if (queryStart < 0) return;
|
||
|
||
const path = hashBody.slice(0, queryStart);
|
||
const search = hashBody.slice(queryStart + 1);
|
||
const params = new URLSearchParams(search);
|
||
let changed = false;
|
||
for (const key of BROWSER_CONTEXT_PARAM_KEYS) {
|
||
if (params.has(key)) {
|
||
params.delete(key);
|
||
changed = true;
|
||
}
|
||
}
|
||
if (!changed) return;
|
||
|
||
const nextSearch = params.toString();
|
||
const nextHash = nextSearch ? `#${path}?${nextSearch}` : `#${path}`;
|
||
window.history.replaceState(
|
||
null,
|
||
"",
|
||
`${window.location.pathname}${window.location.search}${nextHash}`,
|
||
);
|
||
}
|
||
|
||
export default function ChatPage() {
|
||
const routeBase = useRouteBase();
|
||
const router = useRouter();
|
||
const searchParams = useSearchParams();
|
||
const pathname = usePathname();
|
||
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
|
||
useSidebar();
|
||
const { t } = useI18n();
|
||
// 有效界面模式:用户自选 > 管理员系统默认 > (下方再回退会话级 sidebarEnabled)
|
||
const [effectiveLayoutMode] = useLayoutMode();
|
||
const [showFollowups, setShowFollowups] = useState(false);
|
||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||
useThreadChat();
|
||
const [settings, setSettings] = useThreadSettings(threadId);
|
||
// Sidebar session toggle: while on, every message in every chat carries
|
||
// knowledge_rag_enabled so the backend injects knowledge-base recall.
|
||
// In-memory only — explicitly overrides any value persisted by the old
|
||
// per-thread header toggle so stale localStorage can't re-enable it.
|
||
const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
|
||
const streamContext = useMemo(
|
||
() => ({
|
||
...settings.context,
|
||
knowledge_rag_enabled: knowledgeRagEnabled,
|
||
}),
|
||
[settings.context, knowledgeRagEnabled],
|
||
);
|
||
const [mounted, setMounted] = useState(false);
|
||
const { tokenUsageEnabled } = useModels();
|
||
const { data: promptPrefixSettings } = usePromptPrefixSettings();
|
||
// Personal opt-out: when set, hide the admin prompt-prefix switch entirely and
|
||
// never apply the prefix — the user uses only their own prompts.
|
||
const { data: userPreferences, isLoading: preferencesLoading } =
|
||
useUserPreferencesQuery();
|
||
const hideAdminPrompt = userPreferences?.hide_admin_prompt_prefix === true;
|
||
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
|
||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||
const [agentHandoffPending, setAgentHandoffPending] = useState(false);
|
||
const agentHandoffPendingRef = useRef(false);
|
||
const browserContextAutoSubmitRef = useRef<string | null>(null);
|
||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||
useAgentSelector();
|
||
// 已选 agent 的存在性校验需要更宽的来源:侧边栏可选未配置进管理弹窗的 agent
|
||
// (用户自己的智能体),这些也算有效,故把「我的 / 广场」一并纳入校验集(缓存共享)。
|
||
const { agents: mineAgents } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
|
||
const { agents: squareAgents } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
|
||
const { selectedArtifact } = useArtifacts();
|
||
useSpecificChatMode();
|
||
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (isNewThread) {
|
||
setHasStartedConversation(false);
|
||
setPromptPrefixOn(false);
|
||
// Pre-select the agent the user last started a conversation with so a
|
||
// fresh chat defaults to it. Validated against the known agent list below.
|
||
setSelectedAgentId(getLastAgentId());
|
||
}
|
||
}, [isNewThread, threadId]);
|
||
|
||
// Drop a restored last-used agent that is no longer in the selector list
|
||
// (deleted / unpublished / no longer curated), so we never hand off to a
|
||
// dead agent. Wait for the list to load before deciding.
|
||
useEffect(() => {
|
||
if (selectorLoading || !selectedAgentId) return;
|
||
// 校验集 = 管理弹窗 ∪ 我的 ∪ 广场;任一存在即视为有效(含用户未配置进管理弹窗
|
||
// 的自有智能体)。三者都还没加载出来时不做清除,避免把恢复值误删。
|
||
const known = [...selectorAgents, ...mineAgents, ...squareAgents];
|
||
if (known.length === 0) return;
|
||
if (!known.some((a) => a.id === selectedAgentId)) {
|
||
setSelectedAgentId(null);
|
||
}
|
||
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
||
|
||
const { showNotification } = useNotification();
|
||
|
||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||
threadId: isNewThread ? undefined : threadId,
|
||
context: streamContext,
|
||
isMock,
|
||
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}#${routeBase}/chats/${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: selectedAgentId, question: lastHuman ? textOfMessage(lastHuman) : "" },
|
||
returnParam: { data: lastAi ? textOfMessage(lastAi) : "" },
|
||
});
|
||
} catch {
|
||
/* 上报绝不影响问答 */
|
||
}
|
||
if (document.hidden || !document.hasFocus()) {
|
||
let body = "Conversation finished";
|
||
const lastMessage = state.messages.at(-1);
|
||
if (lastMessage) {
|
||
const textContent = textOfMessage(lastMessage);
|
||
if (textContent) {
|
||
body =
|
||
textContent.length > 200
|
||
? textContent.substring(0, 200) + "..."
|
||
: textContent;
|
||
}
|
||
}
|
||
showNotification(state.title, { body });
|
||
}
|
||
},
|
||
});
|
||
|
||
const writingArtifactPath = useMemo(() => {
|
||
const isMarkdown = (path: string | null | undefined) =>
|
||
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
|
||
if (isMarkdown(selectedArtifact)) {
|
||
return selectedArtifact ?? undefined;
|
||
}
|
||
return [...(thread.values.artifacts ?? [])]
|
||
.reverse()
|
||
.find((path) => isMarkdown(path));
|
||
}, [selectedArtifact, thread.values.artifacts]);
|
||
|
||
const handleSubmit = useCallback(
|
||
(message: PromptInputMessage) => {
|
||
// If the user picked an agent before sending the opening turn, hand off
|
||
// to AgentChatPage so the conversation runs against that agent. We stash
|
||
// the pending text in sessionStorage because the next/navigation shim
|
||
// doesn't carry router state across hash-route pushes. File attachments
|
||
// are bound to the current thread upload directory and would not match
|
||
// the new thread, so we skip the handoff when files are present and let
|
||
// the message run on Lead Agent instead.
|
||
if (
|
||
selectedAgentId &&
|
||
isNewThread &&
|
||
!hasStartedConversation &&
|
||
message.text &&
|
||
(!message.files || message.files.length === 0)
|
||
) {
|
||
if (agentHandoffPendingRef.current) return;
|
||
const text = message.text.trim();
|
||
agentHandoffPendingRef.current = true;
|
||
setAgentHandoffPending(true);
|
||
setHasStartedConversation(true);
|
||
void (async () => {
|
||
try {
|
||
const created = await getAPIClient(isMock).threads.create({
|
||
metadata: { agent_id: selectedAgentId },
|
||
});
|
||
sessionStorage.setItem(
|
||
"pendingAgentMessage",
|
||
JSON.stringify({
|
||
agentId: selectedAgentId,
|
||
threadId: created.thread_id,
|
||
text,
|
||
}),
|
||
);
|
||
saveLastAgentId(selectedAgentId);
|
||
router.push(
|
||
`/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`,
|
||
);
|
||
} catch (err) {
|
||
agentHandoffPendingRef.current = false;
|
||
setAgentHandoffPending(false);
|
||
setHasStartedConversation(false);
|
||
toast.error(
|
||
err instanceof Error ? err.message : "创建智能体对话失败",
|
||
);
|
||
}
|
||
})();
|
||
return;
|
||
}
|
||
|
||
// Only the opening turn of a brand-new thread may carry the prefix.
|
||
const shouldApplyPrefix =
|
||
isNewThread &&
|
||
!hasStartedConversation &&
|
||
promptPrefixOn &&
|
||
!preferencesLoading &&
|
||
!hideAdminPrompt &&
|
||
promptPrefixSettings?.enabled === true;
|
||
setHasStartedConversation(true);
|
||
window.dispatchEvent(
|
||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||
);
|
||
// The prefix is intentionally NOT concatenated into the message text:
|
||
// doing so would make the user see it. We only flag the request via
|
||
// context; the backend tags the message and PromptPrefixMiddleware
|
||
// injects the prefix into the LLM request only. Writing mode adds the
|
||
// active markdown artifact path so the agent keeps editing the canvas.
|
||
const extraContext: Record<string, unknown> = {};
|
||
if (shouldApplyPrefix) {
|
||
extraContext.prompt_prefix_enabled = true;
|
||
}
|
||
if (settings.context.writing_mode && writingArtifactPath) {
|
||
extraContext.writing_artifact_path = writingArtifactPath;
|
||
}
|
||
void sendMessage(
|
||
threadId,
|
||
message,
|
||
Object.keys(extraContext).length > 0 ? extraContext : undefined,
|
||
);
|
||
},
|
||
[
|
||
sendMessage,
|
||
threadId,
|
||
isNewThread,
|
||
hasStartedConversation,
|
||
promptPrefixOn,
|
||
promptPrefixSettings,
|
||
hideAdminPrompt,
|
||
preferencesLoading,
|
||
selectedAgentId,
|
||
agentHandoffPending,
|
||
isMock,
|
||
router,
|
||
settings.context.writing_mode,
|
||
writingArtifactPath,
|
||
],
|
||
);
|
||
|
||
const handleClarificationSubmit = useCallback(
|
||
(answer: string) => {
|
||
setHasStartedConversation(true);
|
||
void sendMessage(threadId, { text: answer, files: [] });
|
||
},
|
||
[sendMessage, threadId],
|
||
);
|
||
|
||
useEffect(() => {
|
||
const token = searchParams.get("browser_context")?.trim();
|
||
if (!token || browserContextAutoSubmitRef.current === token) {
|
||
return;
|
||
}
|
||
if (!threadId || isNewThread || thread.isLoading || agentHandoffPending) {
|
||
return;
|
||
}
|
||
|
||
const consumedKey = `deerflow.browser-context.consumed.${token}`;
|
||
if (window.sessionStorage.getItem(consumedKey)) {
|
||
browserContextAutoSubmitRef.current = token;
|
||
clearBrowserContextParamsFromHash();
|
||
return;
|
||
}
|
||
|
||
const filename = searchParams.get("bc_filename")?.trim();
|
||
const path = searchParams.get("bc_path")?.trim();
|
||
const size = Number(searchParams.get("bc_size") ?? "0");
|
||
const prompt =
|
||
searchParams.get("bc_prompt")?.trim() ||
|
||
"请基于刚刚发送的网页内容,先用中文总结要点,并等待我继续提问。";
|
||
|
||
if (!filename || !path || !Number.isFinite(size) || size < 0) {
|
||
browserContextAutoSubmitRef.current = token;
|
||
clearBrowserContextParamsFromHash();
|
||
toast.error("网页上下文参数不完整,无法自动发送到 Deer");
|
||
return;
|
||
}
|
||
|
||
browserContextAutoSubmitRef.current = token;
|
||
window.sessionStorage.setItem(consumedKey, "1");
|
||
clearBrowserContextParamsFromHash();
|
||
setHasStartedConversation(true);
|
||
|
||
void sendMessage(
|
||
threadId,
|
||
{ text: prompt, files: [] },
|
||
undefined,
|
||
{
|
||
additionalKwargs: {
|
||
browser_context: {
|
||
source: "chrome-extension",
|
||
token,
|
||
},
|
||
files: [
|
||
{
|
||
filename,
|
||
size,
|
||
path,
|
||
status: "uploaded",
|
||
},
|
||
],
|
||
},
|
||
},
|
||
).catch((err) => {
|
||
toast.error(
|
||
err instanceof Error ? err.message : "网页上下文发送失败,请重试",
|
||
);
|
||
});
|
||
}, [
|
||
searchParams,
|
||
threadId,
|
||
isNewThread,
|
||
thread.isLoading,
|
||
agentHandoffPending,
|
||
sendMessage,
|
||
]);
|
||
|
||
// Persist the picked agent the moment it changes so the next new chat
|
||
// defaults to it — even if the user never sends a message this session.
|
||
// Passing null (the ✕ clear) wipes the stored preference.
|
||
const handleAgentChange = useCallback((agentId: string | null) => {
|
||
setSelectedAgentId(agentId);
|
||
saveLastAgentId(agentId);
|
||
}, []);
|
||
const handleStop = useCallback(async () => {
|
||
await thread.stop();
|
||
}, [thread]);
|
||
|
||
const promptPrefixProp = useMemo(() => {
|
||
if (!promptPrefixSettings?.enabled) return undefined;
|
||
if (preferencesLoading) return undefined;
|
||
// Personal opt-out hides the admin prompt-prefix switch entirely.
|
||
if (hideAdminPrompt) return undefined;
|
||
// The prefix toggle is offered only on the opening turn of a new thread.
|
||
if (!isNewThread || hasStartedConversation) return undefined;
|
||
return {
|
||
label: promptPrefixSettings.switch_label || "前缀",
|
||
on: promptPrefixOn,
|
||
onToggle: () => setPromptPrefixOn((v) => !v),
|
||
};
|
||
}, [
|
||
promptPrefixSettings,
|
||
hideAdminPrompt,
|
||
preferencesLoading,
|
||
isNewThread,
|
||
hasStartedConversation,
|
||
promptPrefixOn,
|
||
]);
|
||
|
||
const messageListPaddingBottom = showFollowups
|
||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||
: undefined;
|
||
const isLandingState = isNewThread && !hasStartedConversation;
|
||
// 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画;
|
||
// 判定与 PageRoutes 的 compactWorkspaceSidebar(pageSidebarAllowed) 保持一致 ——
|
||
// 优先看外观设置的「全量/简洁」,未设置过才回退到会话级 sidebarEnabled。否则用户显式
|
||
// 选「全量」时 PageRoutes 已挂出顶栏+侧栏,这里却还按旧 sidebarEnabled 判定,导致深蓝
|
||
// 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。
|
||
const layoutMode = effectiveLayoutMode;
|
||
const chromeByPreference =
|
||
layoutMode === "full"
|
||
? true
|
||
: layoutMode === "compact"
|
||
? false
|
||
: readSidebarEnabled();
|
||
const compactChrome =
|
||
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
|
||
const compactLanding = isLandingState && compactChrome;
|
||
|
||
return (
|
||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||
<ChatBox threadId={threadId}>
|
||
<div
|
||
className={cn(
|
||
"relative flex size-full min-h-0 justify-between",
|
||
// 紧凑模式(有顶栏+左侧页面侧栏)下,深色背景换成设计稿深蓝主题底图
|
||
compactChrome && "workspace-chat-bg",
|
||
)}
|
||
>
|
||
<header
|
||
className={cn(
|
||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
|
||
isLandingState
|
||
? "border-transparent bg-transparent"
|
||
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
||
)}
|
||
>
|
||
<div className="flex w-full items-center text-sm font-medium">
|
||
<ThreadTitle threadId={threadId} thread={thread} />
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
|
||
{compactChrome && !historySidebarOpen && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={toggleHistorySidebar}
|
||
title="打开历史记录"
|
||
aria-label="打开历史记录"
|
||
className="text-muted-foreground hover:text-foreground"
|
||
>
|
||
<PanelLeftOpen className="size-4" />
|
||
</Button>
|
||
)}
|
||
<TokenUsageIndicator
|
||
enabled={tokenUsageEnabled}
|
||
messages={thread.messages}
|
||
/>
|
||
<ExportTrigger threadId={threadId} />
|
||
<KnowledgeSaveTrigger threadId={threadId} />
|
||
<ArtifactTrigger />
|
||
{/* 不带全局顶栏(紧凑模式)时,名称+岗位回落到聊天页顶栏右侧,
|
||
与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
|
||
{/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角,
|
||
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
|
||
{!compactChrome && (
|
||
<>
|
||
<CompactShortcutButtons tone="content" />
|
||
<ThemeSwitcher tone="content" />
|
||
<LayoutModeSwitcher tone="content" />
|
||
<div
|
||
className="bg-border/70 mx-1 h-6 w-px shrink-0"
|
||
aria-hidden
|
||
/>
|
||
<UserIdentityBadge />
|
||
</>
|
||
)}
|
||
</div>
|
||
</header>
|
||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||
<div className="flex size-full justify-center">
|
||
{/*
|
||
Landing state covers two cases: (a) a brand-new chat that
|
||
hasn't sent anything yet, and (b) the user clicking 「新对话」
|
||
mid-stream. In (b) the underlying useStream's internal
|
||
stream.values takes one or two effect ticks to clear, so
|
||
rendering MessageList unconditionally would leak the previous
|
||
conversation under the welcome card. Skip rendering during
|
||
the landing state to guarantee a clean slate.
|
||
*/}
|
||
{!isLandingState && (
|
||
<MessageList
|
||
className={cn("size-full", "pt-10")}
|
||
threadId={threadId}
|
||
thread={thread}
|
||
paddingBottom={messageListPaddingBottom}
|
||
tokenUsageEnabled={tokenUsageEnabled}
|
||
// 输入框为底部绝对浮层(约 160px 高),按钮上移到其上方居中。
|
||
scrollButtonClassName="bottom-40"
|
||
onClarificationSubmit={handleClarificationSubmit}
|
||
onEditResubmit={(text) =>
|
||
handleSubmit({ text, files: [] })
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
// 外层居中容器不吃指针事件:空白区域(尤其落地态铺满 inset-0 时盖住的
|
||
// 右上角「展开历史」按钮)让点击穿透,仅真正的内容块(下方 pointer-events-auto)
|
||
// 接收交互。否则该透明层会吞掉顶栏按钮的 hover/光标/点击。
|
||
"pointer-events-none absolute z-30 flex justify-center px-4",
|
||
// 紧凑落地态:整块垂直+水平居中于对话区
|
||
compactLanding
|
||
? "inset-0 items-center"
|
||
: "right-0 bottom-0 left-0",
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"pointer-events-auto relative w-full",
|
||
// 非紧凑落地态沿用上移居中;紧凑态由外层 items-center 居中,无需位移
|
||
isLandingState &&
|
||
!compactLanding &&
|
||
"-translate-y-[calc(50vh-96px)]",
|
||
compactLanding
|
||
? "max-w-(--container-width-xl)"
|
||
: isLandingState
|
||
? "max-w-(--container-width-md)"
|
||
: "max-w-(--container-width-lg)",
|
||
)}
|
||
>
|
||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||
<div className="absolute right-0 bottom-0 left-0">
|
||
<TodoList
|
||
className="bg-background/5"
|
||
todos={thread.values.todos ?? []}
|
||
hidden={
|
||
!thread.values.todos || thread.values.todos.length === 0
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{isLandingState && mounted &&
|
||
(compactLanding ? (
|
||
<CompactWelcome className="mb-6 -translate-y-4" />
|
||
) : (
|
||
<Welcome
|
||
mode={settings.context.mode}
|
||
className="mb-6 -translate-y-4"
|
||
/>
|
||
))}
|
||
{mounted ? (
|
||
<div className="w-full -translate-y-4">
|
||
<InputBox
|
||
className="w-full"
|
||
isNewThread={isLandingState}
|
||
compact={compactLanding}
|
||
threadId={threadId}
|
||
autoFocus={isLandingState}
|
||
status={
|
||
thread.error
|
||
? "error"
|
||
: thread.isLoading
|
||
? "streaming"
|
||
: "ready"
|
||
}
|
||
context={settings.context}
|
||
disabled={
|
||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||
isUploading ||
|
||
agentHandoffPending
|
||
}
|
||
onContextChange={(context) =>
|
||
setSettings("context", context)
|
||
}
|
||
promptPrefix={promptPrefixProp}
|
||
agentSelector={
|
||
isLandingState
|
||
? {
|
||
selectedAgentId,
|
||
onAgentChange: handleAgentChange,
|
||
}
|
||
: undefined
|
||
}
|
||
onFollowupsVisibilityChange={setShowFollowups}
|
||
onSubmit={handleSubmit}
|
||
onStop={handleStop}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div
|
||
aria-hidden="true"
|
||
className={cn(
|
||
"bg-background/5 h-32 w-full -translate-y-4 rounded-3xl border",
|
||
)}
|
||
/>
|
||
)}
|
||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||
{t.common.notAvailableInDemoMode}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</main>
|
||
{/*
|
||
ChatGPT-style question index. Anchored to the chat column (this
|
||
relative wrapper), not the browser edge; insets keep it below the
|
||
overlay header (h-12) and above the docked input box, and it is
|
||
pointer-events-none outside its own cards so it never blocks the
|
||
header buttons or input.
|
||
*/}
|
||
{!isLandingState && (
|
||
<QuestionNavigator
|
||
messages={thread.messages}
|
||
className="top-16 right-0 bottom-48 left-0"
|
||
/>
|
||
)}
|
||
</div>
|
||
</ChatBox>
|
||
</ThreadContext.Provider>
|
||
);
|
||
}
|