技能展示配置持久化并适配引用来源,新增技能文件预览组件
- 后端将技能展示适配配置存入数据库,引用批次按持久化映射进行适配 - 前端新增技能文件预览弹窗与文件树组件,完善技能来源展示 - 修正 .gitignore 中宽泛的 skills/ 规则误忽略前端组件目录的问题 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f200972c00
commit
4410acaee8
11 files changed
+1153
-172
No files matched your search
+7
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -165,7 +165,7 @@ export function WorkspaceNavChatList({
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
{!showCollapseTrigger && <WorkspaceNavStudio />}
|
||||
{/* {!showCollapseTrigger && <WorkspaceNavStudio />} */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user