feat: improve citation display and skill persistence

This commit is contained in:
吉鑫升 committed 2026-05-26 19:27:03 +08:00
1 parent b671b49725
commit 8b2f398015
12 files changed
+721 -136

No files matched your search

@@ -40,9 +40,11 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
const { thread } = useThread();
const pathname = usePathname();
const threadIdRef = useRef(threadId);
const artifactsCountRef = useRef(0);
const layoutRef = useRef<GroupImperativeHandle>(null);
const [sideTab, setSideTab] = useState("document");
const [referencesOpen, setReferencesOpen] = useState(true);
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
const [settings] = useLocalSettings();
const { skills } = useSkills();
@@ -61,24 +63,32 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[settings.display.ragModeEnabled, skills],
);
const latestHumanIndex = useMemo(
() => findLatestHumanMessageIndex(thread.values.messages ?? []),
[thread.values.messages],
);
const displaySources = useMemo(() => {
const referenceBatches = useMemo(() => {
if (!settings.display.ragModeEnabled) {
return [];
}
const messages = thread.values.messages ?? [];
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: latestHumanIndex >= 0 ? messages[latestHumanIndex]?.id : undefined,
includeModes: ["citation"],
});
}, [settings.display.ragModeEnabled, skillDisplayMap, latestHumanIndex, thread.values.messages]);
const referenceSources = useMemo(
() => displaySources.filter((source) => source.mode === "citation"),
[displaySources],
);
return messages
.filter((message) => message.type === "ai" && message.id)
.map((message) => ({
id: message.id!,
sources: collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, message.id),
beforeMessageId: message.id,
includeBeforeMessage: true,
includeModes: ["citation"],
}).filter((source) => source.mode === "citation"),
}))
.filter((batch) => batch.sources.length > 0);
}, [settings.display.ragModeEnabled, skillDisplayMap, thread.values.messages]);
const latestReferenceBatch =
referenceBatches.length > 0
? referenceBatches[referenceBatches.length - 1]!
: null;
const activeReferenceBatch =
referenceBatches.find((batch) => batch.id === activeReferenceBatchId) ??
latestReferenceBatch;
const referenceSources = activeReferenceBatch?.sources ?? [];
const hasCitationReferences = referenceSources.length > 0;
useEffect(() => {
if (hasCitationReferences) {
@@ -86,7 +96,22 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
}
}, [hasCitationReferences]);
useEffect(() => {
const handleShowReference = () => {
if (!latestReferenceBatch) {
setActiveReferenceBatchId(null);
return;
}
setActiveReferenceBatchId((current) =>
current && referenceBatches.some((batch) => batch.id === current)
? current
: latestReferenceBatch.id,
);
}, [latestReferenceBatch?.id, referenceBatches]);
useEffect(() => {
const handleShowReference = (event: Event) => {
const detail = (event as CustomEvent<{ batchId?: string }>).detail;
if (detail?.batchId) {
setActiveReferenceBatchId(detail.batchId);
}
setReferencesOpen(true);
setSideTab("references");
};
@@ -98,12 +123,22 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
useEffect(() => {
if (threadIdRef.current !== threadId) {
threadIdRef.current = threadId;
artifactsCountRef.current = 0;
deselect();
setReferencesOpen(true);
}
// Update artifacts from the current thread
setArtifacts(thread.values.artifacts);
const nextArtifacts = thread.values.artifacts ?? [];
const hadArtifacts = artifactsCountRef.current > 0;
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
artifactsCountRef.current = nextArtifacts.length;
setArtifacts(nextArtifacts);
if ((hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) && !selectedArtifact) {
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
setArtifactsOpen(true);
setSideTab("document");
}
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
// if (
@@ -142,10 +177,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
const showTabs = !!selectedArtifact && hasCitationReferences;
useEffect(() => {
if (!selectedArtifact && hasCitationReferences) {
setSideTab("references");
} else if (selectedArtifact && !hasCitationReferences) {
if (selectedArtifact) {
setSideTab("document");
} else if (hasCitationReferences) {
setSideTab("references");
}
}, [hasCitationReferences, selectedArtifact]);
@@ -315,13 +350,22 @@ function ReferencePanel({
);
}
function findLatestHumanMessageIndex(messages: Message[]) {
for (let index = messages.length - 1; index >= 0; index--) {
if (messages[index]?.type === "human") {
return index;
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
): string | undefined {
const beforeIndex =
beforeMessageId === undefined
? messages.length
: messages.findIndex((message) => message.id === beforeMessageId);
const end = beforeIndex < 0 ? messages.length : beforeIndex;
for (let index = end - 1; index >= 0; index--) {
const message = messages[index];
if (message?.type === "human") {
return message.id;
}
}
return -1;
return undefined;
}
export { ChatBox };
@@ -28,20 +28,19 @@ export function CitationLink({
)}
onClick={(event) => {
event.preventDefault();
const batchId =
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
?.dataset.referenceBatchId;
window.dispatchEvent(
new CustomEvent(SHOW_REFERENCE_EVENT, {
detail: { referenceId },
detail: { batchId, referenceId },
}),
);
const target = document.getElementById(`reference-${referenceId}`);
const scroll = () => {
const target = document.getElementById(`reference-${referenceId}`);
target?.scrollIntoView({ behavior: "smooth", block: "center" });
};
if (target) {
scroll();
} else {
window.setTimeout(scroll, 220);
}
window.setTimeout(scroll, 240);
}}
{...props}
>
@@ -51,7 +51,6 @@ import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
findDisplayConfigForPayload,
findDisplayConfigForTool,
isDisplaySamplePayload,
normalizeDefaultDisplaySources,
@@ -256,9 +255,7 @@ function ToolCall({
result !== undefined && isDisplaySamplePayload(result)
? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
(result !== undefined
? findDisplayConfigForPayload(name, result, skillDisplayMap)
: null));
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
@@ -288,19 +288,21 @@ function MessageContent_({
);
}
return (
<AIElementMessageContent className={className}>
{filesList}
<AssistantDisplayContent
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
components={components}
/>
<MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
return (
<AIElementMessageContent className={className}>
{filesList}
<div data-reference-batch-id={message.id} className="contents">
<AssistantDisplayContent
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
components={components}
/>
</div>
<MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
message={message}
/>
</AIElementMessageContent>
@@ -2,9 +2,11 @@
import { MoonIcon, SunIcon } from "lucide-react";
import { useMemo, type ComponentType, type SVGProps } from "react";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
@@ -14,9 +16,15 @@ import {
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings";
import {
useAppearanceSettings,
useUpdateAppearanceSettings,
} from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
@@ -31,6 +39,24 @@ export function AppearanceSettingsPage() {
const { theme, setTheme } = useTheme();
const currentTheme = theme;
const [settings, setSettings] = useLocalSettings();
const isAdmin = getStoredAuth()?.system_role === "admin";
const { data: appearance } = useAppearanceSettings();
const updateAppearance = useUpdateAppearanceSettings();
const globalDefault = appearance?.default_theme ?? "light";
const handleSetGlobalDefault = async (value: "light" | "dark") => {
if (value === globalDefault) return;
try {
await updateAppearance.mutateAsync({ default_theme: value });
toast.success(
value === "light"
? "已将系统默认主题设置为浅色"
: "已将系统默认主题设置为深色",
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
const themeOptions = useMemo(
() => [
@@ -76,6 +102,43 @@ export function AppearanceSettingsPage() {
</div>
</SettingsSection>
{isAdmin && (
<>
<Separator />
<SettingsSection
title="系统默认主题(管理员)"
description="给所有还没有手动切换过主题的用户的默认主题;已经自己切换过的用户保留其个人设置不受影响。"
>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={globalDefault === "light" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("light")}
disabled={updateAppearance.isPending}
>
<SunIcon className="mr-1.5 size-4" />
浅色
</Button>
<Button
type="button"
variant={globalDefault === "dark" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("dark")}
disabled={updateAppearance.isPending}
>
<MoonIcon className="mr-1.5 size-4" />
深色
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:
{globalDefault === "light" ? "浅色" : "深色"}
</p>
</div>
</SettingsSection>
</>
)}
<Separator />
<SettingsSection
@@ -124,26 +187,49 @@ export function AppearanceSettingsPage() {
}
aria-label="回答速度指示器"
/>
</div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">RAG 引用模式</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
</p>
</div>
<Switch
checked={settings.display.ragModeEnabled}
onCheckedChange={(checked) =>
setSettings("display", { ragModeEnabled: checked })
}
aria-label="RAG 引用模式"
/>
</div>
</SettingsSection>
</div>
);
}
</div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">RAG 引用模式</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
</p>
</div>
<Switch
checked={settings.display.ragModeEnabled}
onCheckedChange={(checked) =>
setSettings("display", { ragModeEnabled: checked })
}
aria-label="RAG 引用模式"
/>
</div>
{isAdmin && (
<div className="rounded-lg border p-4">
<div className="mb-3">
<div className="text-sm font-medium">参考文献去除词</div>
<p className="text-muted-foreground mt-1 max-w-2xl text-xs leading-relaxed">
管理员配置。每行一个词或短句,参考文献卡片和详情弹框会自动移除这些广告词、导航词或免责声明。
</p>
</div>
<Textarea
value={(settings.display.citationCleanupWords ?? []).join("\n")}
onChange={(event) =>
setSettings("display", {
citationCleanupWords: event.currentTarget.value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean),
})
}
placeholder={"广告\n点击下载APP\n打开客户端\n免责声明\n责任编辑"}
className="min-h-32 text-sm"
/>
</div>
)}
</SettingsSection>
</div>
);
}
function ThemePreviewCard({
icon: Icon,
@@ -22,6 +22,7 @@ import {
normalizeDisplaySources,
} from "@/core/skill-display/display-adapter";
import {
getSkill,
getPersistedSkillDisplaySample,
getSkillDisplaySample,
} from "@/core/skills/api";
@@ -76,6 +77,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample));
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
const [loadingDbConfig, setLoadingDbConfig] = useState(false);
// Echo server state into the form — on mount, on skill switch, AND after
// a successful save (the refetched skill object has a new `display` identity).
@@ -88,6 +90,41 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
}, [skill.name, skill.display]);
useEffect(() => {
let cancelled = false;
async function loadDbSkillConfig() {
setLoadingDbConfig(true);
try {
const fresh = await getSkill(skill.name);
if (cancelled) {
return;
}
const display = fresh.display ?? emptyDisplayConfig();
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
setSampleText(formatSample(fresh.displaySample));
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], fresh);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) => (item.name === skill.name ? { ...item, ...fresh } : item))
: current,
);
} catch (err) {
if (!cancelled) {
toast.error(err instanceof Error ? err.message : String(err));
}
} finally {
if (!cancelled) {
setLoadingDbConfig(false);
}
}
}
void loadDbSkillConfig();
return () => {
cancelled = true;
};
}, [queryClient, skill.name]);
// Sample text is editor-only state — never reset on save. Only seed it once
// per skill, otherwise the user loses the JSON they just pulled in via
// 自动识别 every time they hit 保存.
@@ -191,11 +228,15 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
async function saveDraft() {
try {
await setDisplay.mutateAsync({
const saved = await setDisplay.mutateAsync({
skillName: skill.name,
display: draft,
sample: samplePayload ?? undefined,
});
const display = saved.display ?? emptyDisplayConfig();
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
setSampleText(formatSample(saved.displaySample));
toast.success("展示适配已保存");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
@@ -222,6 +263,11 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
<p className="mt-1 text-xs text-muted-foreground">
从技能返回样例中自动识别字段,然后把技能结果显示为编号引用、列表、卡片或自定义样式。
</p>
{loadingDbConfig && (
<p className="mt-1 text-xs text-muted-foreground">
正在从数据库读取已保存的配置...
</p>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">启用</span>
@@ -9,6 +9,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import { useState } from "react";
@@ -44,7 +45,11 @@ export function SourceCard({
referenceTarget?: boolean;
}) {
const [open, setOpen] = useState(false);
const content = source.content || source.snippet;
const [settings] = useLocalSettings();
const cleanupWords = settings.display.citationCleanupWords ?? [];
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
const snippet = compactReferenceText(content).slice(0, 280);
const paragraphs = segmentReferenceText(content);
const score =
typeof source.score === "number"
? source.score <= 1
@@ -90,9 +95,9 @@ export function SourceCard({
))}
</div>
)}
{content && (
{snippet && (
<p className="text-muted-foreground/90 line-clamp-2 text-xs leading-relaxed">
{content}
{snippet}
</p>
)}
</div>
@@ -138,9 +143,13 @@ export function SourceCard({
)}
</div>
</DialogHeader>
{content && (
<div className="text-foreground max-h-[55vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
{content}
{paragraphs.length > 0 && (
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
{paragraphs.map((paragraph, index) => (
<p key={index} className="whitespace-pre-wrap">
{paragraph}
</p>
))}
</div>
)}
{source.url && (
@@ -153,3 +162,66 @@ export function SourceCard({
</>
);
}
function cleanReferenceText(
raw: string | undefined,
cleanupWords: string[],
): string {
let text = raw ?? "";
for (const word of cleanupWords) {
const trimmed = word.trim();
if (!trimmed) continue;
text = text.split(trimmed).join("");
}
return text
.replace(/\r\n/g, "\n")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function compactReferenceText(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
function segmentReferenceText(text: string): string[] {
const explicit = text
.split(/\n{2,}/)
.map((item) => item.trim())
.filter(Boolean);
const base = explicit.length > 1 ? explicit : splitLongParagraph(text);
return base.slice(0, 80);
}
function splitLongParagraph(text: string): string[] {
const normalized = text
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean)
.join("\n");
if (normalized.length <= 360) {
return normalized ? [normalized] : [];
}
const sentences = normalized
.split(/(?<=[。!?!?;;])\s*/)
.map((item) => item.trim())
.filter(Boolean);
if (sentences.length <= 1) {
return normalized.match(/[\s\S]{1,360}/g) ?? [];
}
const paragraphs: string[] = [];
let current = "";
for (const sentence of sentences) {
if ((current + sentence).length > 360 && current) {
paragraphs.push(current.trim());
current = "";
}
current += sentence;
}
if (current.trim()) {
paragraphs.push(current.trim());
}
return paragraphs;
}
+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] });
},
@@ -36,6 +36,7 @@ from deerflow.skills.types import SKILL_MD_FILE, SkillCategory
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["skills"])
_UNSET = object()
# ---------------------------------------------------------------------------
@@ -221,7 +222,7 @@ def _skill_to_response(
skill: Skill,
ownership: dict[str, Any] | None = None,
*,
display_override: dict[str, Any] | None = None,
display_override: dict[str, Any] | None | object = _UNSET,
display_sample: dict[str, Any] | None = None,
) -> SkillResponse:
"""Convert a Skill object to a SkillResponse.
@@ -252,8 +253,8 @@ def _skill_to_response(
builtin = bool(ownership.get("builtin", owner_user_id is None))
detail = ownership.get("detail")
display = display_override
if display is None:
display = None if display_override is _UNSET else display_override
if display_override is _UNSET:
try:
skill_config = get_extensions_config().skills.get(skill.name)
if skill_config and skill_config.display:
@@ -318,6 +319,10 @@ def _json_text_dumps(value: dict[str, Any] | None) -> str | None:
return json.dumps(value, ensure_ascii=False)
def _display_override_from_adapter(adapter: dict[str, Any] | None) -> dict[str, Any] | None | object:
return adapter.get("display") if adapter is not None else _UNSET
async def _get_skill_display_adapter(skill_name: str) -> dict[str, Any] | None:
if not await _ensure_skill_display_table():
return None
@@ -698,7 +703,7 @@ async def list_skills(
display_adapters = await _list_skill_display_adapters([skill.name for skill in all_skills])
responses: list[SkillResponse] = []
for skill in all_skills:
adapter = display_adapters.get(skill.name, {})
adapter = display_adapters.get(skill.name)
# Public/built-in skills (no DB row) are visible to everyone.
# Custom skills must have an ownership record in the visible set.
if skill.category != SkillCategory.CUSTOM:
@@ -708,8 +713,8 @@ async def list_skills(
_skill_to_response(
skill,
ownership.get(skill.name),
display_override=adapter.get("display"),
display_sample=adapter.get("sample"),
display_override=_display_override_from_adapter(adapter),
display_sample=adapter.get("sample") if adapter else None,
)
)
continue
@@ -720,8 +725,8 @@ async def list_skills(
_skill_to_response(
skill,
record,
display_override=adapter.get("display"),
display_sample=adapter.get("sample"),
display_override=_display_override_from_adapter(adapter),
display_sample=adapter.get("sample") if adapter else None,
)
)
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
@@ -984,16 +989,19 @@ async def list_custom_skills(
custom = [s for s in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM]
ownership = await _build_ownership_index(skill_store, user_id)
display_adapters = await _list_skill_display_adapters([skill.name for skill in custom])
responses = [
_skill_to_response(
skill,
ownership[skill.name],
display_override=display_adapters.get(skill.name, {}).get("display"),
display_sample=display_adapters.get(skill.name, {}).get("sample"),
responses = []
for skill in custom:
if skill.name not in ownership:
continue
adapter = display_adapters.get(skill.name)
responses.append(
_skill_to_response(
skill,
ownership[skill.name],
display_override=_display_override_from_adapter(adapter),
display_sample=adapter.get("sample") if adapter else None,
)
)
for skill in custom
if skill.name in ownership
]
return SkillsListResponse(skills=responses)
except Exception as e:
logger.error("Failed to list custom skills: %s", e, exc_info=True)
@@ -1008,7 +1016,15 @@ async def get_custom_skill(skill_name: str, config: AppConfig = Depends(get_conf
skill = next((s for s in skills if s.name == skill_name and s.category == SkillCategory.CUSTOM), None)
if skill is None:
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
return CustomSkillContentResponse(**_skill_to_response(skill).model_dump(), content=get_or_new_skill_storage(app_config=config).read_custom_skill(skill_name))
adapter = await _get_skill_display_adapter(skill_name)
return CustomSkillContentResponse(
**_skill_to_response(
skill,
display_override=_display_override_from_adapter(adapter),
display_sample=adapter.get("sample") if adapter else None,
).model_dump(),
content=get_or_new_skill_storage(app_config=config).read_custom_skill(skill_name),
)
except HTTPException:
raise
except Exception as e:
@@ -1365,7 +1381,13 @@ async def get_skill(
# Attach ownership (owner/published/detail) when the caller may see it.
ownership = await skill_store.get_visible(skill_name, _current_user_id(request))
return _skill_to_response(skill, ownership)
adapter = await _get_skill_display_adapter(skill_name)
return _skill_to_response(
skill,
ownership,
display_override=_display_override_from_adapter(adapter),
display_sample=adapter.get("sample") if adapter else None,
)
except HTTPException:
raise
except Exception as e:
@@ -1944,11 +1966,11 @@ async def update_skill_display(
updated_skill = next((s for s in skills if s.name == skill_name), None)
if updated_skill is None:
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after display update")
adapter = await _get_skill_display_adapter(skill_name) or {}
adapter = await _get_skill_display_adapter(skill_name)
return _skill_to_response(
updated_skill,
display_override=adapter.get("display"),
display_sample=adapter.get("sample"),
display_override=_display_override_from_adapter(adapter),
display_sample=adapter.get("sample") if adapter else None,
)
except HTTPException:
raise