From 4410acaee8644ee3961273c24069562eaba1cad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Thu, 28 May 2026 15:16:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=80=E8=83=BD=E5=B1=95=E7=A4=BA=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=8C=81=E4=B9=85=E5=8C=96=E5=B9=B6=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E6=9D=A5=E6=BA=90,=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=8A=80=E8=83=BD=E6=96=87=E4=BB=B6=E9=A2=84=E8=A7=88=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端将技能展示适配配置存入数据库,引用批次按持久化映射进行适配 - 前端新增技能文件预览弹窗与文件树组件,完善技能来源展示 - 修正 .gitignore 中宽泛的 skills/ 规则误忽略前端组件目录的问题 Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 11 +- .../components/workspace/chats/chat-box.tsx | 231 +++++++++- .../workspace/skill-display-sources.tsx | 9 +- .../skills/skill-file-preview-dialog.tsx | 284 +++++++++++++ .../workspace/skills/skill-file-tree.tsx | 152 +++++++ .../workspace/workspace-nav-chat-list.tsx | 2 +- .../src/core/skill-display/runtime.ts | 101 +---- frontend-web/src/core/threads/hooks.ts | 41 +- frontend-web/src/styles/globals.css | 13 - .../backend/app/gateway/routers/threads.py | 80 +++- .../harness/deerflow/runtime/references.py | 401 +++++++++++++++++- 11 files changed, 1153 insertions(+), 172 deletions(-) create mode 100644 frontend-web/src/components/workspace/skills/skill-file-preview-dialog.tsx create mode 100644 frontend-web/src/components/workspace/skills/skill-file-tree.tsx diff --git a/.gitignore b/.gitignore index f6b1fff3..ba42257b 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index 20b6e434..2841cdfa 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -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, +): 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, +): BackendReferenceSource | null { + const raw = source.raw; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return null; + } + const rawItem = raw as Record; + 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, +) { + 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[]) { + const parts = path.split(".").map((part) => part.trim()).filter(Boolean); + if (parts.length === 0) { + return items; + } + const root: Record = {}; + 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 = {}; + 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) + : {}; + 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 }).additional_kwargs; - const extra = kwargs?.tool_calls; - return Array.isArray(extra) && extra.length > 0; -} - export { ChatBox }; diff --git a/frontend-web/src/components/workspace/skill-display-sources.tsx b/frontend-web/src/components/workspace/skill-display-sources.tsx index 080be3c0..9c940afe 100644 --- a/frontend-web/src/components/workspace/skill-display-sources.tsx +++ b/frontend-web/src/components/workspace/skill-display-sources.tsx @@ -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)} >
- {isCitation && ( + {showReferenceNumber && ( {source.index} @@ -122,7 +123,7 @@ export function SourceCard({
- {isCitation && ( + {showReferenceNumber && ( {source.index} diff --git a/frontend-web/src/components/workspace/skills/skill-file-preview-dialog.tsx b/frontend-web/src/components/workspace/skills/skill-file-preview-dialog.tsx new file mode 100644 index 00000000..000aa9ae --- /dev/null +++ b/frontend-web/src/components/workspace/skills/skill-file-preview-dialog.tsx @@ -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 ( + + + +
+
+ + {path ?? ""} + + + {skillName} + {data ? ` · ${formatBytes(data.size)} · ${data.mime}` : null} + +
+
+ {fileEditable && !editing && data?.is_text && ( + + )} + {editing && ( + <> + + + + )} + {!editing && ( + <> + + {downloadHref && ( + + )} + + )} + +
+
+
+ +
+ {isLoading ? ( +
+ + {t.common.loading} +
+ ) : error ? ( +
+ {error instanceof Error ? error.message : String(error)} +
+ ) : data == null ? null : editing ? ( +