diff --git a/frontend-web/src/components/workspace/chats/use-thread-chat.ts b/frontend-web/src/components/workspace/chats/use-thread-chat.ts index 54145f96..9bfaada8 100644 --- a/frontend-web/src/components/workspace/chats/use-thread-chat.ts +++ b/frontend-web/src/components/workspace/chats/use-thread-chat.ts @@ -2,12 +2,19 @@ import { useParams, usePathname, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; +import { useLocation } from "react-router-dom"; import { uuid } from "@/core/utils/uuid"; export function useThreadChat() { const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const pathname = usePathname(); + // ``location.key`` is regenerated on every react-router navigation, even when + // the resolved pathname is identical. We depend on it so that clicking + // "新对话" while already at /chats/new still triggers a fresh thread reset + // (otherwise navigate-to-same-path is silently a no-op and the in-flight + // conversation would persist on screen). + const locationKey = useLocation().key; const searchParams = useSearchParams(); const [threadId, setThreadId] = useState(() => { @@ -37,7 +44,7 @@ export function useThreadChat() { } setIsNewThread(false); setThreadId(threadIdFromPath); - }, [pathname, threadIdFromPath]); + }, [pathname, threadIdFromPath, locationKey]); const isMock = searchParams.get("mock") === "true"; return { threadId, setThreadId, isNewThread, setIsNewThread, isMock }; } diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 43640eeb..f1e54ee5 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -2,9 +2,9 @@ import type { ChatStatus } from "ai"; import { - BrainCircuitIcon, - BookMarkedIcon, - CheckIcon, + BrainCircuitIcon, + BookMarkedIcon, + CheckIcon, FileTextIcon, GraduationCapIcon, LightbulbIcon, @@ -85,8 +85,8 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu"; -import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; -import { useLocalSettings } from "@/core/settings"; +import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; +import { useLocalSettings } from "@/core/settings"; import { useThread } from "./messages/context"; @@ -177,8 +177,8 @@ export function InputBox({ onAgentChange: (agentId: string | null) => void; }; }) { - const { t } = useI18n(); - const [localSettings, setLocalSettings] = useLocalSettings(); + const { t } = useI18n(); + const [localSettings, setLocalSettings] = useLocalSettings(); const searchParams = useSearchParams(); const [modelDialogOpen, setModelDialogOpen] = useState(false); const { models } = useModels(); @@ -291,18 +291,18 @@ export function InputBox({ }); }, [context, onContextChange]); - const memoryInjectionEnabled = context.memory_injection_enabled ?? true; + const memoryInjectionEnabled = context.memory_injection_enabled ?? true; const handleMemoryInjectionToggle = useCallback(() => { onContextChange?.({ ...context, memory_injection_enabled: !memoryInjectionEnabled, }); - }, [context, memoryInjectionEnabled, onContextChange]); - - const ragModeEnabled = localSettings.display.ragModeEnabled; - const handleRagModeToggle = useCallback(() => { - setLocalSettings("display", { ragModeEnabled: !ragModeEnabled }); - }, [ragModeEnabled, setLocalSettings]); + }, [context, memoryInjectionEnabled, onContextChange]); + + const ragModeEnabled = localSettings.display.ragModeEnabled; + const handleRagModeToggle = useCallback(() => { + setLocalSettings("display", { ragModeEnabled: !ragModeEnabled }); + }, [ragModeEnabled, setLocalSettings]); const handleSubmit = useCallback( async (message: PromptInputMessage) => { @@ -628,28 +628,33 @@ export function InputBox({ 记忆 - - - - - 参考文献 - - - + + + + {ragModeEnabled && ( + + )} + + 参考文献 + + + ) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) { + return; + } + event.preventDefault(); + navigate("/page/workspace/chats/new", { + replace: true, + state: { resetAt: Date.now() }, + }); + }; return ( @@ -47,7 +63,7 @@ export function WorkspaceNavChatList({ asChild className={cn(NEW_CHAT_CLASS, showCollapseTrigger && "min-w-0 flex-1")} > - + {t.sidebar.newChat} diff --git a/frontend-web/src/core/settings/local.ts b/frontend-web/src/core/settings/local.ts index 8e5166ab..36c19ffc 100644 --- a/frontend-web/src/core/settings/local.ts +++ b/frontend-web/src/core/settings/local.ts @@ -4,10 +4,10 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = { notification: { enabled: true, }, - display: { - streamSpeedMeterEnabled: true, - ragModeEnabled: true, - }, + display: { + streamSpeedMeterEnabled: true, + ragModeEnabled: false, + }, context: { model_name: undefined, mode: undefined, @@ -33,13 +33,13 @@ export interface LocalSettings { * AI reply is in flight. Default on; controlled by the user from * the appearance settings page. */ - streamSpeedMeterEnabled: boolean; - /** - * Global switch for skill result display adapters. When disabled, chat - * messages render tool results with the legacy generic search UI only. - */ - ragModeEnabled: boolean; - }; + streamSpeedMeterEnabled: boolean; + /** + * Global switch for skill result display adapters. When disabled, chat + * messages render tool results with the legacy generic search UI only. + */ + ragModeEnabled: boolean; + }; context: Omit< AgentThreadContext, | "thread_id" diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index ecb8a5d8..568b204e 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -12,8 +12,8 @@ import { apiFetch } from "../api"; import { getBackendBaseURL } from "../config"; import { useI18n } from "../i18n/hooks"; import type { FileInMessage } from "../messages/utils"; -import type { LocalSettings } from "../settings"; -import { useLocalSettings } from "../settings"; +import type { LocalSettings } from "../settings"; +import { useLocalSettings } from "../settings"; import { useUpdateSubtask } from "../tasks/context"; import type { UploadedFileInfo } from "../uploads"; import { promptInputFilePartToFile, uploadFiles } from "../uploads"; @@ -146,8 +146,8 @@ export function useThreadStream({ onFinish, onToolEnd, }: ThreadStreamOptions) { - const { t } = useI18n(); - const [localSettings] = useLocalSettings(); + const { t } = useI18n(); + const [localSettings] = useLocalSettings(); // Track the thread ID that is currently streaming to handle thread changes during streaming const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId); // Ref to track current thread ID across async callbacks without causing re-renders, @@ -309,9 +309,27 @@ export function useThreadStream({ // Track message count before sending so we know when server has responded const prevMsgCountRef = useRef(thread.messages.length); + // Keep a ref to the latest `thread` so the threadId-change effect can stop + // an in-flight stream without re-running on every render that produces a + // new `thread` identity. + const threadStreamRef = useRef(thread); + threadStreamRef.current = thread; + // Reset thread-local pending UI state when switching between threads so // optimistic messages and in-flight guards do not leak across chat views. + // Also abort any in-flight SSE from the previous thread: without this, + // clicking "新对话" mid-stream leaves the old run firing onUpdate events + // against the now-detached thread context, which manifests as the page + // appearing unresponsive to the navigation. useEffect(() => { + const prevThread = threadStreamRef.current; + if (prevThread?.isLoading) { + try { + prevThread.stop(); + } catch { + // best-effort; nothing useful to do if the SDK fails to abort + } + } startedRef.current = false; sendInFlightRef.current = false; prevMsgCountRef.current = 0; @@ -508,9 +526,9 @@ export function useThreadStream({ : context.mode === "thinking" ? "low" : undefined), - memory_injection_enabled: context.memory_injection_enabled ?? true, - rag_mode_enabled: localSettings.display.ragModeEnabled, - thread_id: threadId, + memory_injection_enabled: context.memory_injection_enabled ?? true, + rag_mode_enabled: localSettings.display.ragModeEnabled, + thread_id: threadId, }, }, ); @@ -523,8 +541,8 @@ export function useThreadStream({ sendInFlightRef.current = false; } }, - [thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient], - ); + [thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient], + ); // Merge thread with optimistic messages for display const mergedThread = diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index a721f0da..4f7b4cbb 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -1,5 +1,5 @@ import type { CSSProperties, ReactNode } from "react"; -import { Navigate, Route, Routes } from "react-router-dom"; +import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { getStoredAuth } from "@/core/auth"; @@ -87,6 +87,20 @@ export function ChatRuntime({ children }: { children: ReactNode }) { ); } +/** + * 让 ChatPage 在每次真正的 react-router navigation 上完整 remount。 + * + * 背景:点「新对话」时即使路径不变,我们也会用 `navigate(..., { replace: true, + * state: { resetAt } })` 强制刷新 location.key —— 用它作 key 能保证 useStream、 + * useThreadStream、optimistic state、refs 一次性全部丢弃,避免上一条流的 + * `stream.values` 残留把欢迎页底下的旧消息拖出来(SDK 的 stream.clear() 需要 + * 多次 effect tick 才会清干净,中间过渡帧用户能看见)。 + */ +function KeyedChatPage({ render }: { render: () => ReactNode }) { + const key = useLocation().key; + return {render()}; +} + export default function WorkspaceRoutes({ pageSidebarCollapsed = false, pageSidebarHidden = false, @@ -121,9 +135,7 @@ export default function WorkspaceRoutes({ path="chats/:thread_id" element={ - - - + } /> } />