feat: improve citation display and skill persistence

This commit is contained in:
吉鑫升 authored and xuhd committed 2026-05-28 18:28:45 +08:00
1 parent 61babbdf7d
commit 650102cedb
11 files changed
+706 -123

No files matched your search

+12 -6
View File
@@ -4,10 +4,11 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
notification: {
enabled: true,
},
display: {
streamSpeedMeterEnabled: true,
ragModeEnabled: false,
},
display: {
streamSpeedMeterEnabled: true,
ragModeEnabled: false,
citationCleanupWords: [],
},
context: {
model_name: undefined,
mode: undefined,
@@ -38,8 +39,13 @@ export interface LocalSettings {
* Global switch for skill result display adapters. When disabled, chat
* messages render tool results with the legacy generic search UI only.
*/
ragModeEnabled: boolean;
};
ragModeEnabled: boolean;
/**
* Admin-maintained words or short phrases removed from reference snippets
* and dialogs, one entry per line in the settings UI.
*/
citationCleanupWords: string[];
};
context: Omit<
AgentThreadContext,
| "thread_id"
@@ -1,5 +1,9 @@
import type { SkillDisplayConfig } from "@/core/skills/type";
const DEFAULT_CITATION_MAX_ITEMS = 100;
const DEFAULT_LIST_MAX_ITEMS = 20;
const MAX_DISPLAY_ITEMS = 100;
export interface DisplaySourceItem {
id?: string;
title: string;
@@ -29,7 +33,7 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
linkStrategy: "auto",
linkTemplate: null,
citationToken: "doubleBracket",
maxItems: 8,
maxItems: DEFAULT_CITATION_MAX_ITEMS,
customInstruction: "",
tableColumns: [],
});
@@ -92,7 +96,7 @@ export function inferDisplayConfig(samplePayload: unknown, skillName?: string):
inferred.enabled = true;
inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list";
inferred.resultPath = found.path || "results";
inferred.maxItems = inferred.mode === "citation" ? 8 : 20;
inferred.maxItems = inferMaxItems(found.items.length, inferred.mode);
for (const [target, candidates] of Object.entries(candidateFields)) {
(inferred as unknown as Record<string, string | null>)[target] = pickField(sample, candidates);
@@ -159,9 +163,10 @@ function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig):
export function normalizeDisplaySources(payload: unknown, config: SkillDisplayConfig): DisplaySourceItem[] {
const rawItems = getByPath(payload, config.resultPath);
if (!Array.isArray(rawItems)) return [];
const maxItems = resolveMaxItems(config, rawItems.length);
return rawItems
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
.slice(0, config.maxItems)
.slice(0, maxItems)
.map((item, index) => ({
id: config.idField ? stringify(item[config.idField]) : undefined,
title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`,
@@ -178,3 +183,26 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
raw: item,
}));
}
function inferMaxItems(
itemCount: number,
mode: SkillDisplayConfig["mode"],
): number {
const fallback =
mode === "citation" ? DEFAULT_CITATION_MAX_ITEMS : DEFAULT_LIST_MAX_ITEMS;
return Math.min(
MAX_DISPLAY_ITEMS,
Math.max(fallback, itemCount || fallback),
);
}
function resolveMaxItems(config: SkillDisplayConfig, itemCount: number): number {
const configured = Number(config.maxItems) || 0;
if (config.mode === "citation" && configured <= 8 && itemCount > configured) {
return Math.min(itemCount, MAX_DISPLAY_ITEMS);
}
return Math.min(
MAX_DISPLAY_ITEMS,
Math.max(1, configured || inferMaxItems(itemCount, config.mode)),
);
}
+305 -28
View File
@@ -15,6 +15,8 @@ export type RuntimeDisplaySource = DisplaySourceItem & {
tableColumns?: SkillDisplayConfig["tableColumns"];
};
type RuntimeDisplaySourceDraft = Omit<RuntimeDisplaySource, "index">;
export type SkillDisplayMap = Map<string, SkillDisplayConfig>;
type RuntimeToolCall = {
@@ -163,6 +165,9 @@ export function collectRuntimeDisplaySources(
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>();
for (let index = start; index < limit; index++) {
const message = messages[index];
@@ -179,16 +184,14 @@ export function collectRuntimeDisplaySources(
if (result === undefined) {
continue;
}
const linkedToolMessage = findToolMessageById(toolCall.id, messages);
if (linkedToolMessage?.id) {
consumedToolMessageIds.add(linkedToolMessage.id);
}
if (isDisplaySamplePayload(result)) {
continue;
}
const match =
toolMatch ??
findDisplayConfigForPayload(
toolCall.name,
result,
displays,
);
const match = toolMatch;
const effectiveMode = match?.display.mode ?? "citation";
if (allowedModes && !allowedModes.has(effectiveMode)) {
continue;
@@ -202,24 +205,144 @@ export function collectRuntimeDisplaySources(
)
? normalizeDefaultDisplaySources(result)
: [];
for (const item of normalized) {
const skillName = match?.skillName ?? toolCall.name;
const displayKey = `${skillName}:${effectiveMode}`;
const displayIndex = (displayIndexes.get(displayKey) ?? 0) + 1;
displayIndexes.set(displayKey, displayIndex);
sources.push({
appendDisplaySources(
sources,
normalized.map((item) => ({
...item,
index: effectiveMode === "citation" ? ++citationIndex : displayIndex,
skillName,
skillName: match?.skillName ?? toolCall.name,
mode: effectiveMode,
tableColumns: match?.display.tableColumns,
});
}
})),
displayIndexes,
seenCitationKeys,
seenDisplayKeys,
() => ++citationIndex,
);
}
}
for (let index = start; index < limit; index++) {
const message = messages[index];
if (!message || message.type !== "tool" || consumedToolMessageIds.has(message.id ?? "")) {
continue;
}
const result = parseToolMessagePayload(message);
if (result === undefined || isDisplaySamplePayload(result)) {
continue;
}
const toolName = extractToolMessageName(message);
const match = null;
const effectiveMode = match?.display.mode ?? "citation";
if (allowedModes && !allowedModes.has(effectiveMode)) {
continue;
}
const normalized = match
? normalizeDisplaySources(result, match.display)
: shouldUseDefaultCitationMode(toolName, result)
? normalizeDefaultDisplaySources(result)
: [];
appendDisplaySources(
sources,
normalized.map((item) => ({
...item,
skillName: match?.skillName ?? toolName,
mode: effectiveMode,
tableColumns: match?.display.tableColumns,
})),
displayIndexes,
seenCitationKeys,
seenDisplayKeys,
() => ++citationIndex,
);
}
return sources;
}
function appendDisplaySources(
target: RuntimeDisplaySource[],
drafts: RuntimeDisplaySourceDraft[],
displayIndexes: Map<string, number>,
seenCitationKeys: Map<string, RuntimeDisplaySource>,
seenDisplayKeys: Set<string>,
nextCitationIndex: () => number,
) {
for (const draft of drafts) {
if (draft.mode === "citation") {
const key = citationSourceKey(draft);
const existing = seenCitationKeys.get(key);
if (existing) {
continue;
}
const source = {
...draft,
index: nextCitationIndex(),
};
seenCitationKeys.set(key, source);
target.push(source);
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,
});
}
}
export function reindexRuntimeDisplaySources(
sources: RuntimeDisplaySource[],
globalSources: RuntimeDisplaySource[],
): RuntimeDisplaySource[] {
if (sources.length === 0 || globalSources.length === 0) {
return sources;
}
const globalByKey = new Map(
globalSources.map((source) => [citationSourceKey(source), source]),
);
return sources.map((source) => {
if (source.mode !== "citation") {
return source;
}
const global = globalByKey.get(citationSourceKey(source));
return global
? {
...source,
index: global.index,
}
: source;
});
}
function citationSourceKey(source: RuntimeDisplaySourceDraft): string {
const raw = source.raw ?? {};
const id =
source.id ||
source.url ||
stringifyMaybe(raw.recUuid) ||
stringifyMaybe(raw.uuid) ||
stringifyMaybe(raw.docId) ||
stringifyMaybe(raw.id);
if (id) {
return normalizeName(`${source.skillName}:${id}`);
}
const fallback = [source.skillName, source.title, source.source, source.time]
.filter(Boolean)
.join("|");
return normalizeName(fallback) || 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;
@@ -312,6 +435,18 @@ export function findDisplayConfigForPayload(
return best ? { skillName: best.skillName, display: best.display } : null;
}
export function isUnconfiguredSkillInvocation(
toolName: string,
displays: SkillDisplayMap | undefined,
toolArgs?: Record<string, unknown>,
): boolean {
if (!toolArgs || !canInferSkillFromToolArgs(toolName)) {
return false;
}
const skillName = extractSkillNameFromArgs(toolArgs);
return !!skillName && !displays?.has(skillName);
}
export function isDisplaySamplePayload(payload: unknown): boolean {
if (!isRecord(payload)) {
return false;
@@ -443,31 +578,59 @@ export function applyInlineCitations(
}
}
const normalized = normalizeExistingCitationLinks(content, byIndex, byUrl, byTitle);
const normalized = normalizeExistingCitationLinks(
content,
byIndex,
byUrl,
byTitle,
citationSources,
);
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
let cursor = 0;
let next = "";
let replaced = false;
for (const match of normalized.matchAll(markdownLinkPattern)) {
const start = match.index ?? 0;
next += replaceCitationTokens(normalized.slice(cursor, start), byIndex);
const replacedSlice = replaceCitationTokens(
normalized.slice(cursor, start),
byIndex,
citationSources,
);
if (replacedSlice !== normalized.slice(cursor, start)) {
replaced = true;
}
next += replacedSlice;
next += match[0];
cursor = start + match[0].length;
}
next += replaceCitationTokens(normalized.slice(cursor), byIndex);
return next;
const tail = replaceCitationTokens(normalized.slice(cursor), byIndex, citationSources);
if (tail !== normalized.slice(cursor)) {
replaced = true;
}
next += tail;
if (replaced || hasCitationMarkdown(next)) {
return next;
}
return appendCitationSummary(next, citationSources);
}
function replaceCitationTokens(
content: string,
byIndex: Map<number, RuntimeDisplaySource>,
orderedSources: RuntimeDisplaySource[],
): string {
return content
.replace(/\[\[(\d+)\]\]/g, (match, rawIndex: string) =>
citationMarkdown(match, Number(rawIndex), byIndex),
citationMarkdown(match, Number(rawIndex), byIndex, orderedSources),
)
.replace(/(^|[^:\[!])\[(\d+)\](?!\()/g, (match, prefix: string, rawIndex: string) =>
`${prefix}${citationMarkdown(match.slice(prefix.length), Number(rawIndex), byIndex)}`,
`${prefix}${citationMarkdown(
match.slice(prefix.length),
Number(rawIndex),
byIndex,
orderedSources,
)}`,
);
}
@@ -476,6 +639,7 @@ function normalizeExistingCitationLinks(
byIndex: Map<number, RuntimeDisplaySource>,
byUrl: Map<string, RuntimeDisplaySource>,
byTitle: Map<string, RuntimeDisplaySource>,
orderedSources: RuntimeDisplaySource[],
): string {
// Resolve every `[citation:LABEL](URL)` link — whether LABEL is a number
// ("[1]", "1") or a free-form title — to a numbered reference link that
@@ -488,8 +652,9 @@ function normalizeExistingCitationLinks(
const numberMatch = label.match(/^\[?(\d+)\]?$/);
if (numberMatch) {
const index = Number(numberMatch[1]);
if (byIndex.has(index)) {
return `[citation:[${index}]](#reference-${index})`;
const source = byIndex.get(index) ?? orderedSources[index - 1];
if (source) {
return `[citation:[${source.index}]](#reference-${source.index})`;
}
}
const sourceByUrl = byUrl.get(url);
@@ -509,12 +674,32 @@ function citationMarkdown(
fallback: string,
index: number,
byIndex: Map<number, RuntimeDisplaySource>,
orderedSources: RuntimeDisplaySource[],
): string {
const source = byIndex.get(index);
const source = byIndex.get(index) ?? orderedSources[index - 1];
if (!source) {
return fallback;
}
return `[citation:[${index}]](#reference-${index})`;
return `[citation:[${source.index}]](#reference-${source.index})`;
}
function hasCitationMarkdown(content: string): boolean {
return /\[citation:\[[^\]]+\]\]\(#reference-[^)]+\)/.test(content);
}
function appendCitationSummary(
content: string,
citationSources: RuntimeDisplaySource[],
): string {
const citations = citationSources
.slice(0, 5)
.map((source) => `[citation:[${source.index}]](#reference-${source.index})`)
.join(" ");
if (!citations) {
return content;
}
const trimmed = content.trimEnd();
return `${trimmed}${trimmed ? "\n\n" : ""}参考来源:${citations}`;
}
function normalizeName(name: string): string {
@@ -540,6 +725,10 @@ function findToolResultPayload(
if (!toolMessage) {
return undefined;
}
return parseToolMessagePayload(toolMessage);
}
function parseToolMessagePayload(toolMessage: Message): unknown {
const structured = extractStructuredToolPayload(toolMessage.content);
if (structured !== undefined) {
return structured;
@@ -555,6 +744,17 @@ function findToolResultPayload(
return content;
}
function extractToolMessageName(message: Message): string {
return (
stringifyMaybe((message as { name?: unknown }).name) ??
stringifyMaybe(
(message as { additional_kwargs?: Record<string, unknown> }).additional_kwargs
?.name,
) ??
"tool"
);
}
function findToolMessageById(
toolCallId: string | undefined,
messages: Message[],
@@ -677,7 +877,7 @@ function parseJSONLikeText(text: string): unknown {
try {
return JSON.parse(trimmed) as unknown;
} catch {
return extractJSONFromText(trimmed);
return extractJSONFromText(trimmed) ?? extractSearchPayloadFromText(trimmed);
}
}
@@ -700,6 +900,83 @@ function extractJSONFromText(text: string): unknown {
return undefined;
}
function extractSearchPayloadFromText(text: string): unknown {
if (!text.includes('"results"') || !text.includes('"recUuid"')) {
return undefined;
}
const results: Record<string, unknown>[] = [];
const objectPattern = /\{\s*"index"\s*:\s*\d+[\s\S]*?\n\s*\}/g;
for (const match of text.matchAll(objectPattern)) {
try {
const item = JSON.parse(match[0]) as unknown;
if (isRecord(item) && item.recUuid) {
results.push(item);
}
} catch {
const item = extractSearchItemFromText(match[0]);
if (item) {
results.push(item);
}
}
}
if (results.length === 0) {
return undefined;
}
return {
success: true,
query: extractStringField(text, "query") ?? "",
count: results.length,
source: extractStringField(text, "source") ?? "tool-output",
results,
};
}
function extractSearchItemFromText(text: string): Record<string, unknown> | null {
const recUuid = extractStringField(text, "recUuid");
if (!recUuid) {
return null;
}
const score = extractNumberField(text, "relevance_score");
return {
index: extractNumberField(text, "index") ?? undefined,
recUuid,
relevance_score: score,
title: extractStringField(text, "title") ?? recUuid,
source_category: extractStringField(text, "source_category") ?? "",
source: extractStringField(text, "source") ?? "",
publish_time: extractStringField(text, "publish_time") ?? "",
content_preview: extractStringField(text, "content_preview") ?? "",
content_length: extractNumberField(text, "content_length") ?? undefined,
};
}
function extractStringField(text: string, field: string): string | undefined {
const pattern = new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`);
const match = pattern.exec(text);
if (!match) {
return undefined;
}
try {
return JSON.parse(`"${match[1]}"`) as string;
} catch {
return match[1];
}
}
function extractNumberField(text: string, field: string): number | undefined {
const pattern = new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`);
const match = pattern.exec(text);
if (!match) {
return undefined;
}
const value = Number(match[1]);
return Number.isFinite(value) ? value : undefined;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function extractToolText(content: unknown): string {
if (typeof content === "string") {
return content.trim();
+8 -2
View File
@@ -22,7 +22,7 @@ import {
setSkillState,
validateSkillUpload,
} from "./api";
import type { SkillState } from "./type";
import type { Skill, SkillState } from "./type";
import { loadSkills } from ".";
@@ -77,7 +77,13 @@ export function useSetSkillDisplay() {
display: Parameters<typeof setSkillDisplay>[1];
sample?: Parameters<typeof setSkillDisplay>[2];
}) => setSkillDisplay(skillName, display, sample),
onSuccess: (_data, { skillName }) => {
onSuccess: (data, { skillName }) => {
queryClient.setQueryData(["skill", skillName], data);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
: current,
);
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},