实现RAG参考文献显示模式
This commit is contained in:
1 parent
89bd07be92
commit
f668133297
20 files changed
+1720
-184
No files matched your search
@@ -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<typeof SkillDisplaySources>[0]["sources"][number];
|
||||
|
||||
type BackendReferenceBatch = {
|
||||
id: string;
|
||||
assistant_message_id?: string;
|
||||
sources: BackendReferenceSource[];
|
||||
};
|
||||
|
||||
function logReferenceDebug(
|
||||
event: string,
|
||||
threadId: string,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
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<GroupImperativeHandle>(null);
|
||||
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
||||
const manualReferenceBatchRef = useRef(false);
|
||||
const referenceDebugSignatureRef = useRef<string>("");
|
||||
const [sideTab, setSideTab] = useState("document");
|
||||
const [referencesOpen, setReferencesOpen] = useState(true);
|
||||
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
|
||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||
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 }> = ({
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||
<ReferencePanel sources={referenceSources} />
|
||||
<ReferencePanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : selectedArtifact ? (
|
||||
@@ -280,7 +394,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<ReferencePanel sources={referenceSources} />
|
||||
<ReferencePanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
@@ -324,10 +441,27 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
};
|
||||
|
||||
function ReferencePanel({
|
||||
highlightedReferenceId,
|
||||
sources,
|
||||
}: {
|
||||
highlightedReferenceId?: string | null;
|
||||
sources: Parameters<typeof SkillDisplaySources>[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 (
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
<header className="shrink-0 px-1 pb-3">
|
||||
@@ -344,7 +478,10 @@ function ReferencePanel({
|
||||
</p>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-1 pt-1 pb-6">
|
||||
<SkillDisplaySources sources={sources} />
|
||||
<SkillDisplaySources
|
||||
highlightedReferenceId={highlightedReferenceId}
|
||||
sources={orderedSources}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
@@ -368,4 +505,101 @@ function findPreviousHumanMessageId(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildTurnReferenceBatches(
|
||||
messages: Message[],
|
||||
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
|
||||
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<string, unknown>;
|
||||
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<string, unknown> }).additional_kwargs;
|
||||
const extra = kwargs?.tool_calls;
|
||||
return Array.isArray(extra) && extra.length > 0;
|
||||
}
|
||||
|
||||
export { ChatBox };
|
||||
@@ -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({
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
aria-pressed={ragModeEnabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
ragModeEnabled
|
||||
type="button"
|
||||
aria-pressed={ragModeEnabled}
|
||||
disabled={ragModeLocked || disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
ragModeLocked && "cursor-not-allowed opacity-80",
|
||||
ragModeEnabled
|
||||
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
|
||||
@@ -36,10 +36,13 @@ export function MarkdownContent({
|
||||
}: MarkdownContentProps) {
|
||||
const components = useMemo(() => {
|
||||
return {
|
||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (typeof props.children === "string") {
|
||||
const match = /^citation:(.+)$/.exec(props.children);
|
||||
if (match) {
|
||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (props.href?.startsWith("#reference-")) {
|
||||
return <CitationLink {...props} />;
|
||||
}
|
||||
if (typeof props.children === "string") {
|
||||
const match = /^citation:(.+)$/.exec(props.children);
|
||||
if (match) {
|
||||
const [, text] = match;
|
||||
return <CitationLink {...props}>{text}</CitationLink>;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
管理员配置。每行一个词或短句,参考文献卡片和详情弹框会自动移除这些广告词、导航词或免责声明。
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
value={(settings.display.citationCleanupWords ?? []).join("\n")}
|
||||
onChange={(event) =>
|
||||
setSettings("display", {
|
||||
citationCleanupWords: event.currentTarget.value
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
<Textarea
|
||||
value={cleanupWords.join("\n")}
|
||||
onChange={(event) => {
|
||||
const words = event.currentTarget.value
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
setSettings("display", { citationCleanupWords: words });
|
||||
updateCitationDisplay.mutate({ cleanup_words: words });
|
||||
}}
|
||||
placeholder={"广告\n点击下载APP\n打开客户端\n免责声明\n责任编辑"}
|
||||
className="min-h-32 text-sm"
|
||||
/>
|
||||
|
||||
@@ -10,13 +10,16 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
export function SkillDisplaySources({
|
||||
highlightedReferenceId,
|
||||
sources,
|
||||
className,
|
||||
}: {
|
||||
highlightedReferenceId?: string | null;
|
||||
sources: RuntimeDisplaySource[];
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -29,6 +32,7 @@ export function SkillDisplaySources({
|
||||
{sources.map((source) => (
|
||||
<SourceCard
|
||||
key={`${source.skillName}-${source.index}`}
|
||||
highlighted={highlightedReferenceId === String(source.index)}
|
||||
source={source}
|
||||
referenceTarget
|
||||
/>
|
||||
@@ -38,15 +42,19 @@ export function SkillDisplaySources({
|
||||
}
|
||||
|
||||
export function SourceCard({
|
||||
highlighted = false,
|
||||
source,
|
||||
referenceTarget = false,
|
||||
}: {
|
||||
highlighted?: boolean;
|
||||
source: RuntimeDisplaySource;
|
||||
referenceTarget?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [settings] = useLocalSettings();
|
||||
const cleanupWords = settings.display.citationCleanupWords ?? [];
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||
const snippet = compactReferenceText(content).slice(0, 280);
|
||||
const paragraphs = segmentReferenceText(content);
|
||||
@@ -72,6 +80,8 @@ export function SourceCard({
|
||||
className={cn(
|
||||
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
|
||||
"target:bg-primary/5 target:border-primary/60",
|
||||
highlighted &&
|
||||
"animate-[reference-flash_1.4s_ease-out] border-amber-400 bg-amber-50 shadow-[0_0_0_3px_rgba(251,191,36,0.22)]",
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
@@ -168,10 +178,13 @@ function cleanReferenceText(
|
||||
cleanupWords: string[],
|
||||
): string {
|
||||
let text = raw ?? "";
|
||||
for (const word of cleanupWords) {
|
||||
const trimmed = word.trim();
|
||||
if (!trimmed) continue;
|
||||
text = text.split(trimmed).join("");
|
||||
const words = cleanupWords.map((word) => word.trim()).filter(Boolean);
|
||||
if (words.length > 0) {
|
||||
text = text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split("\n")
|
||||
.filter((line) => !words.some((word) => line.includes(word)))
|
||||
.join("\n");
|
||||
}
|
||||
return text
|
||||
.replace(/\r\n/g, "\n")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -567,6 +567,20 @@
|
||||
transform: translateY(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reference-flash {
|
||||
0% {
|
||||
background: rgba(254, 243, 199, 0.95);
|
||||
box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.28);
|
||||
}
|
||||
70% {
|
||||
background: rgba(254, 243, 199, 0.58);
|
||||
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.14);
|
||||
}
|
||||
100% {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Tiptap writing editor ─────────────────────────────────────────────── */
|
||||
@@ -818,4 +832,4 @@
|
||||
* {
|
||||
border-color: var(--border);
|
||||
outline-color: var(--ring);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user