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 { thread } = useThread();
const pathname = usePathname(); const pathname = usePathname();
const threadIdRef = useRef(threadId); const threadIdRef = useRef(threadId);
const artifactsCountRef = useRef(0);
const layoutRef = useRef<GroupImperativeHandle>(null); const layoutRef = useRef<GroupImperativeHandle>(null);
const [sideTab, setSideTab] = useState("document"); const [sideTab, setSideTab] = useState("document");
const [referencesOpen, setReferencesOpen] = useState(true); const [referencesOpen, setReferencesOpen] = useState(true);
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
const { skills } = useSkills(); 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 ? buildSkillDisplayMap(skills) : new Map()),
[settings.display.ragModeEnabled, skills], [settings.display.ragModeEnabled, skills],
); );
const latestHumanIndex = useMemo( const referenceBatches = useMemo(() => {
() => findLatestHumanMessageIndex(thread.values.messages ?? []),
[thread.values.messages],
);
const displaySources = useMemo(() => {
if (!settings.display.ragModeEnabled) { if (!settings.display.ragModeEnabled) {
return []; return [];
} }
const messages = thread.values.messages ?? []; const messages = thread.values.messages ?? [];
return collectRuntimeDisplaySources(messages, skillDisplayMap, { return messages
afterMessageId: latestHumanIndex >= 0 ? messages[latestHumanIndex]?.id : undefined, .filter((message) => message.type === "ai" && message.id)
includeModes: ["citation"], .map((message) => ({
}); id: message.id!,
}, [settings.display.ragModeEnabled, skillDisplayMap, latestHumanIndex, thread.values.messages]); sources: collectRuntimeDisplaySources(messages, skillDisplayMap, {
const referenceSources = useMemo( afterMessageId: findPreviousHumanMessageId(messages, message.id),
() => displaySources.filter((source) => source.mode === "citation"), beforeMessageId: message.id,
[displaySources], 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; const hasCitationReferences = referenceSources.length > 0;
useEffect(() => { useEffect(() => {
if (hasCitationReferences) { if (hasCitationReferences) {
@@ -86,7 +96,22 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
} }
}, [hasCitationReferences]); }, [hasCitationReferences]);
useEffect(() => { 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); setReferencesOpen(true);
setSideTab("references"); setSideTab("references");
}; };
@@ -98,12 +123,22 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
useEffect(() => { useEffect(() => {
if (threadIdRef.current !== threadId) { if (threadIdRef.current !== threadId) {
threadIdRef.current = threadId; threadIdRef.current = threadId;
artifactsCountRef.current = 0;
deselect(); deselect();
setReferencesOpen(true); setReferencesOpen(true);
} }
// Update artifacts from the current thread // 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. // DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
// if ( // if (
@@ -142,10 +177,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
const showTabs = !!selectedArtifact && hasCitationReferences; const showTabs = !!selectedArtifact && hasCitationReferences;
useEffect(() => { useEffect(() => {
if (!selectedArtifact && hasCitationReferences) { if (selectedArtifact) {
setSideTab("references");
} else if (selectedArtifact && !hasCitationReferences) {
setSideTab("document"); setSideTab("document");
} else if (hasCitationReferences) {
setSideTab("references");
} }
}, [hasCitationReferences, selectedArtifact]); }, [hasCitationReferences, selectedArtifact]);
@@ -315,13 +350,22 @@ function ReferencePanel({
); );
} }
function findLatestHumanMessageIndex(messages: Message[]) { function findPreviousHumanMessageId(
for (let index = messages.length - 1; index >= 0; index--) { messages: Message[],
if (messages[index]?.type === "human") { beforeMessageId?: string,
return index; ): 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 }; export { ChatBox };
@@ -28,20 +28,19 @@ export function CitationLink({
)} )}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
const batchId =
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
?.dataset.referenceBatchId;
window.dispatchEvent( window.dispatchEvent(
new CustomEvent(SHOW_REFERENCE_EVENT, { new CustomEvent(SHOW_REFERENCE_EVENT, {
detail: { referenceId }, detail: { batchId, referenceId },
}), }),
); );
const target = document.getElementById(`reference-${referenceId}`);
const scroll = () => { const scroll = () => {
const target = document.getElementById(`reference-${referenceId}`);
target?.scrollIntoView({ behavior: "smooth", block: "center" }); target?.scrollIntoView({ behavior: "smooth", block: "center" });
}; };
if (target) { window.setTimeout(scroll, 240);
scroll();
} else {
window.setTimeout(scroll, 220);
}
}} }}
{...props} {...props}
> >
@@ -51,7 +51,6 @@ import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { import {
buildSkillDisplayMap, buildSkillDisplayMap,
findDisplayConfigForPayload,
findDisplayConfigForTool, findDisplayConfigForTool,
isDisplaySamplePayload, isDisplaySamplePayload,
normalizeDefaultDisplaySources, normalizeDefaultDisplaySources,
@@ -256,9 +255,7 @@ function ToolCall({
result !== undefined && isDisplaySamplePayload(result) result !== undefined && isDisplaySamplePayload(result)
? null ? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ?? : (findDisplayConfigForTool(name, skillDisplayMap, args) ??
(result !== undefined null);
? findDisplayConfigForPayload(name, result, skillDisplayMap)
: null));
const displayItems = const displayItems =
displayMatch && result !== undefined displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({ ? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
@@ -288,19 +288,21 @@ function MessageContent_({
); );
} }
return ( return (
<AIElementMessageContent className={className}> <AIElementMessageContent className={className}>
{filesList} {filesList}
<AssistantDisplayContent <div data-reference-batch-id={message.id} className="contents">
content={contentToDisplay} <AssistantDisplayContent
displaySources={displaySources} content={contentToDisplay}
isLoading={isLoading} displaySources={displaySources}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]} isLoading={isLoading}
components={components} rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
/> components={components}
<MessageTokenUsage />
enabled={tokenUsageEnabled} </div>
isLoading={isLoading} <MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
message={message} message={message}
/> />
</AIElementMessageContent> </AIElementMessageContent>
@@ -2,9 +2,11 @@
import { MoonIcon, SunIcon } from "lucide-react"; import { MoonIcon, SunIcon } from "lucide-react";
import { useMemo, type ComponentType, type SVGProps } from "react"; import { useMemo, type ComponentType, type SVGProps } from "react";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { Button } from "@/components/ui/button";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -14,9 +16,15 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch"; 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 { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import {
useAppearanceSettings,
useUpdateAppearanceSettings,
} from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section"; import { SettingsSection } from "./settings-section";
@@ -31,6 +39,24 @@ export function AppearanceSettingsPage() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const currentTheme = theme; const currentTheme = theme;
const [settings, setSettings] = useLocalSettings(); 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( const themeOptions = useMemo(
() => [ () => [
@@ -76,6 +102,43 @@ export function AppearanceSettingsPage() {
</div> </div>
</SettingsSection> </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 /> <Separator />
<SettingsSection <SettingsSection
@@ -124,26 +187,49 @@ export function AppearanceSettingsPage() {
} }
aria-label="回答速度指示器" aria-label="回答速度指示器"
/> />
</div> </div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4"> <div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div> <div>
<div className="text-sm font-medium">RAG 引用模式</div> <div className="text-sm font-medium">RAG 引用模式</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed"> <p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。 开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
</p> </p>
</div> </div>
<Switch <Switch
checked={settings.display.ragModeEnabled} checked={settings.display.ragModeEnabled}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setSettings("display", { ragModeEnabled: checked }) setSettings("display", { ragModeEnabled: checked })
} }
aria-label="RAG 引用模式" aria-label="RAG 引用模式"
/> />
</div> </div>
</SettingsSection> {isAdmin && (
</div> <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({ function ThemePreviewCard({
icon: Icon, icon: Icon,
@@ -22,6 +22,7 @@ import {
normalizeDisplaySources, normalizeDisplaySources,
} from "@/core/skill-display/display-adapter"; } from "@/core/skill-display/display-adapter";
import { import {
getSkill,
getPersistedSkillDisplaySample, getPersistedSkillDisplaySample,
getSkillDisplaySample, getSkillDisplaySample,
} from "@/core/skills/api"; } from "@/core/skills/api";
@@ -76,6 +77,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig()); const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample)); const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample));
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2)); 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 // 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). // 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)); setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
}, [skill.name, skill.display]); }, [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 // 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 // per skill, otherwise the user loses the JSON they just pulled in via
// 自动识别 every time they hit 保存. // 自动识别 every time they hit 保存.
@@ -191,11 +228,15 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
async function saveDraft() { async function saveDraft() {
try { try {
await setDisplay.mutateAsync({ const saved = await setDisplay.mutateAsync({
skillName: skill.name, skillName: skill.name,
display: draft, display: draft,
sample: samplePayload ?? undefined, sample: samplePayload ?? undefined,
}); });
const display = saved.display ?? emptyDisplayConfig();
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
setSampleText(formatSample(saved.displaySample));
toast.success("展示适配已保存"); toast.success("展示适配已保存");
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : String(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 className="mt-1 text-xs text-muted-foreground">
从技能返回样例中自动识别字段,然后把技能结果显示为编号引用、列表、卡片或自定义样式。 从技能返回样例中自动识别字段,然后把技能结果显示为编号引用、列表、卡片或自定义样式。
</p> </p>
{loadingDbConfig && (
<p className="mt-1 text-xs text-muted-foreground">
正在从数据库读取已保存的配置...
</p>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">启用</span> <span className="text-xs text-muted-foreground">启用</span>
@@ -9,6 +9,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime"; import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useState } from "react"; import { useState } from "react";
@@ -44,7 +45,11 @@ export function SourceCard({
referenceTarget?: boolean; referenceTarget?: boolean;
}) { }) {
const [open, setOpen] = useState(false); 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 = const score =
typeof source.score === "number" typeof source.score === "number"
? source.score <= 1 ? source.score <= 1
@@ -90,9 +95,9 @@ export function SourceCard({
))} ))}
</div> </div>
)} )}
{content && ( {snippet && (
<p className="text-muted-foreground/90 line-clamp-2 text-xs leading-relaxed"> <p className="text-muted-foreground/90 line-clamp-2 text-xs leading-relaxed">
{content} {snippet}
</p> </p>
)} )}
</div> </div>
@@ -138,9 +143,13 @@ export function SourceCard({
)} )}
</div> </div>
</DialogHeader> </DialogHeader>
{content && ( {paragraphs.length > 0 && (
<div className="text-foreground max-h-[55vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed"> <div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
{content} {paragraphs.map((paragraph, index) => (
<p key={index} className="whitespace-pre-wrap">
{paragraph}
</p>
))}
</div> </div>
)} )}
{source.url && ( {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: { notification: {
enabled: true, enabled: true,
}, },
display: { display: {
streamSpeedMeterEnabled: true, streamSpeedMeterEnabled: true,
ragModeEnabled: false, ragModeEnabled: false,
}, citationCleanupWords: [],
},
context: { context: {
model_name: undefined, model_name: undefined,
mode: undefined, mode: undefined,
@@ -38,8 +39,13 @@ export interface LocalSettings {
* Global switch for skill result display adapters. When disabled, chat * Global switch for skill result display adapters. When disabled, chat
* messages render tool results with the legacy generic search UI only. * 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< context: Omit<
AgentThreadContext, AgentThreadContext,
| "thread_id" | "thread_id"
@@ -1,5 +1,9 @@
import type { SkillDisplayConfig } from "@/core/skills/type"; 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 { export interface DisplaySourceItem {
id?: string; id?: string;
title: string; title: string;
@@ -29,7 +33,7 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
linkStrategy: "auto", linkStrategy: "auto",
linkTemplate: null, linkTemplate: null,
citationToken: "doubleBracket", citationToken: "doubleBracket",
maxItems: 8, maxItems: DEFAULT_CITATION_MAX_ITEMS,
customInstruction: "", customInstruction: "",
tableColumns: [], tableColumns: [],
}); });
@@ -92,7 +96,7 @@ export function inferDisplayConfig(samplePayload: unknown, skillName?: string):
inferred.enabled = true; inferred.enabled = true;
inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list"; inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list";
inferred.resultPath = found.path || "results"; 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)) { for (const [target, candidates] of Object.entries(candidateFields)) {
(inferred as unknown as Record<string, string | null>)[target] = pickField(sample, candidates); (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[] { export function normalizeDisplaySources(payload: unknown, config: SkillDisplayConfig): DisplaySourceItem[] {
const rawItems = getByPath(payload, config.resultPath); const rawItems = getByPath(payload, config.resultPath);
if (!Array.isArray(rawItems)) return []; if (!Array.isArray(rawItems)) return [];
const maxItems = resolveMaxItems(config, rawItems.length);
return rawItems return rawItems
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object") .filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
.slice(0, config.maxItems) .slice(0, maxItems)
.map((item, index) => ({ .map((item, index) => ({
id: config.idField ? stringify(item[config.idField]) : undefined, id: config.idField ? stringify(item[config.idField]) : undefined,
title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`, title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`,
@@ -178,3 +183,26 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
raw: item, 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"]; tableColumns?: SkillDisplayConfig["tableColumns"];
}; };
type RuntimeDisplaySourceDraft = Omit<RuntimeDisplaySource, "index">;
export type SkillDisplayMap = Map<string, SkillDisplayConfig>; export type SkillDisplayMap = Map<string, SkillDisplayConfig>;
type RuntimeToolCall = { type RuntimeToolCall = {
@@ -163,6 +165,9 @@ export function collectRuntimeDisplaySources(
const sources: RuntimeDisplaySource[] = []; const sources: RuntimeDisplaySource[] = [];
let citationIndex = 0; let citationIndex = 0;
const displayIndexes = new Map<string, number>(); 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++) { for (let index = start; index < limit; index++) {
const message = messages[index]; const message = messages[index];
@@ -179,16 +184,14 @@ export function collectRuntimeDisplaySources(
if (result === undefined) { if (result === undefined) {
continue; continue;
} }
const linkedToolMessage = findToolMessageById(toolCall.id, messages);
if (linkedToolMessage?.id) {
consumedToolMessageIds.add(linkedToolMessage.id);
}
if (isDisplaySamplePayload(result)) { if (isDisplaySamplePayload(result)) {
continue; continue;
} }
const match = const match = toolMatch;
toolMatch ??
findDisplayConfigForPayload(
toolCall.name,
result,
displays,
);
const effectiveMode = match?.display.mode ?? "citation"; const effectiveMode = match?.display.mode ?? "citation";
if (allowedModes && !allowedModes.has(effectiveMode)) { if (allowedModes && !allowedModes.has(effectiveMode)) {
continue; continue;
@@ -202,24 +205,144 @@ export function collectRuntimeDisplaySources(
) )
? normalizeDefaultDisplaySources(result) ? normalizeDefaultDisplaySources(result)
: []; : [];
for (const item of normalized) { appendDisplaySources(
const skillName = match?.skillName ?? toolCall.name; sources,
const displayKey = `${skillName}:${effectiveMode}`; normalized.map((item) => ({
const displayIndex = (displayIndexes.get(displayKey) ?? 0) + 1;
displayIndexes.set(displayKey, displayIndex);
sources.push({
...item, ...item,
index: effectiveMode === "citation" ? ++citationIndex : displayIndex, skillName: match?.skillName ?? toolCall.name,
skillName,
mode: effectiveMode, mode: effectiveMode,
tableColumns: match?.display.tableColumns, 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; 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[] { function extractRuntimeToolCalls(message: Message): RuntimeToolCall[] {
const calls: RuntimeToolCall[] = []; const calls: RuntimeToolCall[] = [];
const direct = (message as { tool_calls?: unknown }).tool_calls; 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; 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 { export function isDisplaySamplePayload(payload: unknown): boolean {
if (!isRecord(payload)) { if (!isRecord(payload)) {
return false; 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; const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
let cursor = 0; let cursor = 0;
let next = ""; let next = "";
let replaced = false;
for (const match of normalized.matchAll(markdownLinkPattern)) { for (const match of normalized.matchAll(markdownLinkPattern)) {
const start = match.index ?? 0; 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]; next += match[0];
cursor = start + match[0].length; cursor = start + match[0].length;
} }
next += replaceCitationTokens(normalized.slice(cursor), byIndex); const tail = replaceCitationTokens(normalized.slice(cursor), byIndex, citationSources);
return next; if (tail !== normalized.slice(cursor)) {
replaced = true;
}
next += tail;
if (replaced || hasCitationMarkdown(next)) {
return next;
}
return appendCitationSummary(next, citationSources);
} }
function replaceCitationTokens( function replaceCitationTokens(
content: string, content: string,
byIndex: Map<number, RuntimeDisplaySource>, byIndex: Map<number, RuntimeDisplaySource>,
orderedSources: RuntimeDisplaySource[],
): string { ): string {
return content return content
.replace(/\[\[(\d+)\]\]/g, (match, rawIndex: string) => .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) => .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>, byIndex: Map<number, RuntimeDisplaySource>,
byUrl: Map<string, RuntimeDisplaySource>, byUrl: Map<string, RuntimeDisplaySource>,
byTitle: Map<string, RuntimeDisplaySource>, byTitle: Map<string, RuntimeDisplaySource>,
orderedSources: RuntimeDisplaySource[],
): string { ): string {
// Resolve every `[citation:LABEL](URL)` link — whether LABEL is a number // Resolve every `[citation:LABEL](URL)` link — whether LABEL is a number
// ("[1]", "1") or a free-form title — to a numbered reference link that // ("[1]", "1") or a free-form title — to a numbered reference link that
@@ -488,8 +652,9 @@ function normalizeExistingCitationLinks(
const numberMatch = label.match(/^\[?(\d+)\]?$/); const numberMatch = label.match(/^\[?(\d+)\]?$/);
if (numberMatch) { if (numberMatch) {
const index = Number(numberMatch[1]); const index = Number(numberMatch[1]);
if (byIndex.has(index)) { const source = byIndex.get(index) ?? orderedSources[index - 1];
return `[citation:[${index}]](#reference-${index})`; if (source) {
return `[citation:[${source.index}]](#reference-${source.index})`;
} }
} }
const sourceByUrl = byUrl.get(url); const sourceByUrl = byUrl.get(url);
@@ -509,12 +674,32 @@ function citationMarkdown(
fallback: string, fallback: string,
index: number, index: number,
byIndex: Map<number, RuntimeDisplaySource>, byIndex: Map<number, RuntimeDisplaySource>,
orderedSources: RuntimeDisplaySource[],
): string { ): string {
const source = byIndex.get(index); const source = byIndex.get(index) ?? orderedSources[index - 1];
if (!source) { if (!source) {
return fallback; 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 { function normalizeName(name: string): string {
@@ -540,6 +725,10 @@ function findToolResultPayload(
if (!toolMessage) { if (!toolMessage) {
return undefined; return undefined;
} }
return parseToolMessagePayload(toolMessage);
}
function parseToolMessagePayload(toolMessage: Message): unknown {
const structured = extractStructuredToolPayload(toolMessage.content); const structured = extractStructuredToolPayload(toolMessage.content);
if (structured !== undefined) { if (structured !== undefined) {
return structured; return structured;
@@ -555,6 +744,17 @@ function findToolResultPayload(
return content; 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( function findToolMessageById(
toolCallId: string | undefined, toolCallId: string | undefined,
messages: Message[], messages: Message[],
@@ -677,7 +877,7 @@ function parseJSONLikeText(text: string): unknown {
try { try {
return JSON.parse(trimmed) as unknown; return JSON.parse(trimmed) as unknown;
} catch { } catch {
return extractJSONFromText(trimmed); return extractJSONFromText(trimmed) ?? extractSearchPayloadFromText(trimmed);
} }
} }
@@ -700,6 +900,83 @@ function extractJSONFromText(text: string): unknown {
return undefined; 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 { function extractToolText(content: unknown): string {
if (typeof content === "string") { if (typeof content === "string") {
return content.trim(); return content.trim();
+8 -2
View File
@@ -22,7 +22,7 @@ import {
setSkillState, setSkillState,
validateSkillUpload, validateSkillUpload,
} from "./api"; } from "./api";
import type { SkillState } from "./type"; import type { Skill, SkillState } from "./type";
import { loadSkills } from "."; import { loadSkills } from ".";
@@ -77,7 +77,13 @@ export function useSetSkillDisplay() {
display: Parameters<typeof setSkillDisplay>[1]; display: Parameters<typeof setSkillDisplay>[1];
sample?: Parameters<typeof setSkillDisplay>[2]; sample?: Parameters<typeof setSkillDisplay>[2];
}) => setSkillDisplay(skillName, display, sample), }) => 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: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] }); void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
}, },
@@ -36,6 +36,7 @@ from deerflow.skills.types import SKILL_MD_FILE, SkillCategory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["skills"]) router = APIRouter(prefix="/api", tags=["skills"])
_UNSET = object()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -221,7 +222,7 @@ def _skill_to_response(
skill: Skill, skill: Skill,
ownership: dict[str, Any] | None = None, 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, display_sample: dict[str, Any] | None = None,
) -> SkillResponse: ) -> SkillResponse:
"""Convert a Skill object to a 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)) builtin = bool(ownership.get("builtin", owner_user_id is None))
detail = ownership.get("detail") detail = ownership.get("detail")
display = display_override display = None if display_override is _UNSET else display_override
if display is None: if display_override is _UNSET:
try: try:
skill_config = get_extensions_config().skills.get(skill.name) skill_config = get_extensions_config().skills.get(skill.name)
if skill_config and skill_config.display: 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) 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: async def _get_skill_display_adapter(skill_name: str) -> dict[str, Any] | None:
if not await _ensure_skill_display_table(): if not await _ensure_skill_display_table():
return None return None
@@ -698,7 +703,7 @@ async def list_skills(
display_adapters = await _list_skill_display_adapters([skill.name for skill in all_skills]) display_adapters = await _list_skill_display_adapters([skill.name for skill in all_skills])
responses: list[SkillResponse] = [] responses: list[SkillResponse] = []
for skill in all_skills: 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. # Public/built-in skills (no DB row) are visible to everyone.
# Custom skills must have an ownership record in the visible set. # Custom skills must have an ownership record in the visible set.
if skill.category != SkillCategory.CUSTOM: if skill.category != SkillCategory.CUSTOM:
@@ -708,8 +713,8 @@ async def list_skills(
_skill_to_response( _skill_to_response(
skill, skill,
ownership.get(skill.name), ownership.get(skill.name),
display_override=adapter.get("display"), display_override=_display_override_from_adapter(adapter),
display_sample=adapter.get("sample"), display_sample=adapter.get("sample") if adapter else None,
) )
) )
continue continue
@@ -720,8 +725,8 @@ async def list_skills(
_skill_to_response( _skill_to_response(
skill, skill,
record, record,
display_override=adapter.get("display"), display_override=_display_override_from_adapter(adapter),
display_sample=adapter.get("sample"), display_sample=adapter.get("sample") if adapter else None,
) )
) )
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids) 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] 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) ownership = await _build_ownership_index(skill_store, user_id)
display_adapters = await _list_skill_display_adapters([skill.name for skill in custom]) display_adapters = await _list_skill_display_adapters([skill.name for skill in custom])
responses = [ responses = []
_skill_to_response( for skill in custom:
skill, if skill.name not in ownership:
ownership[skill.name], continue
display_override=display_adapters.get(skill.name, {}).get("display"), adapter = display_adapters.get(skill.name)
display_sample=display_adapters.get(skill.name, {}).get("sample"), 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) return SkillsListResponse(skills=responses)
except Exception as e: except Exception as e:
logger.error("Failed to list custom skills: %s", e, exc_info=True) 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) skill = next((s for s in skills if s.name == skill_name and s.category == SkillCategory.CUSTOM), None)
if skill is None: if skill is None:
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found") 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -1365,7 +1381,13 @@ async def get_skill(
# Attach ownership (owner/published/detail) when the caller may see it. # Attach ownership (owner/published/detail) when the caller may see it.
ownership = await skill_store.get_visible(skill_name, _current_user_id(request)) 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: except HTTPException:
raise raise
except Exception as e: 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) updated_skill = next((s for s in skills if s.name == skill_name), None)
if updated_skill is None: if updated_skill is None:
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after display update") 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( return _skill_to_response(
updated_skill, updated_skill,
display_override=adapter.get("display"), display_override=_display_override_from_adapter(adapter),
display_sample=adapter.get("sample"), display_sample=adapter.get("sample") if adapter else None,
) )
except HTTPException: except HTTPException:
raise raise