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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 committed 2026-05-28 15:16:56 +08:00
1 parent f200972c00
commit 4410acaee8
11 files changed
+1153 -172

No files matched your search

@@ -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 };