This commit is contained in:
fengxiangdebaozi committed 2026-05-28 15:56:58 +08:00
commit 23b9d401b7
11 files changed
+1153 -172

No files matched your search

+7 -4
View File
@@ -5,10 +5,10 @@ frontend-web/.vscode/
# Claude Code local-only settings (per-user permissions, not shared) # Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json .claude/settings.local.json
# Local runtime logs and pid files # Local runtime logs and pid files
.runtime/ .runtime/
logs/ logs/
offline-backend-20260512/backend/logs/ offline-backend-20260512/backend/logs/
# Frontend local/runtime artifacts # Frontend local/runtime artifacts
frontend-web/.env frontend-web/.env
@@ -83,3 +83,6 @@ hermes-agent-main/
offline-backend-20260512/backend/tests/* offline-backend-20260512/backend/tests/*
deploy deploy
.history .history
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/
@@ -16,6 +16,7 @@ import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import { import {
buildSkillDisplayMap, buildSkillDisplayMap,
collectRuntimeDisplaySources, collectRuntimeDisplaySources,
@@ -147,16 +148,30 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
}); });
return () => controller.abort(); return () => controller.abort();
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]); }, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
const referenceBatches = const adaptedBackendReferenceBatches = useMemo(
backendReferenceBatches.length > 0 () =>
? backendReferenceBatches adaptBackendReferenceBatches(
: fallbackReferenceBatches; backendReferenceBatches,
fallbackReferenceBatches,
skillDisplayMap,
),
[backendReferenceBatches, fallbackReferenceBatches, skillDisplayMap],
);
const referenceBatches = useMemo(
() =>
mergeReferenceBatches(
adaptedBackendReferenceBatches,
fallbackReferenceBatches,
),
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
);
useEffect(() => { useEffect(() => {
if (!ragModeEnabled) { if (!ragModeEnabled) {
return; return;
} }
const signature = JSON.stringify({ const signature = JSON.stringify({
backend: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0), backend: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
adaptedBackend: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
fallback: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0), fallback: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
final: referenceBatches.map((batch) => batch.sources?.length ?? 0), final: referenceBatches.map((batch) => batch.sources?.length ?? 0),
}); });
@@ -168,12 +183,13 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
using: backendReferenceBatches.length > 0 ? "后端接口" : "前端兜底", using: backendReferenceBatches.length > 0 ? "后端接口" : "前端兜底",
backendBatchCount: backendReferenceBatches.length, backendBatchCount: backendReferenceBatches.length,
backendSourceCounts: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0), backendSourceCounts: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
adaptedBackendSourceCounts: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
fallbackBatchCount: fallbackReferenceBatches.length, fallbackBatchCount: fallbackReferenceBatches.length,
fallbackSourceCounts: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0), fallbackSourceCounts: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
finalBatchCount: referenceBatches.length, finalBatchCount: referenceBatches.length,
finalSourceCounts: referenceBatches.map((batch) => batch.sources?.length ?? 0), finalSourceCounts: referenceBatches.map((batch) => batch.sources?.length ?? 0),
}); });
}, [backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]); }, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
const latestReferenceBatch = const latestReferenceBatch =
referenceBatches.length > 0 referenceBatches.length > 0
? referenceBatches[referenceBatches.length - 1]! ? referenceBatches[referenceBatches.length - 1]!
@@ -487,6 +503,191 @@ function ReferencePanel({
); );
} }
function adaptBackendReferenceBatches(
backendBatches: BackendReferenceBatch[],
configuredBatches: BackendReferenceBatch[],
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
): BackendReferenceBatch[] {
if (backendBatches.length === 0) {
return backendBatches;
}
return backendBatches.map((backendBatch, batchIndex) => {
const configuredBatch =
configuredBatches.find(
(batch) =>
batch.id === backendBatch.id ||
batch.assistant_message_id === backendBatch.assistant_message_id,
) ?? configuredBatches[batchIndex];
const configuredSources = configuredBatch?.sources ?? [];
const configuredByIndex = new Map(
configuredSources.map((source) => [source.index, source]),
);
const configuredByKey = new Map(
configuredSources.map((source) => [referenceSourceKey(source), source]),
);
return {
...backendBatch,
sources: backendBatch.sources.map((backendSource, sourceIndex) => {
const configuredSource =
configuredByIndex.get(backendSource.index) ??
configuredByKey.get(referenceSourceKey(backendSource)) ??
configuredSources[sourceIndex] ??
adaptBackendSourceWithSkillDisplay(backendSource, skillDisplayMap);
if (!configuredSource) {
return backendSource;
}
return {
...backendSource,
...configuredSource,
index: backendSource.index,
mode:
backendSource.mode !== "citation"
? backendSource.mode
: configuredSource.mode || backendSource.mode,
raw: backendSource.raw ?? configuredSource.raw,
};
}),
};
});
}
function mergeReferenceBatches(
backendBatches: BackendReferenceBatch[],
fallbackBatches: BackendReferenceBatch[],
): BackendReferenceBatch[] {
if (backendBatches.length === 0) {
return fallbackBatches;
}
if (fallbackBatches.length === 0) {
return backendBatches;
}
const merged = [...backendBatches];
const knownIds = new Set(
backendBatches.flatMap((batch) =>
[batch.id, batch.assistant_message_id].filter(Boolean),
),
);
for (const fallbackBatch of fallbackBatches) {
if (
knownIds.has(fallbackBatch.id) ||
(fallbackBatch.assistant_message_id &&
knownIds.has(fallbackBatch.assistant_message_id))
) {
continue;
}
merged.push(fallbackBatch);
}
return merged;
}
function adaptBackendSourceWithSkillDisplay(
source: BackendReferenceSource,
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
): BackendReferenceSource | null {
const raw = source.raw;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return null;
}
const rawItem = raw as Record<string, unknown>;
const match = findBackendSourceDisplay(source, skillDisplayMap);
if (!match) {
return null;
}
const normalized = normalizeDisplaySources(
buildPayloadAtPath(match.display.resultPath || "results", [rawItem]),
match.display,
)[0];
if (!normalized) {
return null;
}
return {
...source,
...normalized,
skillName: match.skillName,
mode: match.display.mode,
tableColumns: match.display.tableColumns,
index: source.index,
raw: source.raw,
};
}
function findBackendSourceDisplay(
source: BackendReferenceSource,
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
) {
const direct = skillDisplayMap.get(source.skillName);
if (direct) {
return { skillName: source.skillName, display: direct };
}
const normalizedSource = normalizeSkillName(source.skillName);
for (const [skillName, display] of skillDisplayMap) {
const normalizedSkill = normalizeSkillName(skillName);
if (
normalizedSource === normalizedSkill ||
normalizedSource.endsWith(normalizedSkill) ||
normalizedSource.includes(normalizedSkill)
) {
return { skillName, display };
}
}
return null;
}
function normalizeSkillName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function buildPayloadAtPath(path: string, items: Record<string, unknown>[]) {
const parts = path.split(".").map((part) => part.trim()).filter(Boolean);
if (parts.length === 0) {
return items;
}
const root: Record<string, unknown> = {};
let cursor = root;
for (let index = 0; index < parts.length; index++) {
const part = parts[index]!;
if (index === parts.length - 1) {
cursor[part] = items;
} else {
const next: Record<string, unknown> = {};
cursor[part] = next;
cursor = next;
}
}
return root;
}
function referenceSourceKey(source: BackendReferenceSource): string {
const raw = source.raw ?? {};
const rawRecord =
raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: {};
const id =
source.id ||
source.url ||
stringifyKeyValue(rawRecord.recUuid) ||
stringifyKeyValue(rawRecord.uuid) ||
stringifyKeyValue(rawRecord.docId) ||
stringifyKeyValue(rawRecord.id);
if (id) {
return id.trim().toLowerCase();
}
return [source.title, source.source, source.time, source.content, source.snippet]
.filter(Boolean)
.join("|")
.trim()
.toLowerCase();
}
function stringifyKeyValue(value: unknown): string | undefined {
if (typeof value === "string" || typeof value === "number") {
const text = String(value).trim();
return text || undefined;
}
return undefined;
}
function findPreviousHumanMessageId( function findPreviousHumanMessageId(
messages: Message[], messages: Message[],
beforeMessageId?: string, beforeMessageId?: string,
@@ -530,8 +731,7 @@ function buildTurnReferenceBatches(
beforeMessageId: beforeMessageId:
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id, nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
includeBeforeMessage: false, includeBeforeMessage: false,
includeModes: ["citation"], }).filter((source) => source.mode !== "none");
}).filter((source) => source.mode === "citation");
if (sources.length > 0) { if (sources.length > 0) {
batches.push({ batches.push({
id: assistant.id, id: assistant.id,
@@ -562,8 +762,7 @@ function findLastAssistantAnswer(
if ( if (
message?.type === "ai" && message?.type === "ai" &&
message.id && message.id &&
hasAssistantAnswerContent(message) && hasAssistantAnswerContent(message)
!hasAssistantToolCalls(message)
) { ) {
return message; return message;
} }
@@ -588,18 +787,4 @@ function hasAssistantAnswerContent(message: Message): boolean {
}); });
} }
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 };
@@ -71,6 +71,7 @@ export function SourceCard({
score ? `相关度 ${score}` : null, score ? `相关度 ${score}` : null,
].filter(Boolean); ].filter(Boolean);
const isCitation = source.mode === "citation"; const isCitation = source.mode === "citation";
const showReferenceNumber = referenceTarget || isCitation;
return ( return (
<> <>
@@ -79,14 +80,14 @@ export function SourceCard({
type="button" type="button"
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:border-amber-300 target:bg-amber-50/80",
highlighted && 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)]", "border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
)} )}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{isCitation && ( {showReferenceNumber && (
<span className="bg-primary/10 text-primary border-primary/20 group-hover:bg-primary/15 mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums"> <span className="bg-primary/10 text-primary border-primary/20 group-hover:bg-primary/15 mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
{source.index} {source.index}
</span> </span>
@@ -122,7 +123,7 @@ export function SourceCard({
<div className="flex items-start justify-between gap-3 pr-8"> <div className="flex items-start justify-between gap-3 pr-8">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
{isCitation && ( {showReferenceNumber && (
<span className="bg-primary/10 text-primary border-primary/20 mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums"> <span className="bg-primary/10 text-primary border-primary/20 mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
{source.index} {source.index}
</span> </span>
@@ -0,0 +1,284 @@
"use client";
import {
CopyIcon,
DownloadIcon,
Loader2,
PencilIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getSkillFileDownloadURL } from "@/core/skills/api";
import {
useSaveSkillFile,
useSkillFileContent,
} from "@/core/skills/hooks";
import { useI18n } from "@/core/i18n/hooks";
import { reasoningPlugins } from "@/core/streamdown";
const markdownClassName =
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
"[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " +
"[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " +
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " +
"[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " +
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
"[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " +
"[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " +
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " +
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0";
function isImageMime(mime: string): boolean {
return mime.startsWith("image/");
}
export function SkillFilePreviewDialog({
skillName,
path,
treeEditable,
open,
onOpenChange,
}: {
skillName: string | null;
path: string | null;
/** From the tree response: whether *any* file in this skill is editable for the caller. */
treeEditable: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const tx = t.settings.skills;
const { data, isLoading, error } = useSkillFileContent(
open ? skillName : null,
open ? path : null,
);
const save = useSaveSkillFile();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
// Reset editor state whenever a different file is opened or the dialog closes.
useEffect(() => {
setEditing(false);
setDraft("");
}, [path, open]);
const fileEditable = !!data?.editable && treeEditable;
const isMarkdown = useMemo(() => {
if (!path) return false;
const lower = path.toLowerCase();
return lower.endsWith(".md") || lower.endsWith(".markdown");
}, [path]);
const dirty = editing && data?.content !== undefined && draft !== data.content;
function requestClose(next: boolean) {
if (!next && dirty) {
if (!window.confirm(tx.filesDiscardConfirm)) return;
}
onOpenChange(next);
}
function startEditing() {
if (!data || data.content == null) return;
setDraft(data.content);
setEditing(true);
}
function cancelEditing() {
if (dirty && !window.confirm(tx.filesDiscardConfirm)) return;
setEditing(false);
setDraft("");
}
async function handleSave() {
if (!skillName || !path) return;
try {
await save.mutateAsync({ skillName, path, content: draft });
toast.success(tx.filesSaveSuccess);
setEditing(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleCopyPath() {
if (!path) return;
await navigator.clipboard.writeText(path);
toast.success(tx.filesCopyPathAction);
}
const downloadHref =
skillName && path ? getSkillFileDownloadURL(skillName, path) : undefined;
return (
<Dialog open={open} onOpenChange={requestClose}>
<DialogContent
showCloseButton={false}
className="flex flex-col gap-0 overflow-hidden p-0"
style={{
width: "min(56rem, calc(100vw - 2rem))",
maxWidth: "none",
height: "min(720px, calc(100dvh - 2rem))",
}}
>
<DialogHeader className="shrink-0 border-b px-5 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<DialogTitle className="truncate font-mono text-sm">
{path ?? ""}
</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs">
{skillName}
{data ? ` · ${formatBytes(data.size)} · ${data.mime}` : null}
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-1">
{fileEditable && !editing && data?.is_text && (
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
onClick={startEditing}
>
<PencilIcon className="size-3.5" />
{tx.filesEditAction}
</Button>
)}
{editing && (
<>
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={cancelEditing}
disabled={save.isPending}
>
{tx.filesCancelEditAction}
</Button>
<Button
size="sm"
className="h-7 px-2 text-xs"
onClick={() => void handleSave()}
disabled={save.isPending || !dirty}
>
{save.isPending && <Loader2 className="size-3.5 animate-spin" />}
{tx.filesSaveAction}
</Button>
</>
)}
{!editing && (
<>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
onClick={() => void handleCopyPath()}
>
<CopyIcon className="size-3.5" />
</Button>
{downloadHref && (
<Button
asChild
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
>
<a href={downloadHref} download>
<DownloadIcon className="size-3.5" />
</a>
</Button>
)}
</>
)}
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-7 px-1.5 text-xs"
onClick={() => requestClose(false)}
>
<XIcon className="size-4" />
</Button>
</div>
</div>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : error ? (
<div className="text-destructive py-6 text-sm">
{error instanceof Error ? error.message : String(error)}
</div>
) : data == null ? null : editing ? (
<Textarea
value={draft}
onChange={(event) => setDraft(event.currentTarget.value)}
autoFocus
spellCheck={false}
className="h-full min-h-[420px] w-full resize-none font-mono text-xs leading-relaxed"
/>
) : data.is_text ? (
isMarkdown ? (
<MarkdownContent
className={markdownClassName}
content={data.content ?? ""}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : (
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre">
{data.content ?? ""}
</pre>
)
) : isImageMime(data.mime) ? (
<div className="flex items-center justify-center">
{downloadHref && (
<img
src={downloadHref}
alt={data.path}
className="max-h-full max-w-full rounded border"
/>
)}
</div>
) : (
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-12 text-center text-sm">
<p>{tx.filesPreviewBinary}</p>
{downloadHref && (
<Button asChild size="sm" variant="outline" className="mt-3">
<a href={downloadHref} download>
<DownloadIcon className="size-3.5" />
{tx.filesDownloadAction}
</a>
</Button>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
@@ -0,0 +1,152 @@
"use client";
import {
ChevronRightIcon,
FileCode2Icon,
FileIcon,
FileJsonIcon,
FileTextIcon,
FolderIcon,
FolderOpenIcon,
ImageIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import type { SkillFileNode } from "@/core/skills/api";
const CODE_EXTS = new Set([
"py", "js", "jsx", "ts", "tsx", "mjs", "cjs",
"sh", "bash", "zsh", "ps1",
"html", "htm", "css", "scss", "sass", "xml", "svg",
"sql", "ini", "toml", "cfg", "env", "yaml", "yml",
]);
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "tiff"]);
function fileIconFor(name: string) {
const ext = name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
if (name === "SKILL.md") return FileTextIcon;
if (IMAGE_EXTS.has(ext)) return ImageIcon;
if (ext === "md" || ext === "markdown" || ext === "txt" || ext === "rst") return FileTextIcon;
if (ext === "json" || ext === "jsonl") return FileJsonIcon;
if (CODE_EXTS.has(ext)) return FileCode2Icon;
return FileIcon;
}
function TreeRow({
node,
depth,
selectedPath,
onOpen,
}: {
node: SkillFileNode;
depth: number;
selectedPath: string | null;
onOpen: (path: string) => void;
}) {
// Top-level (root) auto-expands; nested directories start collapsed.
const [open, setOpen] = useState(depth === 0);
if (node.type === "dir") {
const FolderGlyph = open ? FolderOpenIcon : FolderIcon;
return (
<div>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className={cn(
"group hover:bg-muted/60 flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-sm",
)}
style={{ paddingLeft: 6 + depth * 14 }}
>
<ChevronRightIcon
className={cn(
"text-muted-foreground size-3.5 shrink-0 transition-transform",
open && "rotate-90",
)}
/>
<FolderGlyph className="size-4 shrink-0 text-amber-500/90" />
<span className="truncate font-medium">{node.name}</span>
</button>
{open && node.children && node.children.length > 0 && (
<div className="border-border/60 ml-[14px] border-l border-dashed">
{node.children.map((child) => (
<TreeRow
key={child.path || child.name}
node={child}
depth={depth + 1}
selectedPath={selectedPath}
onOpen={onOpen}
/>
))}
</div>
)}
</div>
);
}
const Icon = fileIconFor(node.name);
const isSelected = selectedPath === node.path;
return (
<button
type="button"
onClick={() => onOpen(node.path)}
className={cn(
"hover:bg-muted/60 flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-sm",
isSelected && "bg-primary/10 hover:bg-primary/15",
)}
style={{ paddingLeft: 6 + depth * 14 + 14 }}
title={node.path}
>
<Icon className="text-muted-foreground size-4 shrink-0" />
<span className="truncate">{node.name}</span>
{typeof node.size === "number" && (
<span className="text-muted-foreground/70 ml-auto shrink-0 text-[10px] tabular-nums">
{formatBytes(node.size)}
</span>
)}
</button>
);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export function SkillFileTree({
root,
onOpen,
selectedPath,
emptyText,
}: {
root: SkillFileNode;
onOpen: (path: string) => void;
selectedPath: string | null;
emptyText: string;
}) {
const isEmpty = useMemo(
() => !root.children || root.children.length === 0,
[root],
);
if (isEmpty) {
return (
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center text-sm">
{emptyText}
</div>
);
}
return (
<div className="font-mono">
<TreeRow
node={root}
depth={0}
selectedPath={selectedPath}
onOpen={onOpen}
/>
</div>
);
}
@@ -165,7 +165,7 @@ export function WorkspaceNavChatList({
)} )}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
{!showCollapseTrigger && <WorkspaceNavStudio />} {/* {!showCollapseTrigger && <WorkspaceNavStudio />} */}
</> </>
); );
} }
+12 -89
View File
@@ -106,23 +106,6 @@ function canInferSkillFromToolArgs(toolName: string): boolean {
); );
} }
function canInspectPayloadForDisplay(toolName: string): boolean {
const normalized = normalizeName(toolName);
if (
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(toolName) ||
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(normalized)
) {
return false;
}
return (
canInferSkillFromToolArgs(toolName) ||
normalized.includes("search") ||
normalized.includes("retriev") ||
normalized.includes("knowledge") ||
normalized.includes("query")
);
}
function extractSkillNameFromArgs(args: Record<string, unknown>): string | undefined { function extractSkillNameFromArgs(args: Record<string, unknown>): string | undefined {
for (const value of Object.values(args)) { for (const value of Object.values(args)) {
if (typeof value !== "string") continue; if (typeof value !== "string") continue;
@@ -164,9 +147,7 @@ export function collectRuntimeDisplaySources(
: null; : null;
const sources: RuntimeDisplaySource[] = []; const sources: RuntimeDisplaySource[] = [];
let citationIndex = 0; let citationIndex = 0;
const displayIndexes = new Map<string, number>();
const seenCitationKeys = new Map<string, RuntimeDisplaySource>(); const seenCitationKeys = new Map<string, RuntimeDisplaySource>();
const seenDisplayKeys = new Set<string>();
const consumedToolMessageIds = new Set<string>(); const consumedToolMessageIds = new Set<string>();
const consumedToolMessages = new Set<Message>(); const consumedToolMessages = new Set<Message>();
@@ -224,9 +205,7 @@ export function collectRuntimeDisplaySources(
mode: effectiveMode, mode: effectiveMode,
tableColumns: match?.display.tableColumns, tableColumns: match?.display.tableColumns,
})), })),
displayIndexes,
seenCitationKeys, seenCitationKeys,
seenDisplayKeys,
() => ++citationIndex, () => ++citationIndex,
); );
} }
@@ -247,7 +226,7 @@ export function collectRuntimeDisplaySources(
continue; continue;
} }
const toolName = extractToolMessageName(message); const toolName = extractToolMessageName(message);
const match = null; const match = findDisplayConfigForPayload(toolName, result, displays);
const effectiveMode = match?.display.mode ?? "citation"; const effectiveMode = match?.display.mode ?? "citation";
if (allowedModes && !allowedModes.has(effectiveMode)) { if (allowedModes && !allowedModes.has(effectiveMode)) {
continue; continue;
@@ -266,9 +245,7 @@ export function collectRuntimeDisplaySources(
mode: effectiveMode, mode: effectiveMode,
tableColumns: match?.display.tableColumns, tableColumns: match?.display.tableColumns,
})), })),
displayIndexes,
seenCitationKeys, seenCitationKeys,
seenDisplayKeys,
() => ++citationIndex, () => ++citationIndex,
); );
} }
@@ -278,9 +255,7 @@ export function collectRuntimeDisplaySources(
function appendDisplaySources( function appendDisplaySources(
target: RuntimeDisplaySource[], target: RuntimeDisplaySource[],
drafts: RuntimeDisplaySourceDraft[], drafts: RuntimeDisplaySourceDraft[],
displayIndexes: Map<string, number>,
seenCitationKeys: Map<string, RuntimeDisplaySource>, seenCitationKeys: Map<string, RuntimeDisplaySource>,
seenDisplayKeys: Set<string>,
nextCitationIndex: () => number, nextCitationIndex: () => number,
) { ) {
for (const draft of drafts) { for (const draft of drafts) {
@@ -297,17 +272,9 @@ function appendDisplaySources(
continue; continue;
} }
const stableKey = nonCitationSourceKey(draft);
if (seenDisplayKeys.has(stableKey)) {
continue;
}
seenDisplayKeys.add(stableKey);
const displayKey = `${draft.skillName}:${draft.mode}`;
const displayIndex = (displayIndexes.get(displayKey) ?? 0) + 1;
displayIndexes.set(displayKey, displayIndex);
target.push({ target.push({
...draft, ...draft,
index: displayIndex, index: nextCitationIndex(),
}); });
} }
} }
@@ -323,9 +290,6 @@ export function reindexRuntimeDisplaySources(
globalSources.map((source) => [citationSourceKey(source), source]), globalSources.map((source) => [citationSourceKey(source), source]),
); );
return sources.map((source) => { return sources.map((source) => {
if (source.mode !== "citation") {
return source;
}
const global = globalByKey.get(citationSourceKey(source)); const global = globalByKey.get(citationSourceKey(source));
return global return global
? { ? {
@@ -361,10 +325,6 @@ function citationSourceKey(source: RuntimeDisplaySourceDraft): string {
return normalizeCitationKey(fallback) || normalizeCitationKey(JSON.stringify(raw).slice(0, 300)); return normalizeCitationKey(fallback) || normalizeCitationKey(JSON.stringify(raw).slice(0, 300));
} }
function nonCitationSourceKey(source: RuntimeDisplaySourceDraft): string {
return `${source.skillName}:${source.mode}:${citationSourceKey(source)}`;
}
function extractRuntimeToolCalls(message: Message): RuntimeToolCall[] { function extractRuntimeToolCalls(message: Message): RuntimeToolCall[] {
const calls: RuntimeToolCall[] = []; const calls: RuntimeToolCall[] = [];
const direct = (message as { tool_calls?: unknown }).tool_calls; const direct = (message as { tool_calls?: unknown }).tool_calls;
@@ -441,20 +401,10 @@ function normalizeToolArgs(value: unknown): Record<string, unknown> | undefined
export function findDisplayConfigForPayload( export function findDisplayConfigForPayload(
toolName: string, toolName: string,
payload: unknown, _payload: unknown,
displays: SkillDisplayMap, displays: SkillDisplayMap,
): { skillName: string; display: SkillDisplayConfig } | null { ): { skillName: string; display: SkillDisplayConfig } | null {
if (!canInspectPayloadForDisplay(toolName)) { return findDisplayConfigForTool(toolName, displays);
return null;
}
let best: { skillName: string; display: SkillDisplayConfig; score: number } | null = null;
for (const [skillName, display] of displays) {
const score = scoreDisplayPayloadMatch(payload, display);
if (score >= 3 && (!best || score > best.score)) {
best = { skillName, display, score };
}
}
return best ? { skillName: best.skillName, display: best.display } : null;
} }
export function isUnconfiguredSkillInvocation( export function isUnconfiguredSkillInvocation(
@@ -498,33 +448,6 @@ export function isDisplaySamplePayload(payload: unknown): boolean {
); );
} }
function scoreDisplayPayloadMatch(payload: unknown, display: SkillDisplayConfig): number {
const items = getArrayByPath(payload, display.resultPath);
const first = items?.find((item): item is Record<string, unknown> => isRecord(item));
if (!first) {
return 0;
}
const fields = [
display.titleField,
display.summaryField,
display.contentField,
display.sourceField,
display.typeField,
display.timeField,
display.scoreField,
display.urlField,
display.idField,
].filter((field): field is string => !!field);
return new Set(fields).size === 0
? 0
: [...new Set(fields)].filter(
(field) =>
first[field] !== undefined &&
first[field] !== null &&
first[field] !== "",
).length;
}
function getArrayByPath(payload: unknown, path: string): unknown[] | null { function getArrayByPath(payload: unknown, path: string): unknown[] | null {
const value = path.split(".").reduce<unknown>((acc, key) => { const value = path.split(".").reduce<unknown>((acc, key) => {
if (acc && typeof acc === "object") { if (acc && typeof acc === "object") {
@@ -589,14 +512,14 @@ export function applyInlineCitations(
if (!content || sources.length === 0) { if (!content || sources.length === 0) {
return content; return content;
} }
const citationSources = sources.filter((source) => source.mode === "citation"); const referenceSources = sources.filter((source) => source.mode !== "none");
if (citationSources.length === 0) { if (referenceSources.length === 0) {
return content; return content;
} }
const byIndex = new Map(citationSources.map((source) => [source.index, source])); const byIndex = new Map(referenceSources.map((source) => [source.index, source]));
const byUrl = new Map<string, RuntimeDisplaySource>(); const byUrl = new Map<string, RuntimeDisplaySource>();
const byTitle = new Map<string, RuntimeDisplaySource>(); const byTitle = new Map<string, RuntimeDisplaySource>();
for (const source of citationSources) { for (const source of referenceSources) {
if (source.url) { if (source.url) {
byUrl.set(source.url, source); byUrl.set(source.url, source);
} }
@@ -610,7 +533,7 @@ export function applyInlineCitations(
byIndex, byIndex,
byUrl, byUrl,
byTitle, byTitle,
citationSources, referenceSources,
); );
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g; const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
@@ -622,7 +545,7 @@ export function applyInlineCitations(
const replacedSlice = replaceCitationTokens( const replacedSlice = replaceCitationTokens(
normalized.slice(cursor, start), normalized.slice(cursor, start),
byIndex, byIndex,
citationSources, referenceSources,
); );
if (replacedSlice !== normalized.slice(cursor, start)) { if (replacedSlice !== normalized.slice(cursor, start)) {
replaced = true; replaced = true;
@@ -631,7 +554,7 @@ export function applyInlineCitations(
next += match[0]; next += match[0];
cursor = start + match[0].length; cursor = start + match[0].length;
} }
const tail = replaceCitationTokens(normalized.slice(cursor), byIndex, citationSources); const tail = replaceCitationTokens(normalized.slice(cursor), byIndex, referenceSources);
if (tail !== normalized.slice(cursor)) { if (tail !== normalized.slice(cursor)) {
replaced = true; replaced = true;
} }
@@ -639,7 +562,7 @@ export function applyInlineCitations(
if (replaced || hasCitationMarkdown(next)) { if (replaced || hasCitationMarkdown(next)) {
return next; return next;
} }
return appendCitationSummary(next, citationSources); return appendCitationSummary(next, referenceSources);
} }
function replaceCitationTokens( function replaceCitationTokens(
+26 -15
View File
@@ -14,8 +14,8 @@ 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 { isThreadRagModeLocked, lockThreadRagMode, 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";
@@ -343,7 +343,7 @@ export function useThreadStream({
if (isThreadNotAccessibleError(message)) { if (isThreadNotAccessibleError(message)) {
const userLabel = buildCurrentUserLabel(); const userLabel = buildCurrentUserLabel();
toast.info( toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`, `当前登录用户为 ${userLabel},原对话不存在或无权访问,正在为你刷新页面…`,
); );
// Prefer staying inside an agent chat context when the user is on an // Prefer staying inside an agent chat context when the user is on an
// agent thread URL — otherwise fall back to the global new chat. // agent thread URL — otherwise fall back to the global new chat.
@@ -352,7 +352,18 @@ export function useThreadStream({
const target = agentMatch const target = agentMatch
? `/page/workspace/agents/${agentMatch[1]}/chats/new` ? `/page/workspace/agents/${agentMatch[1]}/chats/new`
: "/page/workspace/chats/new"; : "/page/workspace/chats/new";
navigate(target, { replace: true }); // First navigate to a safe URL so the post-reload page lands on a
// valid route. Then full-reload so TanStack Query cache, context
// providers and any other state captured under the previous user's
// identity get rebuilt against the current login.
if (typeof window !== "undefined") {
window.location.hash = target;
window.setTimeout(() => {
window.location.reload();
}, 900);
} else {
navigate(target, { replace: true });
}
return; return;
} }
toast.error(message); toast.error(message);
@@ -555,13 +566,13 @@ export function useThreadStream({
}), }),
); );
const ragModeEnabled = const ragModeEnabled =
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId); localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
if (ragModeEnabled) { if (ragModeEnabled) {
lockThreadRagMode(threadId); lockThreadRagMode(threadId);
} }
await thread.submit( await thread.submit(
{ {
messages: [ messages: [
{ {
@@ -604,10 +615,10 @@ 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: ragModeEnabled, rag_mode_enabled: ragModeEnabled,
thread_id: threadId, thread_id: threadId,
}, },
}, },
); );
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
} catch (error) { } catch (error) {
-13
View File
@@ -568,19 +568,6 @@
} }
} }
@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 ─────────────────────────────────────────────── */
@@ -19,16 +19,21 @@ from pathlib import Path
import uuid import uuid
from typing import Any from typing import Any
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from langgraph.checkpoint.base import empty_checkpoint from langgraph.checkpoint.base import empty_checkpoint
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from app.gateway.authz import require_permission
from app.gateway.deps import get_checkpointer from app.gateway.authz import require_permission
from app.gateway.utils import sanitize_log_param from app.gateway.deps import get_checkpointer
from app.gateway.utils import sanitize_log_param
from deerflow.config.extensions_config import SkillDisplayConfig
from deerflow.config.paths import Paths, get_paths from deerflow.config.paths import Paths, get_paths
from deerflow.persistence.engine import get_engine, get_session_factory
from deerflow.persistence.skills.model import SkillDisplayAdapterRow
from deerflow.runtime import serialize_channel_values from deerflow.runtime import serialize_channel_values
from deerflow.runtime.references import ( from deerflow.runtime.references import (
adapt_reference_batches_with_displays,
build_reference_batches as build_runtime_reference_batches, build_reference_batches as build_runtime_reference_batches,
build_reference_debug_summary, build_reference_debug_summary,
) )
@@ -81,11 +86,54 @@ def _rag_debug_file_path() -> str:
def _log_rag_debug(message: str, *args: Any) -> None: def _log_rag_debug(message: str, *args: Any) -> None:
logger.info(message, *args) logger.info(message, *args)
_get_rag_debug_logger().info(message, *args) _get_rag_debug_logger().info(message, *args)
# --------------------------------------------------------------------------- def _json_text_loads(value: str | None) -> dict[str, Any] | None:
# Response / request models if not value:
# --------------------------------------------------------------------------- return None
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else None
except Exception:
logger.debug("Failed to parse persisted skill display JSON", exc_info=True)
return None
async def _load_persisted_skill_displays() -> dict[str, SkillDisplayConfig]:
engine = get_engine()
sf = get_session_factory()
if engine is None or sf is None:
return {}
try:
async with engine.begin() as conn:
await conn.run_sync(SkillDisplayAdapterRow.__table__.create, checkfirst=True)
async with sf() as session:
result = await session.execute(select(SkillDisplayAdapterRow))
displays: dict[str, SkillDisplayConfig] = {}
for row in result.scalars():
payload = _json_text_loads(row.display_config)
if payload is None:
continue
try:
display = SkillDisplayConfig.model_validate(payload)
except Exception:
logger.debug(
"Failed to validate persisted skill display for %s",
row.skill_name,
exc_info=True,
)
continue
if display.enabled and display.mode != "none":
displays[row.skill_name] = display
return displays
except Exception:
logger.debug("Failed to load persisted skill display adapters", exc_info=True)
return {}
# ---------------------------------------------------------------------------
# Response / request models
# ---------------------------------------------------------------------------
class ThreadDeleteResponse(BaseModel): class ThreadDeleteResponse(BaseModel):
@@ -187,6 +235,7 @@ class ReferenceSourceResponse(BaseModel):
url: str | None = None url: str | None = None
content: str | None = None content: str | None = None
score: float | None = None score: float | None = None
tableColumns: list[dict[str, Any]] = Field(default_factory=list)
raw: dict[str, Any] = Field(default_factory=dict) raw: dict[str, Any] = Field(default_factory=dict)
@@ -795,10 +844,14 @@ async def get_thread_reference_batches(thread_id: str, request: Request) -> Refe
) )
return ReferenceBatchesResponse(batches=[]) return ReferenceBatchesResponse(batches=[])
raw_batches = build_runtime_reference_batches(messages) raw_batches = build_runtime_reference_batches(messages)
persisted_displays = await _load_persisted_skill_displays()
if persisted_displays:
raw_batches = adapt_reference_batches_with_displays(raw_batches, persisted_displays)
debug_summary = build_reference_debug_summary(messages, raw_batches) debug_summary = build_reference_debug_summary(messages, raw_batches)
_log_rag_debug( _log_rag_debug(
"backend reference-batches thread=%s messages=%s turns=%s tool_messages=%s raw_items=%s " "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", "normalized_items=%s kept_items=%s batches=%s batch_sources=%s batch_ids=%s "
"display_adapters=%s turns_detail=%s log_file=%s",
sanitize_log_param(thread_id), sanitize_log_param(thread_id),
debug_summary["messages"], debug_summary["messages"],
debug_summary["turns"], debug_summary["turns"],
@@ -809,6 +862,7 @@ async def get_thread_reference_batches(thread_id: str, request: Request) -> Refe
len(raw_batches), len(raw_batches),
",".join(str(batch.get("source_count", 0)) for batch in debug_summary["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"]), ",".join(str(batch.get("id") or "") for batch in debug_summary["batches"]),
len(persisted_displays),
json.dumps(debug_summary["turns_detail"], ensure_ascii=False), json.dumps(debug_summary["turns_detail"], ensure_ascii=False),
_rag_debug_file_path(), _rag_debug_file_path(),
) )
@@ -3,7 +3,14 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re
from typing import Any from typing import Any
from urllib.parse import quote
from deerflow.config.extensions_config import SkillDisplayConfig, get_extensions_config
_SKILL_PATH_RE = re.compile(r"[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)")
def build_reference_batches(messages: list[Any]) -> list[dict[str, Any]]: def build_reference_batches(messages: list[Any]) -> list[dict[str, Any]]:
@@ -16,12 +23,10 @@ def build_reference_batches(messages: list[Any]) -> list[dict[str, Any]]:
if _message_type(messages[cursor]) == "human": if _message_type(messages[cursor]) == "human":
end = cursor end = cursor
break break
assistant = _find_last_assistant_answer(messages, index + 1, end)
if assistant is None:
continue
sources = _collect_sources(messages[index + 1 : end]) sources = _collect_sources(messages[index + 1 : end])
if sources: if sources:
message_id = _message_id(assistant) or str(index) anchor = _find_reference_batch_anchor(messages, index + 1, end)
message_id = _message_id(anchor) or _message_id(message) or str(index)
batches.append( batches.append(
{ {
"id": message_id, "id": message_id,
@@ -39,7 +44,7 @@ def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, A
sources = _collect_sources(messages[human_index + 1 :]) sources = _collect_sources(messages[human_index + 1 :])
if not sources: if not sources:
return "", None return "", None
assistant = _find_last_assistant_answer(messages, human_index + 1, len(messages)) assistant = _find_reference_batch_anchor(messages, human_index + 1, len(messages))
message_id = _message_id(assistant) or _message_id(messages[human_index]) or str(human_index) message_id = _message_id(assistant) or _message_id(messages[human_index]) or str(human_index)
batch = { batch = {
"id": message_id, "id": message_id,
@@ -152,14 +157,112 @@ def build_reference_debug_summary(messages: list[Any], batches: list[dict[str, A
} }
def adapt_reference_batches_with_displays(
batches: list[dict[str, Any]],
displays: dict[str, SkillDisplayConfig | dict[str, Any]],
) -> list[dict[str, Any]]:
"""Apply persisted skill display configs to already-built reference batches.
The reference builder can run before the gateway has a fresh extensions
cache, while the skill adapter editor persists the user's latest mapping in
the database. This pass lets the API response use that DB-backed mapping
without changing citation indexes.
"""
normalized_displays: dict[str, SkillDisplayConfig] = {}
for skill_name, display in displays.items():
try:
config = (
display
if isinstance(display, SkillDisplayConfig)
else SkillDisplayConfig.model_validate(display)
)
except Exception:
continue
if not config.enabled or config.mode == "none":
continue
normalized_displays[skill_name] = config
if not normalized_displays:
return batches
displays_by_normalized_name = {
_normalize_name(name): (name, display)
for name, display in normalized_displays.items()
}
adapted_batches: list[dict[str, Any]] = []
for batch in batches:
sources: list[dict[str, Any]] = []
for source in batch.get("sources", []):
if not isinstance(source, dict):
continue
skill_name = str(source.get("skillName") or "")
display = normalized_displays.get(skill_name)
if display is None:
normalized_source = _normalize_name(skill_name)
normalized_match = displays_by_normalized_name.get(normalized_source)
if normalized_match is None:
for normalized_skill, candidate in displays_by_normalized_name.items():
if (
normalized_source.endswith(normalized_skill)
or normalized_skill in normalized_source
):
normalized_match = candidate
break
if normalized_match is not None:
skill_name, display = normalized_match
raw = source.get("raw")
if display is not None and isinstance(raw, dict):
index_value = int(source.get("index") or len(sources) + 1)
configured = _normalize_with_display_config(
raw,
index_value,
skill_name,
display,
)
if configured is not None:
sources.append(
{
**source,
**configured,
"index": index_value,
"skillName": skill_name,
"raw": raw,
}
)
continue
sources.append(source)
adapted_batches.append({**batch, "sources": sources})
return adapted_batches
def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]: def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
sources: list[dict[str, Any]] = [] sources: list[dict[str, Any]] = []
pending_tool_contexts: list[dict[str, Any]] = []
tool_contexts_by_id: dict[str, dict[str, Any]] = {}
for message in messages: for message in messages:
if _message_type(message) != "tool": message_type = _message_type(message)
if message_type == "ai":
for tool_call in _extract_tool_calls(message):
context = _build_tool_context(tool_call)
pending_tool_contexts.append(context)
if context.get("id"):
tool_contexts_by_id[str(context["id"])] = context
continue continue
if message_type != "tool":
continue
tool_context = _resolve_tool_context(
message,
pending_tool_contexts,
tool_contexts_by_id,
)
payload = _extract_tool_payload(message) payload = _extract_tool_payload(message)
for raw_item in _first_list(payload): for raw_item in _first_list(payload):
normalized = _normalize_reference_item(raw_item, len(sources) + 1) normalized = _normalize_reference_item(
raw_item,
len(sources) + 1,
payload=payload,
tool_context=tool_context,
)
if not normalized: if not normalized:
continue continue
normalized["index"] = len(sources) + 1 normalized["index"] = len(sources) + 1
@@ -167,6 +270,17 @@ def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
return sources return sources
def _find_reference_batch_anchor(messages: list[Any], start: int, end: int) -> Any | None:
answer = _find_last_assistant_answer(messages, start, end)
if answer is not None:
return answer
for index in range(end - 1, start - 1, -1):
candidate = messages[index]
if _message_type(candidate) == "ai" and _message_id(candidate):
return candidate
return None
def _find_last_assistant_answer(messages: list[Any], start: int, end: int) -> Any | None: def _find_last_assistant_answer(messages: list[Any], start: int, end: int) -> Any | None:
for index in range(end - 1, start - 1, -1): for index in range(end - 1, start - 1, -1):
candidate = messages[index] candidate = messages[index]
@@ -174,7 +288,6 @@ def _find_last_assistant_answer(messages: list[Any], start: int, end: int) -> An
_message_type(candidate) == "ai" _message_type(candidate) == "ai"
and _message_id(candidate) and _message_id(candidate)
and _has_answer_content(candidate) and _has_answer_content(candidate)
and not _has_tool_calls(candidate)
): ):
return candidate return candidate
return None return None
@@ -195,6 +308,20 @@ def _message_content(message: Any) -> Any:
return message.get("content") if isinstance(message, dict) else getattr(message, "content", None) return message.get("content") if isinstance(message, dict) else 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 _message_tool_call_id(message: Any) -> str | None:
if isinstance(message, dict):
value = message.get("tool_call_id") or message.get("toolCallId")
else:
value = getattr(message, "tool_call_id", None) or getattr(message, "toolCallId", None)
return str(value) if value else None
def _has_tool_calls(message: Any) -> bool: def _has_tool_calls(message: Any) -> bool:
if isinstance(message, dict): if isinstance(message, dict):
direct = message.get("tool_calls") direct = message.get("tool_calls")
@@ -221,6 +348,102 @@ def _has_answer_content(message: Any) -> bool:
return False return False
def _extract_tool_calls(message: Any) -> list[dict[str, Any]]:
candidates: list[Any] = []
if isinstance(message, dict):
for key in ("tool_calls", "tool_call_chunks"):
value = message.get(key)
if isinstance(value, list):
candidates.extend(value)
extra = (message.get("additional_kwargs") or {}).get("tool_calls")
if isinstance(extra, list):
candidates.extend(extra)
else:
for key in ("tool_calls", "tool_call_chunks"):
value = getattr(message, key, None)
if isinstance(value, list):
candidates.extend(value)
extra = (getattr(message, "additional_kwargs", None) or {}).get("tool_calls")
if isinstance(extra, list):
candidates.extend(extra)
calls: list[dict[str, Any]] = []
seen_ids: set[str] = set()
for raw in candidates:
call = _normalize_tool_call(raw)
if not call:
continue
call_id = str(call.get("id") or "")
if call_id and call_id in seen_ids:
continue
if call_id:
seen_ids.add(call_id)
calls.append(call)
return calls
def _normalize_tool_call(raw: Any) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
fn = raw.get("function") if isinstance(raw.get("function"), dict) else {}
name = raw.get("name") or fn.get("name") or raw.get("tool_name")
if not name:
return None
return {
"id": raw.get("id") or raw.get("tool_call_id") or raw.get("call_id"),
"name": str(name),
"args": _normalize_tool_args(raw.get("args") or raw.get("arguments") or fn.get("arguments")),
}
def _normalize_tool_args(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
except Exception:
pass
return {"command": value}
return {}
def _build_tool_context(tool_call: dict[str, Any]) -> dict[str, Any]:
tool_name = str(tool_call.get("name") or "tool")
args = tool_call.get("args") if isinstance(tool_call.get("args"), dict) else {}
return {
"id": tool_call.get("id"),
"tool_name": tool_name,
"args": args,
"skill_name": _infer_skill_name(tool_name, args),
}
def _resolve_tool_context(
message: Any,
pending_tool_contexts: list[dict[str, Any]],
tool_contexts_by_id: dict[str, dict[str, Any]],
) -> dict[str, Any]:
tool_call_id = _message_tool_call_id(message)
if tool_call_id and tool_call_id in tool_contexts_by_id:
context = tool_contexts_by_id[tool_call_id]
pending_tool_contexts[:] = [
item for item in pending_tool_contexts if str(item.get("id") or "") != tool_call_id
]
return context
if pending_tool_contexts:
return pending_tool_contexts.pop(0)
tool_name = _message_name(message)
return {
"id": tool_call_id,
"tool_name": tool_name,
"args": {},
"skill_name": _infer_skill_name(tool_name, {}),
}
def _extract_tool_payload(message: Any) -> Any: def _extract_tool_payload(message: Any) -> Any:
content = _message_content(message) content = _message_content(message)
if isinstance(content, dict): if isinstance(content, dict):
@@ -316,9 +539,167 @@ def _pick(layers: list[dict[str, Any]], keys: tuple[str, ...]) -> str | None:
return None return None
def _normalize_reference_item(raw: Any, fallback_index: int) -> dict[str, Any] | None: def _enabled_skill_displays() -> dict[str, SkillDisplayConfig]:
try:
return {
name: state.display
for name, state in get_extensions_config().skills.items()
if state.display is not None
and state.enabled
and state.display.enabled
and state.display.mode != "none"
}
except Exception:
return {}
def _infer_skill_name(tool_name: str, args: dict[str, Any]) -> str | None:
displays = _enabled_skill_displays()
if tool_name in displays:
return tool_name
normalized_tool = _normalize_name(tool_name)
for skill_name in displays:
normalized_skill = _normalize_name(skill_name)
if (
normalized_tool == normalized_skill
or normalized_tool.endswith(normalized_skill)
or normalized_skill in normalized_tool
):
return skill_name
for value in args.values():
if not isinstance(value, str):
continue
match = _SKILL_PATH_RE.search(value)
if match:
return match.group(1)
return None
def _resolve_display_match(
raw: dict[str, Any],
payload: Any,
tool_context: dict[str, Any] | None,
) -> tuple[str, SkillDisplayConfig] | None:
displays = _enabled_skill_displays()
if not displays:
return None
skill_name = str((tool_context or {}).get("skill_name") or "")
display = displays.get(skill_name)
if display is not None:
return skill_name, display
tool_name = str((tool_context or {}).get("tool_name") or "")
inferred = _infer_skill_name(tool_name, (tool_context or {}).get("args") or {})
if inferred and inferred in displays:
return inferred, displays[inferred]
return None
def _normalize_with_display_config(
raw: dict[str, Any],
fallback_index: int,
skill_name: str,
display: SkillDisplayConfig,
) -> dict[str, Any] | None:
title = _stringify(_get_by_path(raw, display.title_field), f"结果 {fallback_index}")
snippet = _stringify(_get_by_path(raw, display.summary_field), "")
content = _stringify(_get_by_path(raw, display.content_field), snippet)
source = _stringify(_get_by_path(raw, display.source_field), "") or None
item_type = _stringify(_get_by_path(raw, display.type_field), "result") or "result"
time = _stringify(_get_by_path(raw, display.time_field), "") or None
item_id = _stringify(_get_by_path(raw, display.id_field), "") or None
score = _number_or_none(_get_by_path(raw, display.score_field))
url = _resolve_display_link(raw, display)
if not title and not content and not url:
return None
return {
"id": item_id or url,
"skillName": skill_name,
"mode": display.mode,
"title": title,
"type": item_type,
"snippet": snippet,
"content": content,
"source": source,
"time": time,
"url": url,
"score": score,
"tableColumns": [column.model_dump(by_alias=True) for column in display.table_columns],
"raw": raw,
}
def _resolve_display_link(raw: dict[str, Any], display: SkillDisplayConfig) -> str | None:
url = _stringify(_get_by_path(raw, display.url_field), "")
item_id = _stringify(_get_by_path(raw, display.id_field), "")
if display.link_strategy == "url":
return url or None
if display.link_strategy == "id":
return _render_template(display.link_template, raw) if item_id and display.link_template else None
return url or (_render_template(display.link_template, raw) if item_id and display.link_template else None) or None
def _render_template(template: str | None, raw: dict[str, Any]) -> str | None:
if not template:
return None
def replace(match: re.Match[str]) -> str:
value = _get_by_path(raw, match.group(1).strip())
return quote(_stringify(value, ""), safe="")
return re.sub(r"\{([^}]+)\}", replace, template)
def _get_by_path(value: Any, path: str | None) -> Any:
if not path:
return None
cursor = value
for part in path.split("."):
if isinstance(cursor, dict):
cursor = cursor.get(part)
else:
return None
return cursor
def _stringify(value: Any, fallback: str = "") -> str:
if value is None:
return fallback
if isinstance(value, (str, int, float)):
text = str(value).strip()
return text if text else fallback
return fallback
def _number_or_none(value: Any) -> float | None:
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value)
except Exception:
return None
return None
def _normalize_name(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "", name.lower())
def _normalize_reference_item(
raw: Any,
fallback_index: int,
*,
payload: Any = None,
tool_context: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
if not isinstance(raw, dict): if not isinstance(raw, dict):
return None return None
display_match = _resolve_display_match(raw, payload, tool_context)
if display_match is not None:
skill_name, display = display_match
configured = _normalize_with_display_config(raw, fallback_index, skill_name, display)
if configured is not None:
return configured
layers = _nested_records(raw) layers = _nested_records(raw)
title = _pick(layers, ("title", "m_title", "name", "question", "keyword", "query", "text")) title = _pick(layers, ("title", "m_title", "name", "question", "keyword", "query", "text"))
content = _pick(layers, ("content", "page_content", "summary", "snippet", "description", "content_preview", "abstract", "body")) content = _pick(layers, ("content", "page_content", "summary", "snippet", "description", "content_preview", "abstract", "body"))
@@ -341,7 +722,7 @@ def _normalize_reference_item(raw: Any, fallback_index: int) -> dict[str, Any] |
return None return None
return { return {
"id": item_id or url, "id": item_id or url,
"skillName": "backend-reference", "skillName": str((tool_context or {}).get("skill_name") or (tool_context or {}).get("tool_name") or "backend-reference"),
"mode": "citation", "mode": "citation",
"title": title, "title": title,
"type": "result", "type": "result",