技能展示配置持久化并适配引用来源,新增技能文件预览组件

- 后端将技能展示适配配置存入数据库,引用批次按持久化映射进行适配
- 前端新增技能文件预览弹窗与文件树组件,完善技能来源展示
- 修正 .gitignore 中宽泛的 skills/ 规则误忽略前端组件目录的问题

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 authored and xuhd committed 2026-05-28 18:29:10 +08:00
1 parent ac98d2f585
commit dac5ff38db
10 files changed
+1152 -171

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/settings.local.json
# Local runtime logs and pid files
.runtime/
logs/
offline-backend-20260512/backend/logs/
# Local runtime logs and pid files
.runtime/
logs/
offline-backend-20260512/backend/logs/
# Frontend local/runtime artifacts
frontend-web/.env
@@ -83,3 +83,6 @@ hermes-agent-main/
offline-backend-20260512/backend/tests/*
deploy
.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 { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
@@ -147,16 +148,30 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
});
return () => controller.abort();
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
const referenceBatches =
backendReferenceBatches.length > 0
? backendReferenceBatches
: fallbackReferenceBatches;
const adaptedBackendReferenceBatches = useMemo(
() =>
adaptBackendReferenceBatches(
backendReferenceBatches,
fallbackReferenceBatches,
skillDisplayMap,
),
[backendReferenceBatches, fallbackReferenceBatches, skillDisplayMap],
);
const referenceBatches = useMemo(
() =>
mergeReferenceBatches(
adaptedBackendReferenceBatches,
fallbackReferenceBatches,
),
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
);
useEffect(() => {
if (!ragModeEnabled) {
return;
}
const signature = JSON.stringify({
backend: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
adaptedBackend: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
fallback: fallbackReferenceBatches.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 ? "后端接口" : "前端兜底",
backendBatchCount: backendReferenceBatches.length,
backendSourceCounts: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
adaptedBackendSourceCounts: adaptedBackendReferenceBatches.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]);
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
const latestReferenceBatch =
referenceBatches.length > 0
? 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(
messages: Message[],
beforeMessageId?: string,
@@ -530,8 +731,7 @@ function buildTurnReferenceBatches(
beforeMessageId:
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
includeBeforeMessage: false,
includeModes: ["citation"],
}).filter((source) => source.mode === "citation");
}).filter((source) => source.mode !== "none");
if (sources.length > 0) {
batches.push({
id: assistant.id,
@@ -562,8 +762,7 @@ function findLastAssistantAnswer(
if (
message?.type === "ai" &&
message.id &&
hasAssistantAnswerContent(message) &&
!hasAssistantToolCalls(message)
hasAssistantAnswerContent(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 };
@@ -71,6 +71,7 @@ export function SourceCard({
score ? `相关度 ${score}` : null,
].filter(Boolean);
const isCitation = source.mode === "citation";
const showReferenceNumber = referenceTarget || isCitation;
return (
<>
@@ -79,14 +80,14 @@ export function SourceCard({
type="button"
className={cn(
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
"target:bg-primary/5 target:border-primary/60",
"target:border-amber-300 target:bg-amber-50/80",
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)}
>
<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">
{source.index}
</span>
@@ -122,7 +123,7 @@ export function SourceCard({
<div className="flex items-start justify-between gap-3 pr-8">
<div className="min-w-0 flex-1">
<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">
{source.index}
</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>
);
}
+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 {
for (const value of Object.values(args)) {
if (typeof value !== "string") continue;
@@ -164,9 +147,7 @@ export function collectRuntimeDisplaySources(
: null;
const sources: RuntimeDisplaySource[] = [];
let citationIndex = 0;
const displayIndexes = new Map<string, number>();
const seenCitationKeys = new Map<string, RuntimeDisplaySource>();
const seenDisplayKeys = new Set<string>();
const consumedToolMessageIds = new Set<string>();
const consumedToolMessages = new Set<Message>();
@@ -224,9 +205,7 @@ export function collectRuntimeDisplaySources(
mode: effectiveMode,
tableColumns: match?.display.tableColumns,
})),
displayIndexes,
seenCitationKeys,
seenDisplayKeys,
() => ++citationIndex,
);
}
@@ -247,7 +226,7 @@ export function collectRuntimeDisplaySources(
continue;
}
const toolName = extractToolMessageName(message);
const match = null;
const match = findDisplayConfigForPayload(toolName, result, displays);
const effectiveMode = match?.display.mode ?? "citation";
if (allowedModes && !allowedModes.has(effectiveMode)) {
continue;
@@ -266,9 +245,7 @@ export function collectRuntimeDisplaySources(
mode: effectiveMode,
tableColumns: match?.display.tableColumns,
})),
displayIndexes,
seenCitationKeys,
seenDisplayKeys,
() => ++citationIndex,
);
}
@@ -278,9 +255,7 @@ export function collectRuntimeDisplaySources(
function appendDisplaySources(
target: RuntimeDisplaySource[],
drafts: RuntimeDisplaySourceDraft[],
displayIndexes: Map<string, number>,
seenCitationKeys: Map<string, RuntimeDisplaySource>,
seenDisplayKeys: Set<string>,
nextCitationIndex: () => number,
) {
for (const draft of drafts) {
@@ -297,17 +272,9 @@ function appendDisplaySources(
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({
...draft,
index: displayIndex,
index: nextCitationIndex(),
});
}
}
@@ -323,9 +290,6 @@ export function reindexRuntimeDisplaySources(
globalSources.map((source) => [citationSourceKey(source), source]),
);
return sources.map((source) => {
if (source.mode !== "citation") {
return source;
}
const global = globalByKey.get(citationSourceKey(source));
return global
? {
@@ -361,10 +325,6 @@ function citationSourceKey(source: RuntimeDisplaySourceDraft): string {
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[] {
const calls: RuntimeToolCall[] = [];
const direct = (message as { tool_calls?: unknown }).tool_calls;
@@ -441,20 +401,10 @@ function normalizeToolArgs(value: unknown): Record<string, unknown> | undefined
export function findDisplayConfigForPayload(
toolName: string,
payload: unknown,
_payload: unknown,
displays: SkillDisplayMap,
): { skillName: string; display: SkillDisplayConfig } | null {
if (!canInspectPayloadForDisplay(toolName)) {
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;
return findDisplayConfigForTool(toolName, displays);
}
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 {
const value = path.split(".").reduce<unknown>((acc, key) => {
if (acc && typeof acc === "object") {
@@ -589,14 +512,14 @@ export function applyInlineCitations(
if (!content || sources.length === 0) {
return content;
}
const citationSources = sources.filter((source) => source.mode === "citation");
if (citationSources.length === 0) {
const referenceSources = sources.filter((source) => source.mode !== "none");
if (referenceSources.length === 0) {
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 byTitle = new Map<string, RuntimeDisplaySource>();
for (const source of citationSources) {
for (const source of referenceSources) {
if (source.url) {
byUrl.set(source.url, source);
}
@@ -610,7 +533,7 @@ export function applyInlineCitations(
byIndex,
byUrl,
byTitle,
citationSources,
referenceSources,
);
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
@@ -622,7 +545,7 @@ export function applyInlineCitations(
const replacedSlice = replaceCitationTokens(
normalized.slice(cursor, start),
byIndex,
citationSources,
referenceSources,
);
if (replacedSlice !== normalized.slice(cursor, start)) {
replaced = true;
@@ -631,7 +554,7 @@ export function applyInlineCitations(
next += match[0];
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)) {
replaced = true;
}
@@ -639,7 +562,7 @@ export function applyInlineCitations(
if (replaced || hasCitationMarkdown(next)) {
return next;
}
return appendCitationSummary(next, citationSources);
return appendCitationSummary(next, referenceSources);
}
function replaceCitationTokens(
+26 -15
View File
@@ -14,8 +14,8 @@ import { getStoredAuth } from "../auth";
import { getBackendBaseURL } from "../config";
import { useI18n } from "../i18n/hooks";
import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings";
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings";
import type { LocalSettings } from "../settings";
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings";
import { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
@@ -343,7 +343,7 @@ export function useThreadStream({
if (isThreadNotAccessibleError(message)) {
const userLabel = buildCurrentUserLabel();
toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
`当前登录用户为 ${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.
@@ -352,7 +352,18 @@ export function useThreadStream({
const target = agentMatch
? `/page/workspace/agents/${agentMatch[1]}/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;
}
toast.error(message);
@@ -555,13 +566,13 @@ export function useThreadStream({
}),
);
const ragModeEnabled =
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
await thread.submit(
const ragModeEnabled =
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
await thread.submit(
{
messages: [
{
@@ -604,10 +615,10 @@ export function useThreadStream({
? "low"
: undefined),
memory_injection_enabled: context.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled,
thread_id: threadId,
},
},
rag_mode_enabled: ragModeEnabled,
thread_id: threadId,
},
},
);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
} 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 ─────────────────────────────────────────────── */
@@ -19,16 +19,21 @@ from pathlib import Path
import uuid
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from langgraph.checkpoint.base import empty_checkpoint
from pydantic import BaseModel, Field, field_validator
from app.gateway.authz import require_permission
from app.gateway.deps import get_checkpointer
from app.gateway.utils import sanitize_log_param
from fastapi import APIRouter, HTTPException, Request
from langgraph.checkpoint.base import empty_checkpoint
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.utils import sanitize_log_param
from deerflow.config.extensions_config import SkillDisplayConfig
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.references import (
adapt_reference_batches_with_displays,
build_reference_batches as build_runtime_reference_batches,
build_reference_debug_summary,
)
@@ -81,11 +86,54 @@ def _rag_debug_file_path() -> str:
def _log_rag_debug(message: str, *args: Any) -> None:
logger.info(message, *args)
_get_rag_debug_logger().info(message, *args)
# ---------------------------------------------------------------------------
# Response / request models
# ---------------------------------------------------------------------------
def _json_text_loads(value: str | None) -> dict[str, Any] | None:
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):
@@ -187,6 +235,7 @@ class ReferenceSourceResponse(BaseModel):
url: str | None = None
content: str | None = None
score: float | None = None
tableColumns: list[dict[str, Any]] = Field(default_factory=list)
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=[])
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)
_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",
"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),
debug_summary["messages"],
debug_summary["turns"],
@@ -809,6 +862,7 @@ async def get_thread_reference_batches(thread_id: str, request: Request) -> Refe
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"]),
len(persisted_displays),
json.dumps(debug_summary["turns_detail"], ensure_ascii=False),
_rag_debug_file_path(),
)
@@ -3,7 +3,14 @@
from __future__ import annotations
import json
import re
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]]:
@@ -16,12 +23,10 @@ def build_reference_batches(messages: list[Any]) -> list[dict[str, Any]]:
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)
anchor = _find_reference_batch_anchor(messages, index + 1, end)
message_id = _message_id(anchor) or _message_id(message) or str(index)
batches.append(
{
"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 :])
if not sources:
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)
batch = {
"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]]:
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:
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
if message_type != "tool":
continue
tool_context = _resolve_tool_context(
message,
pending_tool_contexts,
tool_contexts_by_id,
)
payload = _extract_tool_payload(message)
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:
continue
normalized["index"] = len(sources) + 1
@@ -167,6 +270,17 @@ def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
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:
for index in range(end - 1, start - 1, -1):
candidate = messages[index]
@@ -174,7 +288,6 @@ def _find_last_assistant_answer(messages: list[Any], start: int, end: int) -> An
_message_type(candidate) == "ai"
and _message_id(candidate)
and _has_answer_content(candidate)
and not _has_tool_calls(candidate)
):
return candidate
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)
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:
if isinstance(message, dict):
direct = message.get("tool_calls")
@@ -221,6 +348,102 @@ def _has_answer_content(message: Any) -> bool:
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:
content = _message_content(message)
if isinstance(content, dict):
@@ -316,9 +539,167 @@ def _pick(layers: list[dict[str, Any]], keys: tuple[str, ...]) -> str | 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):
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)
title = _pick(layers, ("title", "m_title", "name", "question", "keyword", "query", "text"))
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 {
"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",
"title": title,
"type": "result",