实现RAG参考文献显示模式
This commit is contained in:
1 parent
89bd07be92
commit
f668133297
20 files changed
+1620
-84
No files matched your search
@@ -12,7 +12,9 @@ import {
|
|||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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 { useSkills } from "@/core/skills/hooks";
|
||||||
import {
|
import {
|
||||||
buildSkillDisplayMap,
|
buildSkillDisplayMap,
|
||||||
@@ -33,6 +35,33 @@ const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
|||||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
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 }> = ({
|
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||||
children,
|
children,
|
||||||
threadId,
|
threadId,
|
||||||
@@ -42,10 +71,17 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
const threadIdRef = useRef(threadId);
|
const threadIdRef = useRef(threadId);
|
||||||
const artifactsCountRef = useRef(0);
|
const artifactsCountRef = useRef(0);
|
||||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
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 [sideTab, setSideTab] = useState("document");
|
||||||
const [referencesOpen, setReferencesOpen] = useState(true);
|
const [referencesOpen, setReferencesOpen] = useState(true);
|
||||||
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
|
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
|
||||||
|
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||||
|
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
|
const ragModeEnabled =
|
||||||
|
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -60,27 +96,84 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
|
|
||||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||||
const skillDisplayMap = useMemo(
|
const skillDisplayMap = useMemo(
|
||||||
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||||
[settings.display.ragModeEnabled, skills],
|
[ragModeEnabled, skills],
|
||||||
);
|
);
|
||||||
const referenceBatches = useMemo(() => {
|
const fallbackReferenceBatches = useMemo(() => {
|
||||||
if (!settings.display.ragModeEnabled) {
|
if (!ragModeEnabled) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const messages = thread.values.messages ?? [];
|
return buildTurnReferenceBatches(
|
||||||
return messages
|
thread.values.messages ?? [],
|
||||||
.filter((message) => message.type === "ai" && message.id)
|
skillDisplayMap,
|
||||||
.map((message) => ({
|
thread.isLoading,
|
||||||
id: message.id!,
|
);
|
||||||
sources: collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
}, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||||
afterMessageId: findPreviousHumanMessageId(messages, message.id),
|
useEffect(() => {
|
||||||
beforeMessageId: message.id,
|
if (!ragModeEnabled) {
|
||||||
includeBeforeMessage: true,
|
setBackendReferenceBatches([]);
|
||||||
includeModes: ["citation"],
|
return;
|
||||||
}).filter((source) => source.mode === "citation"),
|
}
|
||||||
}))
|
if (thread.isLoading) {
|
||||||
.filter((batch) => batch.sources.length > 0);
|
return;
|
||||||
}, [settings.display.ragModeEnabled, skillDisplayMap, thread.values.messages]);
|
}
|
||||||
|
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 =
|
const latestReferenceBatch =
|
||||||
referenceBatches.length > 0
|
referenceBatches.length > 0
|
||||||
? referenceBatches[referenceBatches.length - 1]!
|
? referenceBatches[referenceBatches.length - 1]!
|
||||||
@@ -98,20 +191,32 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!latestReferenceBatch) {
|
if (!latestReferenceBatch) {
|
||||||
setActiveReferenceBatchId(null);
|
setActiveReferenceBatchId(null);
|
||||||
|
latestReferenceBatchIdRef.current = null;
|
||||||
|
manualReferenceBatchRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const previousLatestId = latestReferenceBatchIdRef.current;
|
||||||
|
latestReferenceBatchIdRef.current = latestReferenceBatch.id;
|
||||||
setActiveReferenceBatchId((current) =>
|
setActiveReferenceBatchId((current) =>
|
||||||
current && referenceBatches.some((batch) => batch.id === current)
|
current &&
|
||||||
|
referenceBatches.some((batch) => batch.id === current) &&
|
||||||
|
manualReferenceBatchRef.current &&
|
||||||
|
current !== previousLatestId
|
||||||
? current
|
? current
|
||||||
: latestReferenceBatch.id,
|
: latestReferenceBatch.id,
|
||||||
);
|
);
|
||||||
}, [latestReferenceBatch?.id, referenceBatches]);
|
}, [latestReferenceBatch?.id, referenceBatches]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleShowReference = (event: Event) => {
|
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) {
|
if (detail?.batchId) {
|
||||||
|
manualReferenceBatchRef.current = true;
|
||||||
setActiveReferenceBatchId(detail.batchId);
|
setActiveReferenceBatchId(detail.batchId);
|
||||||
}
|
}
|
||||||
|
setPinnedReferenceId(null);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setPinnedReferenceId(detail?.referenceId ?? null);
|
||||||
|
}, 0);
|
||||||
setReferencesOpen(true);
|
setReferencesOpen(true);
|
||||||
setSideTab("references");
|
setSideTab("references");
|
||||||
};
|
};
|
||||||
@@ -125,7 +230,13 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
threadIdRef.current = threadId;
|
threadIdRef.current = threadId;
|
||||||
artifactsCountRef.current = 0;
|
artifactsCountRef.current = 0;
|
||||||
deselect();
|
deselect();
|
||||||
setReferencesOpen(true);
|
setReferencesOpen(false);
|
||||||
|
setActiveReferenceBatchId(null);
|
||||||
|
setPinnedReferenceId(null);
|
||||||
|
setBackendReferenceBatches([]);
|
||||||
|
latestReferenceBatchIdRef.current = null;
|
||||||
|
manualReferenceBatchRef.current = false;
|
||||||
|
referenceDebugSignatureRef.current = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update artifacts from the current thread
|
// Update artifacts from the current thread
|
||||||
@@ -257,7 +368,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||||
<ReferencePanel sources={referenceSources} />
|
<ReferencePanel
|
||||||
|
highlightedReferenceId={pinnedReferenceId}
|
||||||
|
sources={referenceSources}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : selectedArtifact ? (
|
) : selectedArtifact ? (
|
||||||
@@ -280,7 +394,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
<XIcon />
|
<XIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ReferencePanel sources={referenceSources} />
|
<ReferencePanel
|
||||||
|
highlightedReferenceId={pinnedReferenceId}
|
||||||
|
sources={referenceSources}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative flex size-full justify-center">
|
<div className="relative flex size-full justify-center">
|
||||||
@@ -324,10 +441,27 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
function ReferencePanel({
|
function ReferencePanel({
|
||||||
|
highlightedReferenceId,
|
||||||
sources,
|
sources,
|
||||||
}: {
|
}: {
|
||||||
|
highlightedReferenceId?: string | null;
|
||||||
sources: Parameters<typeof SkillDisplaySources>[0]["sources"];
|
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 (
|
return (
|
||||||
<div className="flex size-full flex-col overflow-hidden">
|
<div className="flex size-full flex-col overflow-hidden">
|
||||||
<header className="shrink-0 px-1 pb-3">
|
<header className="shrink-0 px-1 pb-3">
|
||||||
@@ -344,7 +478,10 @@ function ReferencePanel({
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<main className="min-h-0 flex-1 overflow-y-auto px-1 pt-1 pb-6">
|
<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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -368,4 +505,101 @@ function findPreviousHumanMessageId(
|
|||||||
return undefined;
|
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 };
|
export { ChatBox };
|
||||||
@@ -87,7 +87,11 @@ import {
|
|||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
|
|
||||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import {
|
||||||
|
isThreadRagModeLocked,
|
||||||
|
lockThreadRagMode,
|
||||||
|
useLocalSettings,
|
||||||
|
} from "@/core/settings";
|
||||||
|
|
||||||
|
|
||||||
import { useThread } from "./messages/context";
|
import { useThread } from "./messages/context";
|
||||||
@@ -300,10 +304,14 @@ export function InputBox({
|
|||||||
});
|
});
|
||||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||||
|
|
||||||
const ragModeEnabled = localSettings.display.ragModeEnabled;
|
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||||
|
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
||||||
const handleRagModeToggle = useCallback(() => {
|
const handleRagModeToggle = useCallback(() => {
|
||||||
|
if (ragModeLocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
|
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
|
||||||
}, [ragModeEnabled, setLocalSettings]);
|
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (message: PromptInputMessage) => {
|
async (message: PromptInputMessage) => {
|
||||||
@@ -317,6 +325,9 @@ export function InputBox({
|
|||||||
setFollowups([]);
|
setFollowups([]);
|
||||||
setFollowupsHidden(false);
|
setFollowupsHidden(false);
|
||||||
setFollowupsLoading(false);
|
setFollowupsLoading(false);
|
||||||
|
if (ragModeEnabled) {
|
||||||
|
lockThreadRagMode(threadId);
|
||||||
|
}
|
||||||
|
|
||||||
// Guard against submitting before the initial model auto-selection
|
// Guard against submitting before the initial model auto-selection
|
||||||
// effect has flushed thread settings to storage/state.
|
// effect has flushed thread settings to storage/state.
|
||||||
@@ -341,8 +352,10 @@ export function InputBox({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onStop,
|
onStop,
|
||||||
resolvedModelName,
|
resolvedModelName,
|
||||||
|
ragModeEnabled,
|
||||||
selectedModel?.supports_thinking,
|
selectedModel?.supports_thinking,
|
||||||
status,
|
status,
|
||||||
|
threadId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -640,8 +653,10 @@ export function InputBox({
|
|||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={ragModeEnabled}
|
aria-pressed={ragModeEnabled}
|
||||||
|
disabled={ragModeLocked || disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"gap-1.5! rounded-full px-3! shadow-none",
|
"gap-1.5! rounded-full px-3! shadow-none",
|
||||||
|
ragModeLocked && "cursor-not-allowed opacity-80",
|
||||||
ragModeEnabled
|
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 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",
|
: "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",
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export function MarkdownContent({
|
|||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
|
if (props.href?.startsWith("#reference-")) {
|
||||||
|
return <CitationLink {...props} />;
|
||||||
|
}
|
||||||
if (typeof props.children === "string") {
|
if (typeof props.children === "string") {
|
||||||
const match = /^citation:(.+)$/.exec(props.children);
|
const match = /^citation:(.+)$/.exec(props.children);
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import { useI18n } from "@/core/i18n/hooks";
|
|||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import {
|
import {
|
||||||
useAppearanceSettings,
|
useAppearanceSettings,
|
||||||
|
useCitationDisplaySettings,
|
||||||
useUpdateAppearanceSettings,
|
useUpdateAppearanceSettings,
|
||||||
|
useUpdateCitationDisplaySettings,
|
||||||
} from "@/core/system-settings/hooks";
|
} from "@/core/system-settings/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -42,7 +44,11 @@ export function AppearanceSettingsPage() {
|
|||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const { data: appearance } = useAppearanceSettings();
|
const { data: appearance } = useAppearanceSettings();
|
||||||
const updateAppearance = useUpdateAppearanceSettings();
|
const updateAppearance = useUpdateAppearanceSettings();
|
||||||
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
|
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
||||||
const globalDefault = appearance?.default_theme ?? "light";
|
const globalDefault = appearance?.default_theme ?? "light";
|
||||||
|
const cleanupWords =
|
||||||
|
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||||
|
|
||||||
const handleSetGlobalDefault = async (value: "light" | "dark") => {
|
const handleSetGlobalDefault = async (value: "light" | "dark") => {
|
||||||
if (value === globalDefault) return;
|
if (value === globalDefault) return;
|
||||||
@@ -212,15 +218,15 @@ export function AppearanceSettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={(settings.display.citationCleanupWords ?? []).join("\n")}
|
value={cleanupWords.join("\n")}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
setSettings("display", {
|
const words = event.currentTarget.value
|
||||||
citationCleanupWords: event.currentTarget.value
|
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean);
|
||||||
})
|
setSettings("display", { citationCleanupWords: words });
|
||||||
}
|
updateCitationDisplay.mutate({ cleanup_words: words });
|
||||||
|
}}
|
||||||
placeholder={"广告\n点击下载APP\n打开客户端\n免责声明\n责任编辑"}
|
placeholder={"广告\n点击下载APP\n打开客户端\n免责声明\n责任编辑"}
|
||||||
className="min-h-32 text-sm"
|
className="min-h-32 text-sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
|
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export function SkillDisplaySources({
|
export function SkillDisplaySources({
|
||||||
|
highlightedReferenceId,
|
||||||
sources,
|
sources,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
|
highlightedReferenceId?: string | null;
|
||||||
sources: RuntimeDisplaySource[];
|
sources: RuntimeDisplaySource[];
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -29,6 +32,7 @@ export function SkillDisplaySources({
|
|||||||
{sources.map((source) => (
|
{sources.map((source) => (
|
||||||
<SourceCard
|
<SourceCard
|
||||||
key={`${source.skillName}-${source.index}`}
|
key={`${source.skillName}-${source.index}`}
|
||||||
|
highlighted={highlightedReferenceId === String(source.index)}
|
||||||
source={source}
|
source={source}
|
||||||
referenceTarget
|
referenceTarget
|
||||||
/>
|
/>
|
||||||
@@ -38,15 +42,19 @@ export function SkillDisplaySources({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SourceCard({
|
export function SourceCard({
|
||||||
|
highlighted = false,
|
||||||
source,
|
source,
|
||||||
referenceTarget = false,
|
referenceTarget = false,
|
||||||
}: {
|
}: {
|
||||||
|
highlighted?: boolean;
|
||||||
source: RuntimeDisplaySource;
|
source: RuntimeDisplaySource;
|
||||||
referenceTarget?: boolean;
|
referenceTarget?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [settings] = useLocalSettings();
|
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 content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||||
const snippet = compactReferenceText(content).slice(0, 280);
|
const snippet = compactReferenceText(content).slice(0, 280);
|
||||||
const paragraphs = segmentReferenceText(content);
|
const paragraphs = segmentReferenceText(content);
|
||||||
@@ -72,6 +80,8 @@ export function SourceCard({
|
|||||||
className={cn(
|
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",
|
"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",
|
"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)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
@@ -168,10 +178,13 @@ function cleanReferenceText(
|
|||||||
cleanupWords: string[],
|
cleanupWords: string[],
|
||||||
): string {
|
): string {
|
||||||
let text = raw ?? "";
|
let text = raw ?? "";
|
||||||
for (const word of cleanupWords) {
|
const words = cleanupWords.map((word) => word.trim()).filter(Boolean);
|
||||||
const trimmed = word.trim();
|
if (words.length > 0) {
|
||||||
if (!trimmed) continue;
|
text = text
|
||||||
text = text.split(trimmed).join("");
|
.replace(/\r\n/g, "\n")
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => !words.some((word) => line.includes(word)))
|
||||||
|
.join("\n");
|
||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
.replace(/\r\n/g, "\n")
|
.replace(/\r\n/g, "\n")
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type SearchResultItem = {
|
export type SearchResultItem = {
|
||||||
|
id?: string;
|
||||||
|
recUuid?: string;
|
||||||
|
uuid?: string;
|
||||||
|
docId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
snippet?: string;
|
snippet?: string;
|
||||||
@@ -300,6 +304,10 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
|||||||
const date =
|
const date =
|
||||||
pickFromLayers(layers, DATE_ALIASES) ??
|
pickFromLayers(layers, DATE_ALIASES) ??
|
||||||
pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 });
|
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>();
|
const reserved = new Set<string>();
|
||||||
for (const value of [title, content, url, source, date]) {
|
for (const value of [title, content, url, source, date]) {
|
||||||
@@ -324,6 +332,10 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
id,
|
||||||
|
recUuid,
|
||||||
|
uuid,
|
||||||
|
docId,
|
||||||
title,
|
title,
|
||||||
url,
|
url,
|
||||||
snippet: content,
|
snippet: content,
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { useLocalSettings, useThreadSettings } from "./hooks";
|
export { useLocalSettings, useThreadSettings } from "./hooks";
|
||||||
|
export { isThreadRagModeLocked, lockThreadRagMode } from "./local";
|
||||||
export type { LocalSettings } from "./local";
|
export type { LocalSettings } from "./local";
|
||||||
@@ -19,6 +19,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
|||||||
|
|
||||||
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
||||||
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
||||||
|
export const THREAD_RAG_MODE_KEY_PREFIX = "deerflow.thread-rag-mode.";
|
||||||
|
|
||||||
function isBrowser(): boolean {
|
function isBrowser(): boolean {
|
||||||
return typeof window !== "undefined";
|
return typeof window !== "undefined";
|
||||||
@@ -105,6 +106,24 @@ export function saveThreadModelName(
|
|||||||
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(
|
export function applyThreadModelOverride(
|
||||||
settings: LocalSettings,
|
settings: LocalSettings,
|
||||||
threadModelName: string | undefined,
|
threadModelName: string | undefined,
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export function collectRuntimeDisplaySources(
|
|||||||
const seenCitationKeys = new Map<string, RuntimeDisplaySource>();
|
const seenCitationKeys = new Map<string, RuntimeDisplaySource>();
|
||||||
const seenDisplayKeys = new Set<string>();
|
const seenDisplayKeys = new Set<string>();
|
||||||
const consumedToolMessageIds = new Set<string>();
|
const consumedToolMessageIds = new Set<string>();
|
||||||
|
const consumedToolMessages = new Set<Message>();
|
||||||
|
|
||||||
for (let index = start; index < limit; index++) {
|
for (let index = start; index < limit; index++) {
|
||||||
const message = messages[index];
|
const message = messages[index];
|
||||||
@@ -180,14 +181,24 @@ export function collectRuntimeDisplaySources(
|
|||||||
displays,
|
displays,
|
||||||
toolCall.args,
|
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) {
|
if (result === undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const linkedToolMessage = findToolMessageById(toolCall.id, messages);
|
if (toolMessage.id) {
|
||||||
if (linkedToolMessage?.id) {
|
consumedToolMessageIds.add(toolMessage.id);
|
||||||
consumedToolMessageIds.add(linkedToolMessage.id);
|
|
||||||
}
|
}
|
||||||
|
consumedToolMessages.add(toolMessage);
|
||||||
if (isDisplaySamplePayload(result)) {
|
if (isDisplaySamplePayload(result)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -223,7 +234,12 @@ export function collectRuntimeDisplaySources(
|
|||||||
|
|
||||||
for (let index = start; index < limit; index++) {
|
for (let index = start; index < limit; index++) {
|
||||||
const message = messages[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;
|
continue;
|
||||||
}
|
}
|
||||||
const result = parseToolMessagePayload(message);
|
const result = parseToolMessagePayload(message);
|
||||||
@@ -238,7 +254,8 @@ export function collectRuntimeDisplaySources(
|
|||||||
}
|
}
|
||||||
const normalized = match
|
const normalized = match
|
||||||
? normalizeDisplaySources(result, match.display)
|
? normalizeDisplaySources(result, match.display)
|
||||||
: shouldUseDefaultCitationMode(toolName, result)
|
: shouldUseDefaultCitationMode(toolName, result) ||
|
||||||
|
normalizeDefaultDisplaySources(result).length > 0
|
||||||
? normalizeDefaultDisplaySources(result)
|
? normalizeDefaultDisplaySources(result)
|
||||||
: [];
|
: [];
|
||||||
appendDisplaySources(
|
appendDisplaySources(
|
||||||
@@ -268,16 +285,14 @@ function appendDisplaySources(
|
|||||||
) {
|
) {
|
||||||
for (const draft of drafts) {
|
for (const draft of drafts) {
|
||||||
if (draft.mode === "citation") {
|
if (draft.mode === "citation") {
|
||||||
const key = citationSourceKey(draft);
|
|
||||||
const existing = seenCitationKeys.get(key);
|
|
||||||
if (existing) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const source = {
|
const source = {
|
||||||
...draft,
|
...draft,
|
||||||
index: nextCitationIndex(),
|
index: nextCitationIndex(),
|
||||||
};
|
};
|
||||||
seenCitationKeys.set(key, source);
|
const key = citationSourceKey(draft);
|
||||||
|
if (key) {
|
||||||
|
seenCitationKeys.set(`${key}:${source.index}`, source);
|
||||||
|
}
|
||||||
target.push(source);
|
target.push(source);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -331,12 +346,19 @@ function citationSourceKey(source: RuntimeDisplaySourceDraft): string {
|
|||||||
stringifyMaybe(raw.docId) ||
|
stringifyMaybe(raw.docId) ||
|
||||||
stringifyMaybe(raw.id);
|
stringifyMaybe(raw.id);
|
||||||
if (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)
|
.filter(Boolean)
|
||||||
.join("|");
|
.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 {
|
function nonCitationSourceKey(source: RuntimeDisplaySourceDraft): string {
|
||||||
@@ -543,7 +565,12 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
return normalized.map((item, index) => ({
|
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}`,
|
title: item.title ?? item.source ?? `结果 ${index + 1}`,
|
||||||
type: "result",
|
type: "result",
|
||||||
snippet: item.snippet ?? item.content ?? "",
|
snippet: item.snippet ?? item.content ?? "",
|
||||||
@@ -620,18 +647,79 @@ function replaceCitationTokens(
|
|||||||
byIndex: Map<number, RuntimeDisplaySource>,
|
byIndex: Map<number, RuntimeDisplaySource>,
|
||||||
orderedSources: RuntimeDisplaySource[],
|
orderedSources: RuntimeDisplaySource[],
|
||||||
): string {
|
): string {
|
||||||
return content
|
const normalized = normalizeLooseCitationTokens(content, byIndex, orderedSources);
|
||||||
|
const withDoubleBracketCitations = normalized
|
||||||
.replace(/\[\[(\d+)\]\]/g, (match, rawIndex: string) =>
|
.replace(/\[\[(\d+)\]\]/g, (match, rawIndex: string) =>
|
||||||
citationMarkdown(match, Number(rawIndex), byIndex, orderedSources),
|
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(
|
function normalizeExistingCitationLinks(
|
||||||
@@ -654,16 +742,16 @@ function normalizeExistingCitationLinks(
|
|||||||
const index = Number(numberMatch[1]);
|
const index = Number(numberMatch[1]);
|
||||||
const source = byIndex.get(index) ?? orderedSources[index - 1];
|
const source = byIndex.get(index) ?? orderedSources[index - 1];
|
||||||
if (source) {
|
if (source) {
|
||||||
return `[citation:[${source.index}]](#reference-${source.index})`;
|
return citationLinkMarkdown(source);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const sourceByUrl = byUrl.get(url);
|
const sourceByUrl = byUrl.get(url);
|
||||||
if (sourceByUrl) {
|
if (sourceByUrl) {
|
||||||
return `[citation:[${sourceByUrl.index}]](#reference-${sourceByUrl.index})`;
|
return citationLinkMarkdown(sourceByUrl);
|
||||||
}
|
}
|
||||||
const sourceByTitle = byTitle.get(label);
|
const sourceByTitle = byTitle.get(label);
|
||||||
if (sourceByTitle) {
|
if (sourceByTitle) {
|
||||||
return `[citation:[${sourceByTitle.index}]](#reference-${sourceByTitle.index})`;
|
return citationLinkMarkdown(sourceByTitle);
|
||||||
}
|
}
|
||||||
return match;
|
return match;
|
||||||
},
|
},
|
||||||
@@ -680,11 +768,11 @@ function citationMarkdown(
|
|||||||
if (!source) {
|
if (!source) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
return `[citation:[${source.index}]](#reference-${source.index})`;
|
return citationLinkMarkdown(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasCitationMarkdown(content: string): boolean {
|
function hasCitationMarkdown(content: string): boolean {
|
||||||
return /\[citation:\[[^\]]+\]\]\(#reference-[^)]+\)/.test(content);
|
return /\[citation:(?:\[[^\]]+\]|\d+)\]\(#reference-[^)]+\)/.test(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendCitationSummary(
|
function appendCitationSummary(
|
||||||
@@ -693,7 +781,7 @@ function appendCitationSummary(
|
|||||||
): string {
|
): string {
|
||||||
const citations = citationSources
|
const citations = citationSources
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map((source) => `[citation:[${source.index}]](#reference-${source.index})`)
|
.map((source) => citationLinkMarkdown(source))
|
||||||
.join(" ");
|
.join(" ");
|
||||||
if (!citations) {
|
if (!citations) {
|
||||||
return content;
|
return content;
|
||||||
@@ -702,10 +790,22 @@ function appendCitationSummary(
|
|||||||
return `${trimmed}${trimmed ? "\n\n" : ""}参考来源:${citations}`;
|
return `${trimmed}${trimmed ? "\n\n" : ""}参考来源:${citations}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function citationLinkMarkdown(source: RuntimeDisplaySource): string {
|
||||||
|
return `[citation:${source.index}](#reference-${source.index})`;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeName(name: string): string {
|
function normalizeName(name: string): string {
|
||||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
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;
|
return typeof value === "string" && value.trim() ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findToolResultPayload(
|
function findToolResultMessage(
|
||||||
toolCall: RuntimeToolCall,
|
toolCall: RuntimeToolCall,
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
toolCallMessageIndex = -1,
|
toolCallMessageIndex = -1,
|
||||||
): unknown {
|
consumedToolMessageIds?: Set<string>,
|
||||||
const toolMessage =
|
consumedToolMessages?: Set<Message>,
|
||||||
findToolMessageById(toolCall.id, messages) ??
|
): Message | undefined {
|
||||||
findToolMessageByName(toolCall, messages, toolCallMessageIndex);
|
const byId = findToolMessageById(toolCall.id, messages);
|
||||||
if (!toolMessage) {
|
if (
|
||||||
return undefined;
|
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 {
|
function parseToolMessagePayload(toolMessage: Message): unknown {
|
||||||
@@ -782,6 +892,8 @@ function findToolMessageByName(
|
|||||||
toolCall: RuntimeToolCall,
|
toolCall: RuntimeToolCall,
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
toolCallMessageIndex: number,
|
toolCallMessageIndex: number,
|
||||||
|
consumedToolMessageIds?: Set<string>,
|
||||||
|
consumedToolMessages?: Set<Message>,
|
||||||
): Message | undefined {
|
): Message | undefined {
|
||||||
const start = Math.max(toolCallMessageIndex + 1, 0);
|
const start = Math.max(toolCallMessageIndex + 1, 0);
|
||||||
for (let index = start; index < messages.length; index++) {
|
for (let index = start; index < messages.length; index++) {
|
||||||
@@ -795,6 +907,12 @@ function findToolMessageByName(
|
|||||||
if (message.type !== "tool") {
|
if (message.type !== "tool") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (message.id && consumedToolMessageIds?.has(message.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (consumedToolMessages?.has(message)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const name =
|
const name =
|
||||||
stringifyMaybe((message as { name?: unknown }).name) ??
|
stringifyMaybe((message as { name?: unknown }).name) ??
|
||||||
stringifyMaybe(
|
stringifyMaybe(
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ export interface PrivateSquareUpdate {
|
|||||||
password?: string;
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CitationDisplaySettings {
|
||||||
|
cleanup_words: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CitationDisplayUpdate {
|
||||||
|
cleanup_words?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
|
export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/system-settings/private-square`,
|
`${getBackendBaseURL()}/api/system-settings/private-square`,
|
||||||
@@ -107,6 +115,38 @@ export async function updateAppearanceSettings(
|
|||||||
return (await response.json()) as AppearanceSettings;
|
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(
|
export async function updatePrivateSquareSettings(
|
||||||
body: PrivateSquareUpdate,
|
body: PrivateSquareUpdate,
|
||||||
): Promise<PrivateSquareStatus> {
|
): Promise<PrivateSquareStatus> {
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
loadAppearanceSettings,
|
loadAppearanceSettings,
|
||||||
|
loadCitationDisplaySettings,
|
||||||
loadPrivateSquareStatus,
|
loadPrivateSquareStatus,
|
||||||
loadPromptPrefixSettings,
|
loadPromptPrefixSettings,
|
||||||
updateAppearanceSettings,
|
updateAppearanceSettings,
|
||||||
|
updateCitationDisplaySettings,
|
||||||
updatePrivateSquareSettings,
|
updatePrivateSquareSettings,
|
||||||
updatePromptPrefixSettings,
|
updatePromptPrefixSettings,
|
||||||
type AppearanceUpdate,
|
type AppearanceUpdate,
|
||||||
|
type CitationDisplayUpdate,
|
||||||
type PrivateSquareUpdate,
|
type PrivateSquareUpdate,
|
||||||
type PromptPrefixUpdate,
|
type PromptPrefixUpdate,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
@@ -15,6 +18,7 @@ import {
|
|||||||
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
||||||
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
|
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
|
||||||
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
|
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
|
||||||
|
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
||||||
|
|
||||||
export function usePromptPrefixSettings() {
|
export function usePromptPrefixSettings() {
|
||||||
return useQuery({
|
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 { useStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
|
|
||||||
import { getAPIClient } from "../api";
|
import { getAPIClient } from "../api";
|
||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
|
import { getStoredAuth } from "../auth";
|
||||||
import { getBackendBaseURL } from "../config";
|
import { getBackendBaseURL } from "../config";
|
||||||
import { useI18n } from "../i18n/hooks";
|
import { useI18n } from "../i18n/hooks";
|
||||||
import type { FileInMessage } from "../messages/utils";
|
import type { FileInMessage } from "../messages/utils";
|
||||||
import type { LocalSettings } from "../settings";
|
import type { LocalSettings } from "../settings";
|
||||||
import { useLocalSettings } from "../settings";
|
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings";
|
||||||
import { useUpdateSubtask } from "../tasks/context";
|
import { useUpdateSubtask } from "../tasks/context";
|
||||||
import type { UploadedFileInfo } from "../uploads";
|
import type { UploadedFileInfo } from "../uploads";
|
||||||
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||||
@@ -137,6 +139,42 @@ function getStreamErrorMessage(error: unknown): string {
|
|||||||
return "Request failed.";
|
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({
|
export function useThreadStream({
|
||||||
threadId,
|
threadId,
|
||||||
context,
|
context,
|
||||||
@@ -195,6 +233,7 @@ export function useThreadStream({
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const updateSubtask = useUpdateSubtask();
|
const updateSubtask = useUpdateSubtask();
|
||||||
|
const navigate = useNavigate();
|
||||||
const runMetadataStorageRef = useRef<
|
const runMetadataStorageRef = useRef<
|
||||||
ReturnType<typeof getRunMetadataStorage> | undefined
|
ReturnType<typeof getRunMetadataStorage> | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
@@ -294,7 +333,29 @@ export function useThreadStream({
|
|||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
setOptimisticMessages([]);
|
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) {
|
onFinish(state) {
|
||||||
listeners.current.onFinish?.(state.values);
|
listeners.current.onFinish?.(state.values);
|
||||||
@@ -494,6 +555,12 @@ export function useThreadStream({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ragModeEnabled =
|
||||||
|
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||||
|
if (ragModeEnabled) {
|
||||||
|
lockThreadRagMode(threadId);
|
||||||
|
}
|
||||||
|
|
||||||
await thread.submit(
|
await thread.submit(
|
||||||
{
|
{
|
||||||
messages: [
|
messages: [
|
||||||
@@ -537,7 +604,7 @@ export function useThreadStream({
|
|||||||
? "low"
|
? "low"
|
||||||
: undefined),
|
: undefined),
|
||||||
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
||||||
rag_mode_enabled: localSettings.display.ragModeEnabled,
|
rag_mode_enabled: ragModeEnabled,
|
||||||
thread_id: threadId,
|
thread_id: threadId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -567,6 +567,20 @@
|
|||||||
transform: translateY(-50%) scale(1);
|
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 ─────────────────────────────────────────────── */
|
/* ─── Tiptap writing editor ─────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ class PrivateSquareUpdateRequest(BaseModel):
|
|||||||
password: str | None = Field(default=None, description="New password in plain text. Pass an empty string to clear.")
|
password: str | None = Field(default=None, description="New password in plain text. Pass an empty string to clear.")
|
||||||
|
|
||||||
|
|
||||||
|
class CitationDisplayResponse(BaseModel):
|
||||||
|
cleanup_words: list[str] = Field(default_factory=list, description="Global citation cleanup words")
|
||||||
|
|
||||||
|
|
||||||
|
class CitationDisplayUpdateRequest(BaseModel):
|
||||||
|
cleanup_words: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
|
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
|
||||||
return PromptPrefixResponse(
|
return PromptPrefixResponse(
|
||||||
enabled=settings.enabled,
|
enabled=settings.enabled,
|
||||||
@@ -83,6 +91,34 @@ async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request
|
|||||||
return _to_response(saved.prompt_prefix)
|
return _to_response(saved.prompt_prefix)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/citation-display", response_model=CitationDisplayResponse)
|
||||||
|
async def get_citation_display() -> CitationDisplayResponse:
|
||||||
|
"""Return global citation display cleanup settings."""
|
||||||
|
settings = load_system_settings().citation_display
|
||||||
|
return CitationDisplayResponse(cleanup_words=settings.cleanup_words)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/citation-display", response_model=CitationDisplayResponse)
|
||||||
|
async def update_citation_display(
|
||||||
|
body: CitationDisplayUpdateRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> CitationDisplayResponse:
|
||||||
|
"""Update global citation display cleanup settings. Admin only."""
|
||||||
|
await _require_admin(request)
|
||||||
|
|
||||||
|
current = load_system_settings()
|
||||||
|
words = [
|
||||||
|
str(item).strip()
|
||||||
|
for item in (body.cleanup_words or [])
|
||||||
|
if str(item).strip()
|
||||||
|
]
|
||||||
|
updated = current.citation_display.model_copy(update={"cleanup_words": words})
|
||||||
|
saved = save_system_settings(
|
||||||
|
current.model_copy(update={"citation_display": updated})
|
||||||
|
)
|
||||||
|
return CitationDisplayResponse(cleanup_words=saved.citation_display.cleanup_words)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/private-square", response_model=PrivateSquareStatusResponse)
|
@router.get("/private-square", response_model=PrivateSquareStatusResponse)
|
||||||
async def get_private_square_status() -> PrivateSquareStatusResponse:
|
async def get_private_square_status() -> PrivateSquareStatusResponse:
|
||||||
"""Return whether the private square is enabled and a password is set."""
|
"""Return whether the private square is enabled and a password is set."""
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ matching the LangGraph Platform wire format expected by the
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -25,11 +28,16 @@ from app.gateway.deps import get_checkpointer
|
|||||||
from app.gateway.utils import sanitize_log_param
|
from app.gateway.utils import sanitize_log_param
|
||||||
from deerflow.config.paths import Paths, get_paths
|
from deerflow.config.paths import Paths, get_paths
|
||||||
from deerflow.runtime import serialize_channel_values
|
from deerflow.runtime import serialize_channel_values
|
||||||
|
from deerflow.runtime.references import (
|
||||||
|
build_reference_batches as build_runtime_reference_batches,
|
||||||
|
build_reference_debug_summary,
|
||||||
|
)
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
from deerflow.utils.time import coerce_iso, now_iso
|
from deerflow.utils.time import coerce_iso, now_iso
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/threads", tags=["threads"])
|
router = APIRouter(prefix="/api/threads", tags=["threads"])
|
||||||
|
_RAG_DEBUG_LOGGER_NAME = "rag_citation_debug"
|
||||||
|
|
||||||
|
|
||||||
# Metadata keys that the server controls; clients are not allowed to set
|
# Metadata keys that the server controls; clients are not allowed to set
|
||||||
@@ -48,6 +56,33 @@ def _strip_reserved_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
return {k: v for k, v in metadata.items() if k not in _SERVER_RESERVED_METADATA_KEYS}
|
return {k: v for k, v in metadata.items() if k not in _SERVER_RESERVED_METADATA_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_rag_debug_logger() -> logging.Logger:
|
||||||
|
debug_logger = logging.getLogger(_RAG_DEBUG_LOGGER_NAME)
|
||||||
|
if getattr(debug_logger, "_rag_file_configured", False):
|
||||||
|
return debug_logger
|
||||||
|
log_path = Path(os.environ.get("RAG_CITATION_DEBUG_LOG", "logs/rag_citation_debug.log"))
|
||||||
|
if not log_path.is_absolute():
|
||||||
|
log_path = Path.cwd() / log_path
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handler = logging.FileHandler(log_path, encoding="utf-8")
|
||||||
|
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||||
|
debug_logger.addHandler(handler)
|
||||||
|
debug_logger.setLevel(logging.INFO)
|
||||||
|
debug_logger.propagate = False
|
||||||
|
setattr(debug_logger, "_rag_file_configured", True)
|
||||||
|
setattr(debug_logger, "_rag_file_path", str(log_path))
|
||||||
|
return debug_logger
|
||||||
|
|
||||||
|
|
||||||
|
def _rag_debug_file_path() -> str:
|
||||||
|
return str(getattr(_get_rag_debug_logger(), "_rag_file_path", "logs/rag_citation_debug.log"))
|
||||||
|
|
||||||
|
|
||||||
|
def _log_rag_debug(message: str, *args: Any) -> None:
|
||||||
|
logger.info(message, *args)
|
||||||
|
_get_rag_debug_logger().info(message, *args)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Response / request models
|
# Response / request models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -139,6 +174,38 @@ class ThreadHistoryRequest(BaseModel):
|
|||||||
before: str | None = Field(default=None, description="Cursor for pagination")
|
before: str | None = Field(default=None, description="Cursor for pagination")
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceSourceResponse(BaseModel):
|
||||||
|
index: int
|
||||||
|
id: str | None = None
|
||||||
|
skillName: str = "backend-reference"
|
||||||
|
mode: str = "citation"
|
||||||
|
title: str
|
||||||
|
type: str = "result"
|
||||||
|
snippet: str = ""
|
||||||
|
source: str | None = None
|
||||||
|
time: str | None = None
|
||||||
|
url: str | None = None
|
||||||
|
content: str | None = None
|
||||||
|
score: float | None = None
|
||||||
|
raw: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceBatchResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
assistant_message_id: str
|
||||||
|
sources: list[ReferenceSourceResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceBatchesResponse(BaseModel):
|
||||||
|
batches: list[ReferenceBatchResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceDebugLogRequest(BaseModel):
|
||||||
|
event: str
|
||||||
|
thread_id: str | None = None
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -182,6 +249,250 @@ def _derive_thread_status(checkpoint_tuple) -> str:
|
|||||||
return "idle"
|
return "idle"
|
||||||
|
|
||||||
|
|
||||||
|
def _message_type(message: Any) -> str:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
return str(message.get("type") or message.get("role") or "")
|
||||||
|
return str(getattr(message, "type", "") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _message_id(message: Any) -> str | None:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
value = message.get("id")
|
||||||
|
else:
|
||||||
|
value = getattr(message, "id", None)
|
||||||
|
return str(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _message_content(message: Any) -> Any:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
return message.get("content")
|
||||||
|
return getattr(message, "content", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_name(message: Any) -> str:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
return str(message.get("name") or (message.get("additional_kwargs") or {}).get("name") or "tool")
|
||||||
|
return str(getattr(message, "name", None) or "tool")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_tool_calls(message: Any) -> bool:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
direct = message.get("tool_calls")
|
||||||
|
chunks = message.get("tool_call_chunks")
|
||||||
|
extra = (message.get("additional_kwargs") or {}).get("tool_calls")
|
||||||
|
else:
|
||||||
|
direct = getattr(message, "tool_calls", None)
|
||||||
|
chunks = getattr(message, "tool_call_chunks", None)
|
||||||
|
extra = (getattr(message, "additional_kwargs", None) or {}).get("tool_calls")
|
||||||
|
return bool(direct or chunks or extra)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_answer_content(message: Any) -> bool:
|
||||||
|
content = _message_content(message)
|
||||||
|
if isinstance(content, str):
|
||||||
|
return bool(content.strip())
|
||||||
|
if isinstance(content, list):
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "text" and str(part.get("text") or "").strip():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_tool_payload(message: Any) -> Any:
|
||||||
|
content = _message_content(message)
|
||||||
|
if isinstance(content, dict):
|
||||||
|
return _unwrap_tool_payload(content)
|
||||||
|
if isinstance(content, list):
|
||||||
|
texts: list[str] = []
|
||||||
|
for part in content:
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
continue
|
||||||
|
if isinstance(part.get("json"), dict):
|
||||||
|
return _unwrap_tool_payload(part["json"])
|
||||||
|
if part.get("type") == "json" and isinstance(part.get("json"), dict):
|
||||||
|
return _unwrap_tool_payload(part["json"])
|
||||||
|
if part.get("type") == "text" and isinstance(part.get("text"), str):
|
||||||
|
texts.append(part["text"])
|
||||||
|
unwrapped = _unwrap_tool_payload(part)
|
||||||
|
if unwrapped is not None:
|
||||||
|
return unwrapped
|
||||||
|
content = "\n".join(texts)
|
||||||
|
if isinstance(content, str):
|
||||||
|
return _parse_json_like(content)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_tool_payload(value: dict[str, Any]) -> Any:
|
||||||
|
if any(isinstance(value.get(key), list) for key in ("results", "data", "items", "hits", "documents", "records", "sources")):
|
||||||
|
return value
|
||||||
|
for key in ("result", "output", "stdout", "content", "text", "payload", "response"):
|
||||||
|
nested = value.get(key)
|
||||||
|
if isinstance(nested, str):
|
||||||
|
parsed = _parse_json_like(nested)
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
unwrapped = _unwrap_tool_payload(nested)
|
||||||
|
if unwrapped is not None:
|
||||||
|
return unwrapped
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_like(text: str) -> Any:
|
||||||
|
trimmed = text.strip()
|
||||||
|
if not trimmed:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(trimmed)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
first_obj = trimmed.find("{")
|
||||||
|
last_obj = trimmed.rfind("}")
|
||||||
|
if 0 <= first_obj < last_obj:
|
||||||
|
try:
|
||||||
|
return json.loads(trimmed[first_obj : last_obj + 1])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _first_list(payload: Any) -> list[Any]:
|
||||||
|
if isinstance(payload, list):
|
||||||
|
return payload
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return []
|
||||||
|
for key in ("results", "data", "items", "hits", "documents", "records", "entries", "matches", "chunks", "sources"):
|
||||||
|
value = payload.get(key)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
for key in ("result", "payload", "response", "output", "data"):
|
||||||
|
nested = payload.get(key)
|
||||||
|
found = _first_list(nested)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _nested_records(raw: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return []
|
||||||
|
layers = [raw]
|
||||||
|
for key in ("metadata", "meta", "attrs", "attributes", "properties", "extra"):
|
||||||
|
nested = raw.get(key)
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
layers.append(nested)
|
||||||
|
return layers
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(layers: list[dict[str, Any]], keys: tuple[str, ...]) -> str | None:
|
||||||
|
for layer in layers:
|
||||||
|
for key in keys:
|
||||||
|
value = layer.get(key)
|
||||||
|
if isinstance(value, (str, int, float)) and str(value).strip():
|
||||||
|
return str(value).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_reference_item(raw: Any, fallback_index: int) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
layers = _nested_records(raw)
|
||||||
|
title = _pick(layers, ("title", "m_title", "name", "question", "keyword", "query", "text"))
|
||||||
|
content = _pick(layers, ("content", "page_content", "summary", "snippet", "description", "content_preview", "abstract", "body"))
|
||||||
|
url = _pick(layers, ("url", "link", "href", "source_url", "web_url"))
|
||||||
|
source = _pick(layers, ("source", "source1", "site", "provider", "from"))
|
||||||
|
time = _pick(layers, ("time", "date", "published_date", "publishedDate", "m_publish", "publish_time", "created_at"))
|
||||||
|
item_id = _pick(layers, ("recUuid", "rec_uuid", "uuid", "docId", "doc_id", "id", "recordId", "taskId"))
|
||||||
|
score_raw = _pick(layers, ("score", "relevance", "similarity"))
|
||||||
|
score = None
|
||||||
|
if score_raw is not None:
|
||||||
|
try:
|
||||||
|
score = float(score_raw)
|
||||||
|
except Exception:
|
||||||
|
score = None
|
||||||
|
if not title:
|
||||||
|
title = source or f"结果 {fallback_index}"
|
||||||
|
if not content:
|
||||||
|
content = ""
|
||||||
|
if not title and not content and not url:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": item_id or url,
|
||||||
|
"title": title,
|
||||||
|
"type": "result",
|
||||||
|
"snippet": content,
|
||||||
|
"content": content,
|
||||||
|
"source": source,
|
||||||
|
"time": time,
|
||||||
|
"url": url,
|
||||||
|
"score": score,
|
||||||
|
"raw": raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_key(source: dict[str, Any]) -> str:
|
||||||
|
raw = source.get("raw") if isinstance(source.get("raw"), dict) else {}
|
||||||
|
item_id = (
|
||||||
|
source.get("id")
|
||||||
|
or source.get("url")
|
||||||
|
or raw.get("recUuid")
|
||||||
|
or raw.get("uuid")
|
||||||
|
or raw.get("docId")
|
||||||
|
or raw.get("id")
|
||||||
|
)
|
||||||
|
if item_id:
|
||||||
|
return str(item_id).casefold().strip()
|
||||||
|
return "|".join(
|
||||||
|
str(source.get(key) or "").casefold().strip()
|
||||||
|
for key in ("title", "source", "time", "content", "snippet")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_reference_batches(messages: list[Any]) -> list[ReferenceBatchResponse]:
|
||||||
|
batches: list[ReferenceBatchResponse] = []
|
||||||
|
for index, message in enumerate(messages):
|
||||||
|
if _message_type(message) != "human":
|
||||||
|
continue
|
||||||
|
end = len(messages)
|
||||||
|
for cursor in range(index + 1, len(messages)):
|
||||||
|
if _message_type(messages[cursor]) == "human":
|
||||||
|
end = cursor
|
||||||
|
break
|
||||||
|
assistant_message: Any | None = None
|
||||||
|
for cursor in range(end - 1, index, -1):
|
||||||
|
candidate = messages[cursor]
|
||||||
|
if (
|
||||||
|
_message_type(candidate) == "ai"
|
||||||
|
and _message_id(candidate)
|
||||||
|
and _has_answer_content(candidate)
|
||||||
|
and not _has_tool_calls(candidate)
|
||||||
|
):
|
||||||
|
assistant_message = candidate
|
||||||
|
break
|
||||||
|
if assistant_message is None:
|
||||||
|
continue
|
||||||
|
seen: set[str] = set()
|
||||||
|
sources: list[ReferenceSourceResponse] = []
|
||||||
|
for tool_message in messages[index + 1 : end]:
|
||||||
|
if _message_type(tool_message) != "tool":
|
||||||
|
continue
|
||||||
|
payload = _extract_tool_payload(tool_message)
|
||||||
|
for raw_item in _first_list(payload):
|
||||||
|
normalized = _normalize_reference_item(raw_item, len(sources) + 1)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
key = _reference_key(normalized)
|
||||||
|
if key and key in seen:
|
||||||
|
continue
|
||||||
|
if key:
|
||||||
|
seen.add(key)
|
||||||
|
sources.append(ReferenceSourceResponse(index=len(sources) + 1, **normalized))
|
||||||
|
if sources:
|
||||||
|
mid = _message_id(assistant_message) or str(index)
|
||||||
|
batches.append(ReferenceBatchResponse(id=mid, assistant_message_id=mid, sources=sources))
|
||||||
|
return batches
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Endpoints
|
# Endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -458,6 +769,78 @@ async def get_thread_state(thread_id: str, request: Request) -> ThreadStateRespo
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{thread_id}/reference-batches", response_model=ReferenceBatchesResponse)
|
||||||
|
@require_permission("threads", "read", owner_check=True)
|
||||||
|
async def get_thread_reference_batches(thread_id: str, request: Request) -> ReferenceBatchesResponse:
|
||||||
|
"""Return backend-built citation batches for completed assistant turns."""
|
||||||
|
checkpointer = get_checkpointer(request)
|
||||||
|
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||||
|
try:
|
||||||
|
checkpoint_tuple = await checkpointer.aget_tuple(config)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to get reference batches for thread %s", sanitize_log_param(thread_id))
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to get reference batches")
|
||||||
|
|
||||||
|
if checkpoint_tuple is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
|
||||||
|
|
||||||
|
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||||||
|
values = serialize_channel_values(checkpoint.get("channel_values", {}) or {})
|
||||||
|
messages = values.get("messages")
|
||||||
|
if not isinstance(messages, list):
|
||||||
|
_log_rag_debug(
|
||||||
|
"backend reference-batches thread=%s messages=missing batches=0 log_file=%s",
|
||||||
|
sanitize_log_param(thread_id),
|
||||||
|
_rag_debug_file_path(),
|
||||||
|
)
|
||||||
|
return ReferenceBatchesResponse(batches=[])
|
||||||
|
raw_batches = build_runtime_reference_batches(messages)
|
||||||
|
debug_summary = build_reference_debug_summary(messages, raw_batches)
|
||||||
|
_log_rag_debug(
|
||||||
|
"backend reference-batches thread=%s messages=%s turns=%s tool_messages=%s raw_items=%s "
|
||||||
|
"normalized_items=%s kept_items=%s batches=%s batch_sources=%s batch_ids=%s turns_detail=%s log_file=%s",
|
||||||
|
sanitize_log_param(thread_id),
|
||||||
|
debug_summary["messages"],
|
||||||
|
debug_summary["turns"],
|
||||||
|
debug_summary["tool_messages"],
|
||||||
|
debug_summary["raw_items"],
|
||||||
|
debug_summary["normalized_items"],
|
||||||
|
debug_summary["kept_items"],
|
||||||
|
len(raw_batches),
|
||||||
|
",".join(str(batch.get("source_count", 0)) for batch in debug_summary["batches"]),
|
||||||
|
",".join(str(batch.get("id") or "") for batch in debug_summary["batches"]),
|
||||||
|
json.dumps(debug_summary["turns_detail"], ensure_ascii=False),
|
||||||
|
_rag_debug_file_path(),
|
||||||
|
)
|
||||||
|
return ReferenceBatchesResponse(
|
||||||
|
batches=[
|
||||||
|
ReferenceBatchResponse(
|
||||||
|
id=str(batch.get("id") or ""),
|
||||||
|
assistant_message_id=str(batch.get("assistant_message_id") or batch.get("id") or ""),
|
||||||
|
sources=[
|
||||||
|
ReferenceSourceResponse(**source)
|
||||||
|
for source in batch.get("sources", [])
|
||||||
|
if isinstance(source, dict)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for batch in raw_batches
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reference-debug-log")
|
||||||
|
async def write_reference_debug_log(body: ReferenceDebugLogRequest) -> dict[str, str | bool]:
|
||||||
|
"""Append frontend citation diagnostics to the backend RAG debug log."""
|
||||||
|
_get_rag_debug_logger().info(
|
||||||
|
"frontend %s thread=%s payload=%s log_file=%s",
|
||||||
|
body.event,
|
||||||
|
sanitize_log_param(body.thread_id or ""),
|
||||||
|
json.dumps(body.payload, ensure_ascii=False, default=str),
|
||||||
|
_rag_debug_file_path(),
|
||||||
|
)
|
||||||
|
return {"ok": True, "log_file": _rag_debug_file_path()}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{thread_id}/state", response_model=ThreadStateResponse)
|
@router.post("/{thread_id}/state", response_model=ThreadStateResponse)
|
||||||
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
||||||
async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, request: Request) -> ThreadStateResponse:
|
async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, request: Request) -> ThreadStateResponse:
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ _DEFAULT_ASSISTANT_ID = "lead_agent"
|
|||||||
# These are NOT stored in the agents DB and must bypass the custom-agent access check.
|
# These are NOT stored in the agents DB and must bypass the custom-agent access check.
|
||||||
_BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent", "ai_writing"})
|
_BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent", "ai_writing"})
|
||||||
|
|
||||||
|
_RAG_CITATION_PROMPT = """当前对话已开启参考文献模式。
|
||||||
|
如果本轮调用了搜索、知识库、检索类技能或工具,请基于本轮所有检索结果整体回答。
|
||||||
|
你必须把同一轮内多次检索得到的资料视为同一批参考文献,按出现顺序从 [1] 开始连续编号,不要让每个检索词各自从 [1] 重新编号。
|
||||||
|
回答正文中的关键结论必须标注对应编号,例如 [1]、[2],多个来源可写为 [1][3][5]。
|
||||||
|
不要引用不存在的编号,不要使用技能内部自带的编号,不要把参考文献完整列表堆在正文末尾。
|
||||||
|
如果没有任何可用检索结果,请直接说明未检索到可靠参考资料。"""
|
||||||
|
|
||||||
|
|
||||||
def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str, Any]:
|
def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str, Any]:
|
||||||
"""Return ``graph_input`` with the prefix *tagged* onto its first HumanMessage.
|
"""Return ``graph_input`` with the prefix *tagged* onto its first HumanMessage.
|
||||||
@@ -140,6 +147,43 @@ def _prompt_prefix_opted_in(body: Any) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _rag_mode_requested(body: Any) -> bool:
|
||||||
|
context = getattr(body, "context", None)
|
||||||
|
if isinstance(context, Mapping) and context.get("rag_mode_enabled"):
|
||||||
|
return True
|
||||||
|
metadata = getattr(body, "metadata", None)
|
||||||
|
if isinstance(metadata, Mapping) and (
|
||||||
|
metadata.get("rag_mode_enabled") or metadata.get("rag_mode")
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_rag_mode_enabled(record: Any) -> bool:
|
||||||
|
if not isinstance(record, Mapping):
|
||||||
|
return False
|
||||||
|
metadata = record.get("metadata")
|
||||||
|
return isinstance(metadata, Mapping) and bool(
|
||||||
|
metadata.get("rag_mode_enabled") or metadata.get("rag_mode")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_invisible_prompt(graph_input: dict[str, Any], prompt: str) -> dict[str, Any]:
|
||||||
|
messages = graph_input.get("messages")
|
||||||
|
if not isinstance(messages, list) or not messages:
|
||||||
|
return graph_input
|
||||||
|
new_messages = list(messages)
|
||||||
|
for idx, msg in enumerate(new_messages):
|
||||||
|
if not isinstance(msg, HumanMessage):
|
||||||
|
continue
|
||||||
|
extra = dict(getattr(msg, "additional_kwargs", None) or {})
|
||||||
|
existing = str(extra.get("prompt_prefix") or "").strip()
|
||||||
|
extra["prompt_prefix"] = f"{existing}\n\n{prompt}".strip() if existing else prompt
|
||||||
|
new_messages[idx] = msg.model_copy(update={"additional_kwargs": extra})
|
||||||
|
break
|
||||||
|
return {**graph_input, "messages": new_messages}
|
||||||
|
|
||||||
|
|
||||||
# Whitelist of run-context keys that the langgraph-compat layer forwards from
|
# Whitelist of run-context keys that the langgraph-compat layer forwards from
|
||||||
# ``body.context`` into the run config. ``config["context"]`` exists in
|
# ``body.context`` into the run config. ``config["context"]`` exists in
|
||||||
# LangGraph >=0.6, but these values must be written to both ``configurable``
|
# LangGraph >=0.6, but these values must be written to both ``configurable``
|
||||||
@@ -399,16 +443,29 @@ async def start_run(
|
|||||||
# Upsert thread metadata so the thread appears in /threads/search,
|
# Upsert thread metadata so the thread appears in /threads/search,
|
||||||
# even for threads that were never explicitly created via POST /threads
|
# even for threads that were never explicitly created via POST /threads
|
||||||
# (e.g. stateless runs).
|
# (e.g. stateless runs).
|
||||||
|
requested_rag_mode = _rag_mode_requested(body)
|
||||||
|
effective_rag_mode = requested_rag_mode
|
||||||
try:
|
try:
|
||||||
existing = await run_ctx.thread_store.get(thread_id)
|
existing = await run_ctx.thread_store.get(thread_id)
|
||||||
|
effective_rag_mode = requested_rag_mode or _thread_rag_mode_enabled(existing)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
|
metadata = dict(body.metadata or {})
|
||||||
|
if effective_rag_mode:
|
||||||
|
metadata["rag_mode_enabled"] = True
|
||||||
|
metadata["rag_mode"] = True
|
||||||
await run_ctx.thread_store.create(
|
await run_ctx.thread_store.create(
|
||||||
thread_id,
|
thread_id,
|
||||||
assistant_id=body.assistant_id,
|
assistant_id=body.assistant_id,
|
||||||
metadata=body.metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await run_ctx.thread_store.update_status(thread_id, "running")
|
await run_ctx.thread_store.update_status(thread_id, "running")
|
||||||
|
if requested_rag_mode and not _thread_rag_mode_enabled(existing):
|
||||||
|
await run_ctx.thread_store.update_metadata(
|
||||||
|
thread_id,
|
||||||
|
{"rag_mode_enabled": True, "rag_mode": True},
|
||||||
|
)
|
||||||
|
effective_rag_mode = True
|
||||||
# SDK 的 ``client.threads.create()`` 不传 assistant_id —— 先建出来空的 thread,
|
# SDK 的 ``client.threads.create()`` 不传 assistant_id —— 先建出来空的 thread,
|
||||||
# 真正提交 run 时才知道用哪个 assistant。这里回填一次,避免历史列表 / 按
|
# 真正提交 run 时才知道用哪个 assistant。这里回填一次,避免历史列表 / 按
|
||||||
# assistant 筛选时拿不到关联。
|
# assistant 筛选时拿不到关联。
|
||||||
@@ -432,6 +489,8 @@ async def start_run(
|
|||||||
prefix_settings = load_system_settings().prompt_prefix
|
prefix_settings = load_system_settings().prompt_prefix
|
||||||
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
|
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
|
||||||
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
|
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
|
||||||
|
if effective_rag_mode:
|
||||||
|
graph_input = _append_invisible_prompt(graph_input, _RAG_CITATION_PROMPT)
|
||||||
config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id)
|
config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id)
|
||||||
|
|
||||||
# Merge DeerFlow-specific context overrides into both ``configurable`` and ``context``.
|
# Merge DeerFlow-specific context overrides into both ``configurable`` and ``context``.
|
||||||
@@ -439,6 +498,8 @@ async def start_run(
|
|||||||
# that carries agent configuration (model_name, thinking_enabled, etc.).
|
# that carries agent configuration (model_name, thinking_enabled, etc.).
|
||||||
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
|
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
|
||||||
body_context = dict(getattr(body, "context", None) or {})
|
body_context = dict(getattr(body, "context", None) or {})
|
||||||
|
if effective_rag_mode:
|
||||||
|
body_context["rag_mode_enabled"] = True
|
||||||
# Promote whitelisted top-level body fields into the context map so
|
# Promote whitelisted top-level body fields into the context map so
|
||||||
# callers can pass them either nested under ``context`` or as flat
|
# callers can pass them either nested under ``context`` or as flat
|
||||||
# top-level fields (``excluded_tools``, ``skill_stop_names``, …).
|
# top-level fields (``excluded_tools``, ``skill_stop_names``, …).
|
||||||
|
|||||||
+4
-1
@@ -9,6 +9,7 @@ from deerflow.agents.middlewares.clarification_middleware import ClarificationMi
|
|||||||
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||||
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||||
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
|
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
|
||||||
|
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
|
||||||
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
|
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
|
||||||
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
||||||
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
||||||
@@ -254,11 +255,14 @@ def _build_middlewares(
|
|||||||
List of middleware instances.
|
List of middleware instances.
|
||||||
"""
|
"""
|
||||||
resolved_app_config = app_config or get_app_config()
|
resolved_app_config = app_config or get_app_config()
|
||||||
|
cfg = _get_runtime_config(config)
|
||||||
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
|
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
|
||||||
|
|
||||||
# Inject the admin prompt prefix into the model request (kept out of the
|
# Inject the admin prompt prefix into the model request (kept out of the
|
||||||
# persisted/displayed message so the user never sees it).
|
# persisted/displayed message so the user never sees it).
|
||||||
middlewares.append(PromptPrefixMiddleware())
|
middlewares.append(PromptPrefixMiddleware())
|
||||||
|
if cfg.get("rag_mode_enabled", False):
|
||||||
|
middlewares.append(RagCitationMiddleware())
|
||||||
|
|
||||||
# Add summarization middleware if enabled
|
# Add summarization middleware if enabled
|
||||||
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
||||||
@@ -266,7 +270,6 @@ def _build_middlewares(
|
|||||||
middlewares.append(summarization_middleware)
|
middlewares.append(summarization_middleware)
|
||||||
|
|
||||||
# Add TodoList middleware if plan mode is enabled
|
# Add TodoList middleware if plan mode is enabled
|
||||||
cfg = _get_runtime_config(config)
|
|
||||||
is_plan_mode = cfg.get("is_plan_mode", False)
|
is_plan_mode = cfg.get("is_plan_mode", False)
|
||||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||||
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
|
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
"""Inject stable citation numbering before RAG final answers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any, override
|
||||||
|
|
||||||
|
from langchain.agents import AgentState
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||||
|
from langchain_core.messages import SystemMessage
|
||||||
|
|
||||||
|
from deerflow.runtime.references import build_latest_reference_prompt, build_reference_debug_summary
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
_RAG_DEBUG_LOGGER_NAME = "rag_citation_debug"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_rag_debug_logger() -> logging.Logger:
|
||||||
|
debug_logger = logging.getLogger(_RAG_DEBUG_LOGGER_NAME)
|
||||||
|
if getattr(debug_logger, "_rag_file_configured", False):
|
||||||
|
return debug_logger
|
||||||
|
log_path = Path(os.environ.get("RAG_CITATION_DEBUG_LOG", "logs/rag_citation_debug.log"))
|
||||||
|
if not log_path.is_absolute():
|
||||||
|
log_path = Path.cwd() / log_path
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handler = logging.FileHandler(log_path, encoding="utf-8")
|
||||||
|
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||||
|
debug_logger.addHandler(handler)
|
||||||
|
debug_logger.setLevel(logging.INFO)
|
||||||
|
debug_logger.propagate = False
|
||||||
|
setattr(debug_logger, "_rag_file_configured", True)
|
||||||
|
setattr(debug_logger, "_rag_file_path", str(log_path))
|
||||||
|
return debug_logger
|
||||||
|
|
||||||
|
|
||||||
|
def _log_rag_debug(message: str, *args: Any) -> None:
|
||||||
|
logger.info(message, *args)
|
||||||
|
_get_rag_debug_logger().info(message, *args)
|
||||||
|
|
||||||
|
|
||||||
|
class RagCitationMiddleware(AgentMiddleware[AgentState]):
|
||||||
|
"""Give the model the same numbered references the frontend will render."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _enabled(request: ModelRequest) -> bool:
|
||||||
|
runtime = getattr(request, "runtime", None)
|
||||||
|
context = getattr(runtime, "context", None) if runtime is not None else None
|
||||||
|
if isinstance(context, dict) and context.get("rag_mode_enabled"):
|
||||||
|
return True
|
||||||
|
config = getattr(runtime, "config", None) if runtime is not None else None
|
||||||
|
if isinstance(config, dict):
|
||||||
|
configurable = config.get("configurable")
|
||||||
|
if isinstance(configurable, dict) and configurable.get("rag_mode_enabled"):
|
||||||
|
return True
|
||||||
|
runtime_context = config.get("context")
|
||||||
|
if isinstance(runtime_context, dict) and runtime_context.get("rag_mode_enabled"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _patch(request: ModelRequest) -> ModelRequest | None:
|
||||||
|
if not RagCitationMiddleware._enabled(request):
|
||||||
|
return None
|
||||||
|
messages = list(request.messages)
|
||||||
|
prompt, batch = build_latest_reference_prompt(messages)
|
||||||
|
if not prompt:
|
||||||
|
_log_rag_debug(
|
||||||
|
"middleware reference-prompt enabled=true prompt=false messages=%s",
|
||||||
|
len(messages),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
summary = build_reference_debug_summary(messages, [batch] if batch else None)
|
||||||
|
_log_rag_debug(
|
||||||
|
"middleware reference-prompt enabled=true prompt=true messages=%s tool_messages=%s "
|
||||||
|
"raw_items=%s normalized_items=%s kept_items=%s latest_batch_sources=%s",
|
||||||
|
summary["messages"],
|
||||||
|
summary["tool_messages"],
|
||||||
|
summary["raw_items"],
|
||||||
|
summary["normalized_items"],
|
||||||
|
summary["kept_items"],
|
||||||
|
len((batch or {}).get("sources") or []),
|
||||||
|
)
|
||||||
|
patched_messages = [
|
||||||
|
*messages,
|
||||||
|
SystemMessage(content=prompt),
|
||||||
|
]
|
||||||
|
return request.override(messages=patched_messages)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def wrap_model_call(
|
||||||
|
self,
|
||||||
|
request: ModelRequest,
|
||||||
|
handler: Callable[[ModelRequest], ModelResponse],
|
||||||
|
) -> ModelCallResult:
|
||||||
|
patched = self._patch(request)
|
||||||
|
return handler(patched or request)
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def awrap_model_call(
|
||||||
|
self,
|
||||||
|
request: ModelRequest,
|
||||||
|
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||||
|
) -> ModelCallResult:
|
||||||
|
patched = self._patch(request)
|
||||||
|
return await handler(patched or request)
|
||||||
@@ -54,6 +54,16 @@ class AppearanceSettings(BaseModel):
|
|||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
class CitationDisplaySettings(BaseModel):
|
||||||
|
"""System-wide cleanup rules for citation cards and detail dialogs."""
|
||||||
|
|
||||||
|
cleanup_words: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Words or phrases used to remove noisy advertisement/disclaimer lines from citation display.",
|
||||||
|
)
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
class PrivateSquareSettings(BaseModel):
|
class PrivateSquareSettings(BaseModel):
|
||||||
"""Admin-configurable settings for the agent private-square (私密广场).
|
"""Admin-configurable settings for the agent private-square (私密广场).
|
||||||
|
|
||||||
@@ -74,6 +84,7 @@ class SystemSettings(BaseModel):
|
|||||||
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
|
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
|
||||||
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
|
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
|
||||||
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
|
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
|
||||||
|
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
"""Build stable per-turn citation batches from LangGraph messages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def build_reference_batches(messages: list[Any]) -> list[dict[str, Any]]:
|
||||||
|
batches: list[dict[str, Any]] = []
|
||||||
|
for index, message in enumerate(messages):
|
||||||
|
if _message_type(message) != "human":
|
||||||
|
continue
|
||||||
|
end = len(messages)
|
||||||
|
for cursor in range(index + 1, len(messages)):
|
||||||
|
if _message_type(messages[cursor]) == "human":
|
||||||
|
end = cursor
|
||||||
|
break
|
||||||
|
assistant = _find_last_assistant_answer(messages, index + 1, end)
|
||||||
|
if assistant is None:
|
||||||
|
continue
|
||||||
|
sources = _collect_sources(messages[index + 1 : end])
|
||||||
|
if sources:
|
||||||
|
message_id = _message_id(assistant) or str(index)
|
||||||
|
batches.append(
|
||||||
|
{
|
||||||
|
"id": message_id,
|
||||||
|
"assistant_message_id": message_id,
|
||||||
|
"sources": sources,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return batches
|
||||||
|
|
||||||
|
|
||||||
|
def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, Any] | None]:
|
||||||
|
human_index = _find_latest_human_index(messages)
|
||||||
|
if human_index is None:
|
||||||
|
return "", None
|
||||||
|
sources = _collect_sources(messages[human_index + 1 :])
|
||||||
|
if not sources:
|
||||||
|
return "", None
|
||||||
|
assistant = _find_last_assistant_answer(messages, human_index + 1, len(messages))
|
||||||
|
message_id = _message_id(assistant) or _message_id(messages[human_index]) or str(human_index)
|
||||||
|
batch = {
|
||||||
|
"id": message_id,
|
||||||
|
"assistant_message_id": message_id,
|
||||||
|
"sources": sources,
|
||||||
|
}
|
||||||
|
lines = [
|
||||||
|
"本轮检索结果已由系统统一合并并重新编号。最终回答必须使用下面这套编号。",
|
||||||
|
"如果你仍需要补充检索,请先继续调用工具;最终回答只能引用最新合并后的编号。",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for source in sources:
|
||||||
|
title = source.get("title") or f"参考文献 {source.get('index')}"
|
||||||
|
meta = " · ".join(
|
||||||
|
str(item)
|
||||||
|
for item in (source.get("source"), source.get("time"))
|
||||||
|
if item
|
||||||
|
)
|
||||||
|
content = str(source.get("content") or source.get("snippet") or "").strip()
|
||||||
|
if len(content) > 520:
|
||||||
|
content = content[:517] + "..."
|
||||||
|
head = f"[{source.get('index')}] {title}"
|
||||||
|
if meta:
|
||||||
|
head += f"({meta})"
|
||||||
|
lines.append(head)
|
||||||
|
if content:
|
||||||
|
lines.append(content)
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"引用规则:",
|
||||||
|
"1. 回答正文中的关键结论必须标注对应编号,例如 [1]、[2]。",
|
||||||
|
"2. 多个来源可写为 [1][3][5]。",
|
||||||
|
"3. 只能引用上方存在的编号,不要编造编号。",
|
||||||
|
"4. 不要使用工具原始编号,也不要重新排序编号。",
|
||||||
|
"5. 不要在正文末尾堆完整参考文献列表,前端会展示右侧参考文献。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines).strip(), batch
|
||||||
|
|
||||||
|
|
||||||
|
def _find_latest_human_index(messages: list[Any]) -> int | None:
|
||||||
|
for index in range(len(messages) - 1, -1, -1):
|
||||||
|
if _message_type(messages[index]) == "human":
|
||||||
|
return index
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_reference_debug_summary(messages: list[Any], batches: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||||
|
"""Return compact counts for citation extraction logs."""
|
||||||
|
turn_summaries: list[dict[str, Any]] = []
|
||||||
|
total_tool_messages = 0
|
||||||
|
total_raw_items = 0
|
||||||
|
total_normalized_items = 0
|
||||||
|
total_kept_items = 0
|
||||||
|
for index, message in enumerate(messages):
|
||||||
|
if _message_type(message) != "human":
|
||||||
|
continue
|
||||||
|
end = len(messages)
|
||||||
|
for cursor in range(index + 1, len(messages)):
|
||||||
|
if _message_type(messages[cursor]) == "human":
|
||||||
|
end = cursor
|
||||||
|
break
|
||||||
|
tool_messages = 0
|
||||||
|
raw_items = 0
|
||||||
|
normalized_items = 0
|
||||||
|
kept_items = 0
|
||||||
|
for tool_message in messages[index + 1 : end]:
|
||||||
|
if _message_type(tool_message) != "tool":
|
||||||
|
continue
|
||||||
|
tool_messages += 1
|
||||||
|
items = _first_list(_extract_tool_payload(tool_message))
|
||||||
|
raw_items += len(items)
|
||||||
|
for raw_item in items:
|
||||||
|
normalized = _normalize_reference_item(raw_item, normalized_items + 1)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
normalized_items += 1
|
||||||
|
kept_items += 1
|
||||||
|
total_tool_messages += tool_messages
|
||||||
|
total_raw_items += raw_items
|
||||||
|
total_normalized_items += normalized_items
|
||||||
|
total_kept_items += kept_items
|
||||||
|
turn_summaries.append(
|
||||||
|
{
|
||||||
|
"human_index": index,
|
||||||
|
"tool_messages": tool_messages,
|
||||||
|
"raw_items": raw_items,
|
||||||
|
"normalized_items": normalized_items,
|
||||||
|
"kept_items": kept_items,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
batch_summaries = [
|
||||||
|
{
|
||||||
|
"id": batch.get("id"),
|
||||||
|
"assistant_message_id": batch.get("assistant_message_id"),
|
||||||
|
"source_count": len(batch.get("sources") or []),
|
||||||
|
}
|
||||||
|
for batch in (batches or build_reference_batches(messages))
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"messages": len(messages),
|
||||||
|
"turns": len(turn_summaries),
|
||||||
|
"tool_messages": total_tool_messages,
|
||||||
|
"raw_items": total_raw_items,
|
||||||
|
"normalized_items": total_normalized_items,
|
||||||
|
"kept_items": total_kept_items,
|
||||||
|
"turns_detail": turn_summaries,
|
||||||
|
"batches": batch_summaries,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
|
||||||
|
sources: list[dict[str, Any]] = []
|
||||||
|
for message in messages:
|
||||||
|
if _message_type(message) != "tool":
|
||||||
|
continue
|
||||||
|
payload = _extract_tool_payload(message)
|
||||||
|
for raw_item in _first_list(payload):
|
||||||
|
normalized = _normalize_reference_item(raw_item, len(sources) + 1)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
normalized["index"] = len(sources) + 1
|
||||||
|
sources.append(normalized)
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
def _find_last_assistant_answer(messages: list[Any], start: int, end: int) -> Any | None:
|
||||||
|
for index in range(end - 1, start - 1, -1):
|
||||||
|
candidate = messages[index]
|
||||||
|
if (
|
||||||
|
_message_type(candidate) == "ai"
|
||||||
|
and _message_id(candidate)
|
||||||
|
and _has_answer_content(candidate)
|
||||||
|
and not _has_tool_calls(candidate)
|
||||||
|
):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _message_type(message: Any) -> str:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
return str(message.get("type") or message.get("role") or "")
|
||||||
|
return str(getattr(message, "type", "") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _message_id(message: Any) -> str | None:
|
||||||
|
value = message.get("id") if isinstance(message, dict) else getattr(message, "id", None)
|
||||||
|
return str(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _message_content(message: Any) -> Any:
|
||||||
|
return message.get("content") if isinstance(message, dict) else getattr(message, "content", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_tool_calls(message: Any) -> bool:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
direct = message.get("tool_calls")
|
||||||
|
chunks = message.get("tool_call_chunks")
|
||||||
|
extra = (message.get("additional_kwargs") or {}).get("tool_calls")
|
||||||
|
else:
|
||||||
|
direct = getattr(message, "tool_calls", None)
|
||||||
|
chunks = getattr(message, "tool_call_chunks", None)
|
||||||
|
extra = (getattr(message, "additional_kwargs", None) or {}).get("tool_calls")
|
||||||
|
return bool(direct or chunks or extra)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_answer_content(message: Any) -> bool:
|
||||||
|
content = _message_content(message)
|
||||||
|
if isinstance(content, str):
|
||||||
|
return bool(content.strip())
|
||||||
|
if isinstance(content, list):
|
||||||
|
return any(
|
||||||
|
isinstance(part, dict)
|
||||||
|
and part.get("type") == "text"
|
||||||
|
and str(part.get("text") or "").strip()
|
||||||
|
for part in content
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_tool_payload(message: Any) -> Any:
|
||||||
|
content = _message_content(message)
|
||||||
|
if isinstance(content, dict):
|
||||||
|
return _unwrap_tool_payload(content)
|
||||||
|
if isinstance(content, list):
|
||||||
|
texts: list[str] = []
|
||||||
|
for part in content:
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
continue
|
||||||
|
if isinstance(part.get("json"), dict):
|
||||||
|
return _unwrap_tool_payload(part["json"])
|
||||||
|
if part.get("type") == "json" and isinstance(part.get("json"), dict):
|
||||||
|
return _unwrap_tool_payload(part["json"])
|
||||||
|
if part.get("type") == "text" and isinstance(part.get("text"), str):
|
||||||
|
texts.append(part["text"])
|
||||||
|
unwrapped = _unwrap_tool_payload(part)
|
||||||
|
if unwrapped is not None:
|
||||||
|
return unwrapped
|
||||||
|
content = "\n".join(texts)
|
||||||
|
if isinstance(content, str):
|
||||||
|
return _parse_json_like(content)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_tool_payload(value: dict[str, Any]) -> Any:
|
||||||
|
list_keys = ("results", "data", "items", "hits", "documents", "records", "sources")
|
||||||
|
if any(isinstance(value.get(key), list) for key in list_keys):
|
||||||
|
return value
|
||||||
|
for key in ("result", "output", "stdout", "content", "text", "payload", "response"):
|
||||||
|
nested = value.get(key)
|
||||||
|
if isinstance(nested, str):
|
||||||
|
parsed = _parse_json_like(nested)
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
unwrapped = _unwrap_tool_payload(nested)
|
||||||
|
if unwrapped is not None:
|
||||||
|
return unwrapped
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_like(text: str) -> Any:
|
||||||
|
trimmed = text.strip()
|
||||||
|
if not trimmed:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(trimmed)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
first = trimmed.find("{")
|
||||||
|
last = trimmed.rfind("}")
|
||||||
|
if 0 <= first < last:
|
||||||
|
try:
|
||||||
|
return json.loads(trimmed[first : last + 1])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _first_list(payload: Any) -> list[Any]:
|
||||||
|
if isinstance(payload, list):
|
||||||
|
return payload
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return []
|
||||||
|
for key in ("results", "data", "items", "hits", "documents", "records", "entries", "matches", "chunks", "sources"):
|
||||||
|
value = payload.get(key)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
for key in ("result", "payload", "response", "output", "data"):
|
||||||
|
found = _first_list(payload.get(key))
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _nested_records(raw: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return []
|
||||||
|
layers = [raw]
|
||||||
|
for key in ("metadata", "meta", "attrs", "attributes", "properties", "extra"):
|
||||||
|
nested = raw.get(key)
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
layers.append(nested)
|
||||||
|
return layers
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(layers: list[dict[str, Any]], keys: tuple[str, ...]) -> str | None:
|
||||||
|
for layer in layers:
|
||||||
|
for key in keys:
|
||||||
|
value = layer.get(key)
|
||||||
|
if isinstance(value, (str, int, float)) and str(value).strip():
|
||||||
|
return str(value).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_reference_item(raw: Any, fallback_index: int) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
layers = _nested_records(raw)
|
||||||
|
title = _pick(layers, ("title", "m_title", "name", "question", "keyword", "query", "text"))
|
||||||
|
content = _pick(layers, ("content", "page_content", "summary", "snippet", "description", "content_preview", "abstract", "body"))
|
||||||
|
url = _pick(layers, ("url", "link", "href", "source_url", "web_url"))
|
||||||
|
source = _pick(layers, ("source", "source1", "site", "provider", "from"))
|
||||||
|
time = _pick(layers, ("time", "date", "published_date", "publishedDate", "m_publish", "publish_time", "created_at"))
|
||||||
|
item_id = _pick(layers, ("recUuid", "rec_uuid", "uuid", "docId", "doc_id", "id", "recordId", "taskId"))
|
||||||
|
score_raw = _pick(layers, ("score", "relevance", "similarity"))
|
||||||
|
score = None
|
||||||
|
if score_raw is not None:
|
||||||
|
try:
|
||||||
|
score = float(score_raw)
|
||||||
|
except Exception:
|
||||||
|
score = None
|
||||||
|
if not title:
|
||||||
|
title = source or f"结果 {fallback_index}"
|
||||||
|
if not content:
|
||||||
|
content = ""
|
||||||
|
if not title and not content and not url:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": item_id or url,
|
||||||
|
"skillName": "backend-reference",
|
||||||
|
"mode": "citation",
|
||||||
|
"title": title,
|
||||||
|
"type": "result",
|
||||||
|
"snippet": content,
|
||||||
|
"content": content,
|
||||||
|
"source": source,
|
||||||
|
"time": time,
|
||||||
|
"url": url,
|
||||||
|
"score": score,
|
||||||
|
"raw": raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_key(source: dict[str, Any]) -> str:
|
||||||
|
raw = source.get("raw") if isinstance(source.get("raw"), dict) else {}
|
||||||
|
item_id = source.get("id") or source.get("url") or raw.get("recUuid") or raw.get("uuid") or raw.get("docId") or raw.get("id")
|
||||||
|
if item_id:
|
||||||
|
return str(item_id).casefold().strip()
|
||||||
|
return "|".join(
|
||||||
|
str(source.get(key) or "").casefold().strip()
|
||||||
|
for key in ("title", "source", "time", "content", "snippet")
|
||||||
|
)
|
||||||
Reference in new issue
Block a user