实现RAG参考文献显示模式

This commit is contained in:
吉鑫升 authored and xuhd committed 2026-05-28 18:28:57 +08:00
1 parent 89bd07be92
commit f668133297
20 files changed
+1720 -184

No files matched your search

@@ -7,6 +7,10 @@
*/
export type SearchResultItem = {
id?: string;
recUuid?: string;
uuid?: string;
docId?: string;
title?: string;
url?: string;
snippet?: string;
@@ -300,6 +304,10 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
const date =
pickFromLayers(layers, DATE_ALIASES) ??
pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 });
const id = pickFromLayers(layers, ["id", "taskId", "recordId", "record_id"]);
const recUuid = pickFromLayers(layers, ["recUuid", "rec_uuid"]);
const uuid = pickFromLayers(layers, ["uuid"]);
const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]);
const reserved = new Set<string>();
for (const value of [title, content, url, source, date]) {
@@ -324,6 +332,10 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
}
return {
id,
recUuid,
uuid,
docId,
title,
url,
snippet: content,
+3 -2
View File
@@ -1,2 +1,3 @@
export { useLocalSettings, useThreadSettings } from "./hooks";
export type { LocalSettings } from "./local";
export { useLocalSettings, useThreadSettings } from "./hooks";
export { isThreadRagModeLocked, lockThreadRagMode } from "./local";
export type { LocalSettings } from "./local";
+27 -8
View File
@@ -17,8 +17,9 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
},
};
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
export const THREAD_RAG_MODE_KEY_PREFIX = "deerflow.thread-rag-mode.";
function isBrowser(): boolean {
return typeof window !== "undefined";
@@ -90,10 +91,10 @@ export function getThreadModelName(threadId: string): string | undefined {
return localStorage.getItem(getThreadModelStorageKey(threadId)) ?? undefined;
}
export function saveThreadModelName(
threadId: string,
modelName: string | undefined,
) {
export function saveThreadModelName(
threadId: string,
modelName: string | undefined,
) {
if (!isBrowser()) {
return;
}
@@ -102,8 +103,26 @@ export function saveThreadModelName(
localStorage.removeItem(key);
return;
}
localStorage.setItem(key, modelName);
}
localStorage.setItem(key, modelName);
}
function getThreadRagModeStorageKey(threadId: string): string {
return `${THREAD_RAG_MODE_KEY_PREFIX}${threadId}`;
}
export function isThreadRagModeLocked(threadId: string): boolean {
if (!isBrowser() || !threadId) {
return false;
}
return localStorage.getItem(getThreadRagModeStorageKey(threadId)) === "true";
}
export function lockThreadRagMode(threadId: string) {
if (!isBrowser() || !threadId) {
return;
}
localStorage.setItem(getThreadRagModeStorageKey(threadId), "true");
}
export function applyThreadModelOverride(
settings: LocalSettings,
+157 -39
View File
@@ -168,6 +168,7 @@ export function collectRuntimeDisplaySources(
const seenCitationKeys = new Map<string, RuntimeDisplaySource>();
const seenDisplayKeys = new Set<string>();
const consumedToolMessageIds = new Set<string>();
const consumedToolMessages = new Set<Message>();
for (let index = start; index < limit; index++) {
const message = messages[index];
@@ -180,14 +181,24 @@ export function collectRuntimeDisplaySources(
displays,
toolCall.args,
);
const result = findToolResultPayload(toolCall, messages, index);
const toolMessage = findToolResultMessage(
toolCall,
messages,
index,
consumedToolMessageIds,
consumedToolMessages,
);
if (!toolMessage) {
continue;
}
const result = parseToolMessagePayload(toolMessage);
if (result === undefined) {
continue;
}
const linkedToolMessage = findToolMessageById(toolCall.id, messages);
if (linkedToolMessage?.id) {
consumedToolMessageIds.add(linkedToolMessage.id);
if (toolMessage.id) {
consumedToolMessageIds.add(toolMessage.id);
}
consumedToolMessages.add(toolMessage);
if (isDisplaySamplePayload(result)) {
continue;
}
@@ -223,7 +234,12 @@ export function collectRuntimeDisplaySources(
for (let index = start; index < limit; index++) {
const message = messages[index];
if (!message || message.type !== "tool" || consumedToolMessageIds.has(message.id ?? "")) {
if (
!message ||
message.type !== "tool" ||
consumedToolMessages.has(message) ||
consumedToolMessageIds.has(message.id ?? "")
) {
continue;
}
const result = parseToolMessagePayload(message);
@@ -238,7 +254,8 @@ export function collectRuntimeDisplaySources(
}
const normalized = match
? normalizeDisplaySources(result, match.display)
: shouldUseDefaultCitationMode(toolName, result)
: shouldUseDefaultCitationMode(toolName, result) ||
normalizeDefaultDisplaySources(result).length > 0
? normalizeDefaultDisplaySources(result)
: [];
appendDisplaySources(
@@ -268,16 +285,14 @@ function appendDisplaySources(
) {
for (const draft of drafts) {
if (draft.mode === "citation") {
const key = citationSourceKey(draft);
const existing = seenCitationKeys.get(key);
if (existing) {
continue;
}
const source = {
...draft,
index: nextCitationIndex(),
};
seenCitationKeys.set(key, source);
const key = citationSourceKey(draft);
if (key) {
seenCitationKeys.set(`${key}:${source.index}`, source);
}
target.push(source);
continue;
}
@@ -331,12 +346,19 @@ function citationSourceKey(source: RuntimeDisplaySourceDraft): string {
stringifyMaybe(raw.docId) ||
stringifyMaybe(raw.id);
if (id) {
return normalizeName(`${source.skillName}:${id}`);
return normalizeCitationKey(String(id));
}
const fallback = [source.skillName, source.title, source.source, source.time]
const fallback = [
source.skillName,
source.title,
source.source,
source.time,
source.content,
source.snippet,
]
.filter(Boolean)
.join("|");
return normalizeName(fallback) || JSON.stringify(raw).slice(0, 300);
return normalizeCitationKey(fallback) || normalizeCitationKey(JSON.stringify(raw).slice(0, 300));
}
function nonCitationSourceKey(source: RuntimeDisplaySourceDraft): string {
@@ -543,7 +565,12 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
: undefined,
);
return normalized.map((item, index) => ({
id: item.url ?? String(index + 1),
id:
item.recUuid ??
item.uuid ??
item.docId ??
item.id ??
item.url,
title: item.title ?? item.source ?? `结果 ${index + 1}`,
type: "result",
snippet: item.snippet ?? item.content ?? "",
@@ -620,18 +647,79 @@ function replaceCitationTokens(
byIndex: Map<number, RuntimeDisplaySource>,
orderedSources: RuntimeDisplaySource[],
): string {
return content
const normalized = normalizeLooseCitationTokens(content, byIndex, orderedSources);
const withDoubleBracketCitations = normalized
.replace(/\[\[(\d+)\]\]/g, (match, rawIndex: string) =>
citationMarkdown(match, Number(rawIndex), byIndex, orderedSources),
)
.replace(/(^|[^:\[!])\[(\d+)\](?!\()/g, (match, prefix: string, rawIndex: string) =>
`${prefix}${citationMarkdown(
match.slice(prefix.length),
Number(rawIndex),
byIndex,
orderedSources,
)}`,
);
return replaceBracketCitationTokens(
withDoubleBracketCitations,
byIndex,
orderedSources,
);
}
function replaceBracketCitationTokens(
content: string,
byIndex: Map<number, RuntimeDisplaySource>,
orderedSources: RuntimeDisplaySource[],
): string {
const tokenPattern = /\\?\[(\d{1,3})\\?\](?!\()/g;
let cursor = 0;
let next = "";
for (const match of content.matchAll(tokenPattern)) {
const start = match.index ?? 0;
const previous = start > 0 ? content[start - 1] : "";
if (previous === ":" || previous === "[" || previous === "!") {
continue;
}
next += content.slice(cursor, start);
next += citationMarkdown(match[0], Number(match[1]), byIndex, orderedSources);
cursor = start + match[0].length;
}
next += content.slice(cursor);
return next;
}
function normalizeLooseCitationTokens(
content: string,
byIndex: Map<number, RuntimeDisplaySource>,
orderedSources: RuntimeDisplaySource[],
): string {
const isValidIndex = (index: number) =>
Number.isInteger(index) &&
index > 0 &&
(byIndex.has(index) || !!orderedSources[index - 1]);
const toCitationRun = (raw: string) => {
const parts = raw.match(/\\?\[\d{1,3}\\?\]|\d{1,3}/g) ?? [];
const indexes = parts.map((part) => Number(part.replace(/\D/g, "")));
if (indexes.length === 0 || indexes.some((index) => !isValidIndex(index))) {
return raw;
}
return indexes.map((index) => `[${index}]`).join("");
};
let next = content.replace(
/(^|[^\d\[\]\\])((?:\d{1,3}|\\?\[\d{1,3}\\?\])(?:\\?\[\d{1,3}\\?\]|\d{1,3})+)/g,
(match, prefix: string, rawRun: string) => {
if (!rawRun.includes("[")) {
return match;
}
return `${prefix}${toCitationRun(rawRun)}`;
},
);
next = next.replace(
/(^|[\s\uFF0C\u3002\uFF1B\uFF1A\u3001,!?!?)])(\d{1,3})(?=([\s\uFF0C\u3002\uFF1B\uFF1A\u3001,!?!?)]|$))/g,
(match, prefix: string, rawIndex: string) => {
const index = Number(rawIndex);
if (!isValidIndex(index)) {
return match;
}
return `${prefix}[${index}]`;
},
);
return next;
}
function normalizeExistingCitationLinks(
@@ -654,16 +742,16 @@ function normalizeExistingCitationLinks(
const index = Number(numberMatch[1]);
const source = byIndex.get(index) ?? orderedSources[index - 1];
if (source) {
return `[citation:[${source.index}]](#reference-${source.index})`;
return citationLinkMarkdown(source);
}
}
const sourceByUrl = byUrl.get(url);
if (sourceByUrl) {
return `[citation:[${sourceByUrl.index}]](#reference-${sourceByUrl.index})`;
return citationLinkMarkdown(sourceByUrl);
}
const sourceByTitle = byTitle.get(label);
if (sourceByTitle) {
return `[citation:[${sourceByTitle.index}]](#reference-${sourceByTitle.index})`;
return citationLinkMarkdown(sourceByTitle);
}
return match;
},
@@ -680,11 +768,11 @@ function citationMarkdown(
if (!source) {
return fallback;
}
return `[citation:[${source.index}]](#reference-${source.index})`;
return citationLinkMarkdown(source);
}
function hasCitationMarkdown(content: string): boolean {
return /\[citation:\[[^\]]+\]\]\(#reference-[^)]+\)/.test(content);
return /\[citation:(?:\[[^\]]+\]|\d+)\]\(#reference-[^)]+\)/.test(content);
}
function appendCitationSummary(
@@ -693,7 +781,7 @@ function appendCitationSummary(
): string {
const citations = citationSources
.slice(0, 5)
.map((source) => `[citation:[${source.index}]](#reference-${source.index})`)
.map((source) => citationLinkMarkdown(source))
.join(" ");
if (!citations) {
return content;
@@ -702,10 +790,22 @@ function appendCitationSummary(
return `${trimmed}${trimmed ? "\n\n" : ""}参考来源:${citations}`;
}
function citationLinkMarkdown(source: RuntimeDisplaySource): string {
return `[citation:${source.index}](#reference-${source.index})`;
}
function normalizeName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function normalizeCitationKey(value: string): string {
return value
.toLowerCase()
.normalize("NFKC")
.replace(/\s+/g, " ")
.trim();
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
@@ -714,18 +814,28 @@ function stringifyMaybe(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function findToolResultPayload(
function findToolResultMessage(
toolCall: RuntimeToolCall,
messages: Message[],
toolCallMessageIndex = -1,
): unknown {
const toolMessage =
findToolMessageById(toolCall.id, messages) ??
findToolMessageByName(toolCall, messages, toolCallMessageIndex);
if (!toolMessage) {
return undefined;
consumedToolMessageIds?: Set<string>,
consumedToolMessages?: Set<Message>,
): Message | undefined {
const byId = findToolMessageById(toolCall.id, messages);
if (
byId &&
!consumedToolMessages?.has(byId) &&
(!byId.id || !consumedToolMessageIds?.has(byId.id))
) {
return byId;
}
return parseToolMessagePayload(toolMessage);
return findToolMessageByName(
toolCall,
messages,
toolCallMessageIndex,
consumedToolMessageIds,
consumedToolMessages,
);
}
function parseToolMessagePayload(toolMessage: Message): unknown {
@@ -782,6 +892,8 @@ function findToolMessageByName(
toolCall: RuntimeToolCall,
messages: Message[],
toolCallMessageIndex: number,
consumedToolMessageIds?: Set<string>,
consumedToolMessages?: Set<Message>,
): Message | undefined {
const start = Math.max(toolCallMessageIndex + 1, 0);
for (let index = start; index < messages.length; index++) {
@@ -795,6 +907,12 @@ function findToolMessageByName(
if (message.type !== "tool") {
continue;
}
if (message.id && consumedToolMessageIds?.has(message.id)) {
continue;
}
if (consumedToolMessages?.has(message)) {
continue;
}
const name =
stringifyMaybe((message as { name?: unknown }).name) ??
stringifyMaybe(
@@ -53,6 +53,14 @@ export interface PrivateSquareUpdate {
password?: string;
}
export interface CitationDisplaySettings {
cleanup_words: string[];
}
export interface CitationDisplayUpdate {
cleanup_words?: string[];
}
export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/private-square`,
@@ -107,6 +115,38 @@ export async function updateAppearanceSettings(
return (await response.json()) as AppearanceSettings;
}
export async function loadCitationDisplaySettings(): Promise<CitationDisplaySettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/citation-display`,
);
if (!response.ok) {
throw new Error(
`Failed to load citation display settings: ${response.status}`,
);
}
return (await response.json()) as CitationDisplaySettings;
}
export async function updateCitationDisplaySettings(
body: CitationDisplayUpdate,
): Promise<CitationDisplaySettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/citation-display`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update citation display" }));
throw new Error(detail.detail ?? "Failed to update citation display");
}
return (await response.json()) as CitationDisplaySettings;
}
export async function updatePrivateSquareSettings(
body: PrivateSquareUpdate,
): Promise<PrivateSquareStatus> {
@@ -2,12 +2,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
loadAppearanceSettings,
loadCitationDisplaySettings,
loadPrivateSquareStatus,
loadPromptPrefixSettings,
updateAppearanceSettings,
updateCitationDisplaySettings,
updatePrivateSquareSettings,
updatePromptPrefixSettings,
type AppearanceUpdate,
type CitationDisplayUpdate,
type PrivateSquareUpdate,
type PromptPrefixUpdate,
} from "./api";
@@ -15,6 +18,7 @@ import {
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
export function usePromptPrefixSettings() {
return useQuery({
@@ -77,3 +81,24 @@ export function useUpdateAppearanceSettings() {
},
});
}
export function useCitationDisplaySettings() {
return useQuery({
queryKey: CITATION_DISPLAY_KEY,
queryFn: () => loadCitationDisplaySettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateCitationDisplaySettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: CitationDisplayUpdate) =>
updateCitationDisplaySettings(body),
onSuccess: (data) => {
queryClient.setQueryData(CITATION_DISPLAY_KEY, data);
},
});
}
+75 -8
View File
@@ -3,17 +3,19 @@ import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
import { useStream } from "@langchain/langgraph-sdk/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { getAPIClient } from "../api";
import { apiFetch } from "../api";
import { getStoredAuth } from "../auth";
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 { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings";
import { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
@@ -137,6 +139,42 @@ function getStreamErrorMessage(error: unknown): string {
return "Request failed.";
}
/**
* Detect "thread not found" / forbidden errors from the LangGraph SDK.
*
* Backend returns 404 both when a thread truly doesn't exist and when the
* currently authenticated user doesn't own it — the SDK surfaces both as
* ``HTTP 404: {"detail":"Thread <uuid> not found"}``. The most common cause
* we see in practice is a stale tab whose URL still points at the previous
* login session's thread. Raw-toasting the 404 just confuses the user, so
* we re-frame it around the current login identity.
*/
function isThreadNotAccessibleError(message: string): boolean {
const lowered = message.toLowerCase();
if (/thread\s+[\w-]{8,}\s+not\s+found/i.test(message)) {
return true;
}
// Fallback: a bare 404/403 from the threads endpoint when the SDK has
// wrapped the detail differently.
return (
(lowered.includes("http 404") || lowered.includes("http 403")) &&
lowered.includes("thread")
);
}
function buildCurrentUserLabel(): string {
const auth = getStoredAuth();
if (!auth) {
return "未登录";
}
const email = auth.email?.trim();
if (email) {
return email;
}
const userId = auth.user_id?.trim();
return userId ? userId : "未知用户";
}
export function useThreadStream({
threadId,
context,
@@ -195,6 +233,7 @@ export function useThreadStream({
const queryClient = useQueryClient();
const updateSubtask = useUpdateSubtask();
const navigate = useNavigate();
const runMetadataStorageRef = useRef<
ReturnType<typeof getRunMetadataStorage> | undefined
>(undefined);
@@ -294,7 +333,29 @@ export function useThreadStream({
},
onError(error) {
setOptimisticMessages([]);
toast.error(getStreamErrorMessage(error));
const message = getStreamErrorMessage(error);
// A "thread not found" 404 almost always means the URL points at a
// thread from a previous login session — either this tab outlived a
// logout/re-login or the thread genuinely belongs to a different user.
// Re-frame the error around the *current* logged-in identity and bounce
// the user to a fresh chat instead of leaving them stuck on the stale
// route looking at a raw HTTP 404.
if (isThreadNotAccessibleError(message)) {
const userLabel = buildCurrentUserLabel();
toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
);
// Prefer staying inside an agent chat context when the user is on an
// agent thread URL — otherwise fall back to the global new chat.
const hash = typeof window !== "undefined" ? window.location.hash : "";
const agentMatch = hash.match(/#\/page\/workspace\/agents\/([^/]+)\//);
const target = agentMatch
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
: "/page/workspace/chats/new";
navigate(target, { replace: true });
return;
}
toast.error(message);
},
onFinish(state) {
listeners.current.onFinish?.(state.values);
@@ -494,7 +555,13 @@ export function useThreadStream({
}),
);
await thread.submit(
const ragModeEnabled =
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
await thread.submit(
{
messages: [
{
@@ -537,10 +604,10 @@ export function useThreadStream({
? "low"
: undefined),
memory_injection_enabled: context.memory_injection_enabled ?? true,
rag_mode_enabled: localSettings.display.ragModeEnabled,
thread_id: threadId,
},
},
rag_mode_enabled: ragModeEnabled,
thread_id: threadId,
},
},
);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
} catch (error) {