1005 lines
40 KiB
TypeScript
1005 lines
40 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 { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
|
||
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 { CompactModeGuide } from "@/components/workspace/compact-mode-guide";
|
||
import { Welcome } from "@/components/workspace/welcome";
|
||
import { CompactWelcome } from "@/components/workspace/compact-welcome";
|
||
import {
|
||
buildFullChatNavigationPath,
|
||
isChatsIframeWorkspacePath,
|
||
} from "@/core/page-layout/chats-iframe-route";
|
||
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
|
||
import { isWujieEnv } from "@/core/embed/wujie";
|
||
import { useSidebar } from "@/components/ui/sidebar";
|
||
import { Button } from "@/components/ui/button";
|
||
import { MessageSquarePlus, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||
import { useI18n } from "@/core/i18n/hooks";
|
||
import { useModels } from "@/core/models/hooks";
|
||
import {
|
||
isFrontendRagProfile,
|
||
isMultiRoundRagProfile,
|
||
isSingleRoundRagProfile,
|
||
SINGLE_ROUND_RAG_MODELS,
|
||
} from "@/core/models/single-round-rag";
|
||
import { useNotification } from "@/core/notification/hooks";
|
||
import {
|
||
getLastAgentId,
|
||
saveLastAgentId,
|
||
useKnowledgeRagEnabled,
|
||
useThreadSettings,
|
||
} from "@/core/settings";
|
||
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
|
||
import {
|
||
myProjectConversationTypeFromSearchParams,
|
||
myProjectThreadMetadata,
|
||
} from "@/core/threads/project-metadata";
|
||
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
||
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
|
||
import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag";
|
||
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 navigate = useNavigate();
|
||
const searchParams = useSearchParams();
|
||
const pathname = usePathname();
|
||
// `chatMode=full` is used by an embedded standard chat. It intentionally
|
||
// overrides the user's compact-layout preference for this route only, so
|
||
// the page keeps the full landing composition and its agent/history sidebar.
|
||
const fullChatMode =
|
||
searchParams.get("chatMode")?.trim().toLowerCase() === "full";
|
||
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
|
||
useSidebar();
|
||
const { t } = useI18n();
|
||
// 有效界面模式:用户自选 > 管理员系统默认 > 内置默认「简洁模式」
|
||
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 [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
|
||
const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState<string[]>([]);
|
||
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
|
||
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
|
||
const knowledgeSelectionThread = useRef<string | null>(null);
|
||
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);
|
||
}, []);
|
||
const streamContext = useMemo(
|
||
() => ({
|
||
...settings.context,
|
||
knowledge_rag_enabled: knowledgeRagEnabled,
|
||
...(knowledgeSelectionReady
|
||
? {
|
||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
||
}
|
||
: {}),
|
||
}),
|
||
[
|
||
settings.context,
|
||
knowledgeRagEnabled,
|
||
knowledgeSelectionReady,
|
||
selectedAssistantKnowledgeBaseIds,
|
||
selectedKnowledgeBaseIds,
|
||
],
|
||
);
|
||
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(() => {
|
||
activeThreadIdRef.current = threadId;
|
||
}, [threadId]);
|
||
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (isNewThread) {
|
||
setHasStartedConversation(false);
|
||
setPromptPrefixOn(false);
|
||
setSelectedKnowledgeBaseIds([]);
|
||
setSelectedAssistantKnowledgeBaseIds([]);
|
||
setKnowledgeSelectionReady(false);
|
||
setKnowledgeSelectionLoading(false);
|
||
knowledgeSelectionThread.current = "__new__";
|
||
// 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]);
|
||
|
||
// The knowledge-base choice belongs to the conversation, not to a browser
|
||
// component instance. Restore it from LangGraph thread metadata whenever an
|
||
// existing conversation is opened. An explicit empty array means the user
|
||
// deliberately disabled LLMWiki retrieval for that conversation.
|
||
useEffect(() => {
|
||
if (isNewThread || !threadId || knowledgeSelectionThread.current === threadId) {
|
||
return;
|
||
}
|
||
knowledgeSelectionThread.current = threadId;
|
||
setKnowledgeSelectionLoading(true);
|
||
void getAPIClient(isMock)
|
||
.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);
|
||
}
|
||
});
|
||
}, [isMock, 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);
|
||
setSelectedKnowledgeBaseIds([]);
|
||
setSelectedAssistantKnowledgeBaseIds([]);
|
||
setKnowledgeSelectionReady(false);
|
||
}
|
||
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
||
|
||
const { showNotification } = useNotification();
|
||
const projectConversationType =
|
||
myProjectConversationTypeFromSearchParams(searchParams);
|
||
const newThreadMetadata = useMemo<Record<string, unknown> | undefined>(() => {
|
||
const metadata: Record<string, unknown> = projectConversationType
|
||
? myProjectThreadMetadata(projectConversationType)
|
||
: {};
|
||
if (knowledgeSelectionReady) {
|
||
metadata.llmwiki_knowledge_base_ids = selectedKnowledgeBaseIds;
|
||
metadata.assistant_knowledge_base_ids = selectedAssistantKnowledgeBaseIds;
|
||
}
|
||
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||
}, [
|
||
knowledgeSelectionReady,
|
||
projectConversationType,
|
||
selectedAssistantKnowledgeBaseIds,
|
||
selectedKnowledgeBaseIds,
|
||
]);
|
||
|
||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||
threadId: isNewThread ? undefined : threadId,
|
||
context: streamContext,
|
||
threadMetadata: newThreadMetadata,
|
||
isMock,
|
||
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}#${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 {
|
||
/* 上报绝不影响问答 */
|
||
}
|
||
queueLlmWikiConversationDeposit({
|
||
threadId: activeThreadIdRef.current,
|
||
messages: state.messages,
|
||
assistantId: selectedAgentId,
|
||
knowledgeBaseIds: selectedKnowledgeBaseIds,
|
||
});
|
||
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 singleRoundRag = useSingleRoundRagMessages(threadId);
|
||
const multiRoundRag = useMultiRoundRagMessages(threadId);
|
||
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
|
||
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
|
||
const frontendRagSelected = isFrontendRagProfile(settings.context);
|
||
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
||
// 流式 stub),成功且真实产物落地后自动撤出。
|
||
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
|
||
const displayThread = useMemo(() => {
|
||
const base = researchDisplayThread;
|
||
const ragMessages = toLangGraphMessages(multiRoundRag.messages);
|
||
if (
|
||
singleRoundRag.messages.length === 0 &&
|
||
ragMessages.length === 0 &&
|
||
!singleRoundRag.isLoading &&
|
||
!multiRoundRag.isLoading
|
||
) {
|
||
return base;
|
||
}
|
||
const messages = mergeMessagesByTime(
|
||
[...base.messages, ...singleRoundRag.messages],
|
||
ragMessages,
|
||
);
|
||
return {
|
||
...base,
|
||
values: { ...base.values, messages },
|
||
messages,
|
||
isLoading:
|
||
base.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading,
|
||
} as typeof base;
|
||
}, [
|
||
multiRoundRag.isLoading,
|
||
multiRoundRag.messages,
|
||
researchDisplayThread,
|
||
singleRoundRag.isLoading,
|
||
singleRoundRag.messages,
|
||
]);
|
||
|
||
const writingArtifactPath = useMemo(
|
||
() =>
|
||
resolveWritingModeReportPath(thread.values.artifacts, selectedArtifact),
|
||
[selectedArtifact, thread.values.artifacts],
|
||
);
|
||
|
||
const handleSubmit = useCallback(
|
||
(message: PromptInputMessage) => {
|
||
if (frontendRagSelected) {
|
||
if (message.files && message.files.length > 0) {
|
||
toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
|
||
return;
|
||
}
|
||
setHasStartedConversation(true);
|
||
window.dispatchEvent(
|
||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||
);
|
||
void (async () => {
|
||
try {
|
||
let targetThreadId = threadId;
|
||
if (isNewThread) {
|
||
const created = await getAPIClient(isMock).threads.create({
|
||
metadata: newThreadMetadata ?? {},
|
||
});
|
||
targetThreadId = created.thread_id;
|
||
setThreadId(targetThreadId);
|
||
setIsNewThread(false);
|
||
history.replaceState(
|
||
null,
|
||
"",
|
||
`${window.location.pathname}${window.location.search}#${routeBase}/chats/${targetThreadId}`,
|
||
);
|
||
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
|
||
if (title) {
|
||
void getAPIClient(isMock).threads
|
||
.updateState(targetThreadId, { values: { title } })
|
||
.catch(() => undefined);
|
||
}
|
||
}
|
||
if (multiRoundRagSelected) {
|
||
await multiRoundRag.submit({ targetThreadId, query: message.text });
|
||
} else {
|
||
await singleRoundRag.submit({
|
||
targetThreadId,
|
||
query: message.text,
|
||
modelName: settings.context.model_name ?? "OpenAI3",
|
||
});
|
||
}
|
||
} catch (error) {
|
||
toast.error(
|
||
error instanceof Error
|
||
? error.message
|
||
: multiRoundRagSelected
|
||
? "多轮 RAG 问答失败。"
|
||
: "单轮 RAG 问答失败。",
|
||
);
|
||
}
|
||
})();
|
||
return;
|
||
}
|
||
// 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: {
|
||
...newThreadMetadata,
|
||
agent_id: selectedAgentId,
|
||
},
|
||
});
|
||
sessionStorage.setItem(
|
||
"pendingAgentMessage",
|
||
JSON.stringify({
|
||
agentId: selectedAgentId,
|
||
threadId: created.thread_id,
|
||
text,
|
||
...(knowledgeSelectionReady
|
||
? {
|
||
knowledgeBaseIds: selectedKnowledgeBaseIds,
|
||
assistantKnowledgeBaseIds: selectedAssistantKnowledgeBaseIds,
|
||
}
|
||
: {}),
|
||
}),
|
||
);
|
||
saveLastAgentId(selectedAgentId);
|
||
router.push(
|
||
`${routeBase}/agents/${encodeURIComponent(selectedAgentId)}/chats/${encodeURIComponent(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;
|
||
}
|
||
// 新一轮提问:清掉上一轮深度研究报告的注入状态(中断态步骤条等)。
|
||
if (threadId) clearResearchReportStream(threadId);
|
||
void sendMessage(
|
||
threadId,
|
||
message,
|
||
Object.keys(extraContext).length > 0 ? extraContext : undefined,
|
||
);
|
||
},
|
||
[
|
||
sendMessage,
|
||
threadId,
|
||
isNewThread,
|
||
hasStartedConversation,
|
||
promptPrefixOn,
|
||
promptPrefixSettings,
|
||
hideAdminPrompt,
|
||
preferencesLoading,
|
||
selectedAgentId,
|
||
selectedAssistantKnowledgeBaseIds,
|
||
selectedKnowledgeBaseIds,
|
||
knowledgeSelectionReady,
|
||
newThreadMetadata,
|
||
agentHandoffPending,
|
||
isMock,
|
||
router,
|
||
settings.context.writing_mode,
|
||
settings.context.model_name,
|
||
writingArtifactPath,
|
||
singleRoundRag,
|
||
singleRoundRagSelected,
|
||
multiRoundRag,
|
||
multiRoundRagSelected,
|
||
frontendRagSelected,
|
||
routeBase,
|
||
setIsNewThread,
|
||
setThreadId,
|
||
],
|
||
);
|
||
|
||
const handleClarificationSubmit = useCallback(
|
||
async (answer: string) => {
|
||
setHasStartedConversation(true);
|
||
await stopActiveThreadBeforeClarificationReply(threadId, thread);
|
||
await sendMessage(threadId, { text: answer, files: [] });
|
||
},
|
||
[sendMessage, thread, 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);
|
||
setSelectedKnowledgeBaseIds([]);
|
||
setSelectedAssistantKnowledgeBaseIds([]);
|
||
setKnowledgeSelectionReady(false);
|
||
saveLastAgentId(agentId);
|
||
}, []);
|
||
const handleStop = useCallback(async () => {
|
||
if (singleRoundRag.isLoading) {
|
||
singleRoundRag.stop();
|
||
return;
|
||
}
|
||
if (multiRoundRag.isLoading) {
|
||
multiRoundRag.stop();
|
||
return;
|
||
}
|
||
await thread.stop();
|
||
}, [multiRoundRag, singleRoundRag, 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) 保持一致 ——
|
||
// 用 useLayoutMode 的有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认),
|
||
// 与 PageRoutes 的 chrome 判定同源。非紧凑(嵌入/standalone)保持原样。
|
||
const chromeByPreference =
|
||
fullChatMode || effectiveLayoutMode === "full" ? true : false;
|
||
const compactChrome =
|
||
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
|
||
const compactLanding = isLandingState && compactChrome;
|
||
const compactActionsHidden = isHideChromeActive();
|
||
const headerNewChatPath = useMemo(
|
||
() =>
|
||
buildFullChatNavigationPath(
|
||
`${routeBase}/chats/new`,
|
||
searchParams.toString(),
|
||
),
|
||
[routeBase, searchParams],
|
||
);
|
||
const handleHeaderNewChat = useCallback(() => {
|
||
navigate(headerNewChatPath, {
|
||
replace: true,
|
||
state: { resetAt: Date.now() },
|
||
});
|
||
}, [headerNewChatPath, navigate]);
|
||
// 简洁模式新手引导:仅面向「无全局导航栏/侧边栏」的正常简洁布局。iframe 嵌入
|
||
// (embed=1)、去 chrome(hideChrome=1)、强制收起侧栏(无界部署)等环境同样没有
|
||
// 全局 chrome,但不该在外部系统里弹引导。
|
||
const compactGuideEligible =
|
||
!compactChrome &&
|
||
!fullChatMode &&
|
||
!isChatsIframeWorkspacePath(pathname) &&
|
||
!compactActionsHidden &&
|
||
!env.FORCE_COLLAPSED_SIDEBAR &&
|
||
!isEmbedActive() &&
|
||
!isWujieEnv();
|
||
|
||
return (
|
||
<ThreadContext.Provider
|
||
value={{
|
||
thread: displayThread,
|
||
isMock,
|
||
knowledgeReferencesEnabled:
|
||
knowledgeSelectionReady && selectedKnowledgeBaseIds.length > 0,
|
||
}}
|
||
>
|
||
<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 min-w-0 flex-1 items-center overflow-hidden text-sm font-medium">
|
||
{/* 左上角始终提供左栏切换;展开态显示“收起”,收起态显示“展开”。 */}
|
||
{compactChrome && !compactActionsHidden && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={toggleHistorySidebar}
|
||
title={historySidebarOpen ? "收起左侧栏" : "展开左侧栏"}
|
||
aria-label={historySidebarOpen ? "收起左侧栏" : "展开左侧栏"}
|
||
className="mr-1 shrink-0 text-muted-foreground hover:text-foreground"
|
||
>
|
||
{historySidebarOpen ? (
|
||
<PanelLeftClose className="size-4" />
|
||
) : (
|
||
<PanelLeftOpen className="size-4" />
|
||
)}
|
||
</Button>
|
||
)}
|
||
<ThreadTitle
|
||
className="-translate-y-1"
|
||
threadId={threadId}
|
||
thread={displayThread}
|
||
/>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<TokenUsageIndicator
|
||
enabled={tokenUsageEnabled}
|
||
messages={displayThread.messages}
|
||
/>
|
||
{compactChrome && !compactActionsHidden && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleHeaderNewChat}
|
||
className="text-muted-foreground hover:text-foreground"
|
||
>
|
||
<MessageSquarePlus className="size-4" />
|
||
新建对话
|
||
</Button>
|
||
)}
|
||
<ExportTrigger threadId={threadId} />
|
||
<KnowledgeSaveTrigger threadId={threadId} />
|
||
<ArtifactTrigger />
|
||
{/* 不带全局顶栏(紧凑模式)时,名称+岗位回落到聊天页顶栏右侧,
|
||
与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
|
||
{/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角,
|
||
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
|
||
{!compactChrome && !compactActionsHidden && (
|
||
<>
|
||
<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={displayThread}
|
||
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-[calc(var(--container-width-md)+150px)]"
|
||
: "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={displayThread.values.todos ?? []}
|
||
hidden={
|
||
!displayThread.values.todos || displayThread.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"
|
||
data-compact-guide="prompt-input"
|
||
>
|
||
<InputBox
|
||
className="w-full"
|
||
isNewThread={isLandingState}
|
||
compact={compactLanding}
|
||
threadId={threadId}
|
||
autoFocus={isLandingState}
|
||
status={
|
||
thread.error
|
||
? "error"
|
||
: displayThread.isLoading || multiRoundRag.isLoading
|
||
? "streaming"
|
||
: "ready"
|
||
}
|
||
context={settings.context}
|
||
extraModels={SINGLE_ROUND_RAG_MODELS}
|
||
disabled={
|
||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||
isUploading ||
|
||
agentHandoffPending ||
|
||
knowledgeSelectionLoading ||
|
||
singleRoundRag.isLoading ||
|
||
multiRoundRag.isLoading
|
||
}
|
||
onContextChange={(context) =>
|
||
setSettings("context", context)
|
||
}
|
||
promptPrefix={promptPrefixProp}
|
||
agentSelector={
|
||
isLandingState
|
||
? {
|
||
selectedAgentId,
|
||
onAgentChange: handleAgentChange,
|
||
}
|
||
: undefined
|
||
}
|
||
knowledgeSelector={
|
||
<LlmWikiKnowledgeSelector
|
||
source={selectedAgentId ? "agent" : "all"}
|
||
agentId={selectedAgentId}
|
||
value={selectedKnowledgeBaseIds}
|
||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||
onChange={changeKnowledgeSelection}
|
||
onAssistantChange={changeAssistantKnowledgeSelection}
|
||
autoSelectAll={isLandingState && !!selectedAgentId}
|
||
disabled={
|
||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||
knowledgeSelectionLoading
|
||
}
|
||
readOnly={!isLandingState}
|
||
label="知识空间"
|
||
/>
|
||
}
|
||
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={displayThread.messages}
|
||
className="top-16 right-0 bottom-48 left-0"
|
||
/>
|
||
)}
|
||
<CompactModeGuide
|
||
eligible={compactGuideEligible}
|
||
autoStart={mounted && isLandingState}
|
||
/>
|
||
</div>
|
||
</ChatBox>
|
||
</ThreadContext.Provider>
|
||
);
|
||
}
|