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={
-
-
-
+ } />
}
/>