Files
deerflow-code/frontend-web/src/pages/ChatPage.tsx
T
吉鑫升andClaude Opus 4.8 54fff09e74 feat(frontend): 不带 sidebar 的聊天页顶栏右侧也展示名称+岗位
紧凑布局(无全局 Header)下复用 UserIdentityBadge,数据源与全量模式 Header 一致,仅在 !compactChrome 时渲染,避免与全局顶栏重复。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:36:39 +08:00

641 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { 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 { 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) => {
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 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
{!compactChrome && (
<>
<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)]",
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>
);
}