diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index 31ec10d9..20b6e434 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -12,7 +12,9 @@ import { ResizablePanelGroup, } from "@/components/ui/resizable"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useLocalSettings } from "@/core/settings"; +import { apiFetch } from "@/core/api"; +import { getBackendBaseURL } from "@/core/config"; +import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { useSkills } from "@/core/skills/hooks"; import { buildSkillDisplayMap, @@ -33,6 +35,33 @@ const CLOSE_MODE = { chat: 100, artifacts: 0 }; const OPEN_MODE = { chat: 60, artifacts: 40 }; const SHOW_REFERENCE_EVENT = "workspace:show-reference"; +type BackendReferenceSource = Parameters[0]["sources"][number]; + +type BackendReferenceBatch = { + id: string; + assistant_message_id?: string; + sources: BackendReferenceSource[]; +}; + +function logReferenceDebug( + event: string, + threadId: string, + payload: Record, +) { + console.info(`[RAG引用调试] ${event}`, payload); + void apiFetch(`${getBackendBaseURL()}/api/threads/reference-debug-log`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + event, + thread_id: threadId, + payload, + }), + }).catch(() => { + // Debug logging must never affect the chat flow. + }); +} + const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ children, threadId, @@ -42,10 +71,17 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ const threadIdRef = useRef(threadId); const artifactsCountRef = useRef(0); const layoutRef = useRef(null); + const latestReferenceBatchIdRef = useRef(null); + const manualReferenceBatchRef = useRef(false); + const referenceDebugSignatureRef = useRef(""); const [sideTab, setSideTab] = useState("document"); const [referencesOpen, setReferencesOpen] = useState(true); const [activeReferenceBatchId, setActiveReferenceBatchId] = useState(null); + const [pinnedReferenceId, setPinnedReferenceId] = useState(null); + const [backendReferenceBatches, setBackendReferenceBatches] = useState([]); const [settings] = useLocalSettings(); + const ragModeEnabled = + settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); const { skills } = useSkills(); const { @@ -60,27 +96,84 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); const skillDisplayMap = useMemo( - () => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), - [settings.display.ragModeEnabled, skills], + () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), + [ragModeEnabled, skills], ); - const referenceBatches = useMemo(() => { - if (!settings.display.ragModeEnabled) { + const fallbackReferenceBatches = useMemo(() => { + if (!ragModeEnabled) { return []; } - const messages = thread.values.messages ?? []; - return messages - .filter((message) => message.type === "ai" && message.id) - .map((message) => ({ - id: message.id!, - sources: collectRuntimeDisplaySources(messages, skillDisplayMap, { - afterMessageId: findPreviousHumanMessageId(messages, message.id), - beforeMessageId: message.id, - includeBeforeMessage: true, - includeModes: ["citation"], - }).filter((source) => source.mode === "citation"), - })) - .filter((batch) => batch.sources.length > 0); - }, [settings.display.ragModeEnabled, skillDisplayMap, thread.values.messages]); + return buildTurnReferenceBatches( + thread.values.messages ?? [], + skillDisplayMap, + thread.isLoading, + ); + }, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]); + useEffect(() => { + if (!ragModeEnabled) { + setBackendReferenceBatches([]); + return; + } + if (thread.isLoading) { + return; + } + const controller = new AbortController(); + logReferenceDebug("开始拉取后端参考文献", threadId, { + messageCount: thread.values.messages?.length ?? 0, + }); + apiFetch( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/reference-batches`, + { signal: controller.signal }, + ) + .then(async (response) => { + if (!response.ok) { + throw new Error(`Failed to load reference batches: ${response.status}`); + } + return (await response.json()) as { batches?: BackendReferenceBatch[] }; + }) + .then((data) => { + const batches = data.batches ?? []; + logReferenceDebug("后端参考文献返回", threadId, { + batchCount: batches.length, + sourceCounts: batches.map((batch) => batch.sources?.length ?? 0), + batchIds: batches.map((batch) => batch.id), + }); + setBackendReferenceBatches(batches); + }) + .catch((error) => { + if (!controller.signal.aborted) { + console.warn("Failed to load backend reference batches", error); + } + }); + return () => controller.abort(); + }, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]); + const referenceBatches = + backendReferenceBatches.length > 0 + ? backendReferenceBatches + : fallbackReferenceBatches; + useEffect(() => { + if (!ragModeEnabled) { + return; + } + const signature = JSON.stringify({ + backend: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0), + fallback: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0), + final: referenceBatches.map((batch) => batch.sources?.length ?? 0), + }); + if (referenceDebugSignatureRef.current === signature) { + return; + } + referenceDebugSignatureRef.current = signature; + logReferenceDebug("前端参考文献来源选择", threadId, { + using: backendReferenceBatches.length > 0 ? "后端接口" : "前端兜底", + backendBatchCount: backendReferenceBatches.length, + backendSourceCounts: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0), + fallbackBatchCount: fallbackReferenceBatches.length, + fallbackSourceCounts: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0), + finalBatchCount: referenceBatches.length, + finalSourceCounts: referenceBatches.map((batch) => batch.sources?.length ?? 0), + }); + }, [backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]); const latestReferenceBatch = referenceBatches.length > 0 ? referenceBatches[referenceBatches.length - 1]! @@ -98,20 +191,32 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ useEffect(() => { if (!latestReferenceBatch) { setActiveReferenceBatchId(null); + latestReferenceBatchIdRef.current = null; + manualReferenceBatchRef.current = false; return; } + const previousLatestId = latestReferenceBatchIdRef.current; + latestReferenceBatchIdRef.current = latestReferenceBatch.id; setActiveReferenceBatchId((current) => - current && referenceBatches.some((batch) => batch.id === current) + current && + referenceBatches.some((batch) => batch.id === current) && + manualReferenceBatchRef.current && + current !== previousLatestId ? current : latestReferenceBatch.id, ); }, [latestReferenceBatch?.id, referenceBatches]); useEffect(() => { const handleShowReference = (event: Event) => { - const detail = (event as CustomEvent<{ batchId?: string }>).detail; + const detail = (event as CustomEvent<{ batchId?: string; referenceId?: string }>).detail; if (detail?.batchId) { + manualReferenceBatchRef.current = true; setActiveReferenceBatchId(detail.batchId); } + setPinnedReferenceId(null); + window.setTimeout(() => { + setPinnedReferenceId(detail?.referenceId ?? null); + }, 0); setReferencesOpen(true); setSideTab("references"); }; @@ -125,7 +230,13 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ threadIdRef.current = threadId; artifactsCountRef.current = 0; deselect(); - setReferencesOpen(true); + setReferencesOpen(false); + setActiveReferenceBatchId(null); + setPinnedReferenceId(null); + setBackendReferenceBatches([]); + latestReferenceBatchIdRef.current = null; + manualReferenceBatchRef.current = false; + referenceDebugSignatureRef.current = ""; } // Update artifacts from the current thread @@ -257,7 +368,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ /> - + ) : selectedArtifact ? ( @@ -280,7 +394,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ - + ) : (
@@ -324,10 +441,27 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ }; function ReferencePanel({ + highlightedReferenceId, sources, }: { + highlightedReferenceId?: string | null; sources: Parameters[0]["sources"]; }) { + const orderedSources = useMemo(() => { + if (!highlightedReferenceId) { + return sources; + } + const index = Number(highlightedReferenceId); + if (!Number.isFinite(index)) { + return sources; + } + const selected = sources.find((source) => source.index === index); + if (!selected) { + return sources; + } + return [selected, ...sources.filter((source) => source.index !== index)]; + }, [highlightedReferenceId, sources]); + return (
@@ -344,7 +478,10 @@ function ReferencePanel({

- +
); @@ -368,4 +505,101 @@ function findPreviousHumanMessageId( return undefined; } +function buildTurnReferenceBatches( + messages: Message[], + skillDisplayMap: ReturnType, + isLoading: boolean, +): BackendReferenceBatch[] { + const batches: BackendReferenceBatch[] = []; + for (let index = 0; index < messages.length; index++) { + const human = messages[index]; + if (human?.type !== "human" || !human.id) { + continue; + } + const nextHumanIndex = findNextHumanIndex(messages, index + 1); + if (nextHumanIndex === -1 && isLoading) { + continue; + } + const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex; + const assistant = findLastAssistantAnswer(messages, index + 1, endIndex); + if (!assistant?.id) { + continue; + } + const sources = collectRuntimeDisplaySources(messages, skillDisplayMap, { + afterMessageId: human.id, + beforeMessageId: + nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id, + includeBeforeMessage: false, + includeModes: ["citation"], + }).filter((source) => source.mode === "citation"); + if (sources.length > 0) { + batches.push({ + id: assistant.id, + assistant_message_id: assistant.id, + sources, + }); + } + } + return batches; +} + +function findNextHumanIndex(messages: Message[], start: number): number { + for (let index = start; index < messages.length; index++) { + if (messages[index]?.type === "human") { + return index; + } + } + return -1; +} + +function findLastAssistantAnswer( + messages: Message[], + start: number, + end: number, +): Message | null { + for (let index = end - 1; index >= start; index--) { + const message = messages[index]; + if ( + message?.type === "ai" && + message.id && + hasAssistantAnswerContent(message) && + !hasAssistantToolCalls(message) + ) { + return message; + } + } + return null; +} + +function hasAssistantAnswerContent(message: Message): boolean { + const content = message.content; + if (typeof content === "string") { + return content.trim().length > 0; + } + if (!Array.isArray(content)) { + return false; + } + return content.some((part) => { + if (!part || typeof part !== "object") { + return false; + } + const block = part as Record; + return block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0; + }); +} + +function hasAssistantToolCalls(message: Message): boolean { + const direct = (message as { tool_calls?: unknown }).tool_calls; + if (Array.isArray(direct) && direct.length > 0) { + return true; + } + const chunks = (message as { tool_call_chunks?: unknown }).tool_call_chunks; + if (Array.isArray(chunks) && chunks.length > 0) { + return true; + } + const kwargs = (message as { additional_kwargs?: Record }).additional_kwargs; + const extra = kwargs?.tool_calls; + return Array.isArray(extra) && extra.length > 0; +} + export { ChatBox }; diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 6e1ce3b3..966268fd 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -87,7 +87,11 @@ import { } from "../ui/dropdown-menu"; import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; -import { useLocalSettings } from "@/core/settings"; +import { + isThreadRagModeLocked, + lockThreadRagMode, + useLocalSettings, +} from "@/core/settings"; import { useThread } from "./messages/context"; @@ -300,10 +304,14 @@ export function InputBox({ }); }, [context, memoryInjectionEnabled, onContextChange]); - const ragModeEnabled = localSettings.display.ragModeEnabled; - const handleRagModeToggle = useCallback(() => { - setLocalSettings("display", { ragModeEnabled: !ragModeEnabled }); - }, [ragModeEnabled, setLocalSettings]); + const ragModeLocked = isThreadRagModeLocked(threadId); + const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked; + const handleRagModeToggle = useCallback(() => { + if (ragModeLocked) { + return; + } + setLocalSettings("display", { ragModeEnabled: !ragModeEnabled }); + }, [ragModeEnabled, ragModeLocked, setLocalSettings]); const handleSubmit = useCallback( async (message: PromptInputMessage) => { @@ -316,9 +324,12 @@ export function InputBox({ } setFollowups([]); setFollowupsHidden(false); - setFollowupsLoading(false); - - // Guard against submitting before the initial model auto-selection + setFollowupsLoading(false); + if (ragModeEnabled) { + lockThreadRagMode(threadId); + } + + // Guard against submitting before the initial model auto-selection // effect has flushed thread settings to storage/state. if (resolvedModelName && context.model_name !== resolvedModelName) { onContextChange?.({ @@ -340,11 +351,13 @@ export function InputBox({ onContextChange, onSubmit, onStop, - resolvedModelName, - selectedModel?.supports_thinking, - status, - ], - ); + resolvedModelName, + ragModeEnabled, + selectedModel?.supports_thinking, + status, + threadId, + ], + ); const requestFormSubmit = useCallback(() => { const form = promptRootRef.current?.querySelector("form"); @@ -638,11 +651,13 @@ export function InputBox({ } > { return { - a: (props: AnchorHTMLAttributes) => { - if (typeof props.children === "string") { - const match = /^citation:(.+)$/.exec(props.children); - if (match) { + a: (props: AnchorHTMLAttributes) => { + if (props.href?.startsWith("#reference-")) { + return ; + } + if (typeof props.children === "string") { + const match = /^citation:(.+)$/.exec(props.children); + if (match) { const [, text] = match; return {text}; } diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index f586f9de..23924482 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -184,10 +184,13 @@ function MessageContent_({ img: (props: ImgHTMLAttributes) => ( ), - a: ({ href, ...props }: AnchorHTMLAttributes) => { - if (typeof props.children === "string" && /^citation:/i.test(props.children)) { - return ; - } + a: ({ href, ...props }: AnchorHTMLAttributes) => { + if (href?.startsWith("#reference-")) { + return ; + } + if (typeof props.children === "string" && /^citation:/i.test(props.children)) { + return ; + } if (href?.startsWith("/mnt/")) { const url = resolveArtifact(href); return ( diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index e659d72f..0523c307 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -21,7 +21,7 @@ import { hasToolCalls, } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; -import { useLocalSettings } from "@/core/settings"; +import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { useSkills } from "@/core/skills/hooks"; import { buildSkillDisplayMap, @@ -73,10 +73,12 @@ export function MessageList({ const updateSubtask = useUpdateSubtask(); const messages = thread.messages; const [settings] = useLocalSettings(); + const ragModeEnabled = + settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); const { skills } = useSkills(); const skillDisplayMap = useMemo( - () => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), - [settings.display.ragModeEnabled, skills], + () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), + [ragModeEnabled, skills], ); // Track which clarification messages are still awaiting an answer. @@ -198,7 +200,7 @@ export function MessageList({ if (group.type === "human" || group.type === "assistant") { return group.messages.map((msg) => { const displaySources = - settings.display.ragModeEnabled && + ragModeEnabled && group.type === "assistant" && msg.type === "ai" ? collectRuntimeDisplaySources(messages, skillDisplayMap, { diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx index b1498420..5970d17e 100644 --- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx @@ -21,10 +21,12 @@ import { getStoredAuth } from "@/core/auth"; import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n"; import { useI18n } from "@/core/i18n/hooks"; import { useLocalSettings } from "@/core/settings"; -import { - useAppearanceSettings, - useUpdateAppearanceSettings, -} from "@/core/system-settings/hooks"; +import { + useAppearanceSettings, + useCitationDisplaySettings, + useUpdateAppearanceSettings, + useUpdateCitationDisplaySettings, +} from "@/core/system-settings/hooks"; import { cn } from "@/lib/utils"; import { SettingsSection } from "./settings-section"; @@ -40,9 +42,13 @@ export function AppearanceSettingsPage() { const currentTheme = theme; const [settings, setSettings] = useLocalSettings(); const isAdmin = getStoredAuth()?.system_role === "admin"; - const { data: appearance } = useAppearanceSettings(); - const updateAppearance = useUpdateAppearanceSettings(); - const globalDefault = appearance?.default_theme ?? "light"; + const { data: appearance } = useAppearanceSettings(); + const updateAppearance = useUpdateAppearanceSettings(); + const { data: citationDisplay } = useCitationDisplaySettings(); + const updateCitationDisplay = useUpdateCitationDisplaySettings(); + const globalDefault = appearance?.default_theme ?? "light"; + const cleanupWords = + citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; const handleSetGlobalDefault = async (value: "light" | "dark") => { if (value === globalDefault) return; @@ -211,16 +217,16 @@ export function AppearanceSettingsPage() { 管理员配置。每行一个词或短句,参考文献卡片和详情弹框会自动移除这些广告词、导航词或免责声明。

-