feat: improve citation display and skill persistence
This commit is contained in:
1 parent
b671b49725
commit
8b2f398015
12 files changed
+689
-104
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) => ({
|
||||||
|
|||||||
@@ -291,13 +291,15 @@ 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<MessageTokenUsage
|
<MessageTokenUsage
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -140,6 +203,29 @@ export function AppearanceSettingsPage() {
|
|||||||
aria-label="RAG 引用模式"
|
aria-label="RAG 引用模式"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</SettingsSection>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
|||||||
display: {
|
display: {
|
||||||
streamSpeedMeterEnabled: true,
|
streamSpeedMeterEnabled: true,
|
||||||
ragModeEnabled: false,
|
ragModeEnabled: false,
|
||||||
|
citationCleanupWords: [],
|
||||||
},
|
},
|
||||||
context: {
|
context: {
|
||||||
model_name: undefined,
|
model_name: undefined,
|
||||||
@@ -39,6 +40,11 @@ export interface LocalSettings {
|
|||||||
* 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,
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user