feat: improve citation display and skill persistence
This commit is contained in:
1 parent
61babbdf7d
commit
650102cedb
11 files changed
+706
-123
No files matched your search
@@ -40,9 +40,11 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
const { thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
const threadIdRef = useRef(threadId);
|
||||
const artifactsCountRef = useRef(0);
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const [sideTab, setSideTab] = useState("document");
|
||||
const [referencesOpen, setReferencesOpen] = useState(true);
|
||||
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
|
||||
const [settings] = useLocalSettings();
|
||||
const { skills } = useSkills();
|
||||
|
||||
@@ -61,24 +63,32 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[settings.display.ragModeEnabled, skills],
|
||||
);
|
||||
const latestHumanIndex = useMemo(
|
||||
() => findLatestHumanMessageIndex(thread.values.messages ?? []),
|
||||
[thread.values.messages],
|
||||
);
|
||||
const displaySources = useMemo(() => {
|
||||
const referenceBatches = useMemo(() => {
|
||||
if (!settings.display.ragModeEnabled) {
|
||||
return [];
|
||||
}
|
||||
const messages = thread.values.messages ?? [];
|
||||
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: latestHumanIndex >= 0 ? messages[latestHumanIndex]?.id : undefined,
|
||||
includeModes: ["citation"],
|
||||
});
|
||||
}, [settings.display.ragModeEnabled, skillDisplayMap, latestHumanIndex, thread.values.messages]);
|
||||
const referenceSources = useMemo(
|
||||
() => displaySources.filter((source) => source.mode === "citation"),
|
||||
[displaySources],
|
||||
);
|
||||
return messages
|
||||
.filter((message) => message.type === "ai" && message.id)
|
||||
.map((message) => ({
|
||||
id: message.id!,
|
||||
sources: collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, message.id),
|
||||
beforeMessageId: message.id,
|
||||
includeBeforeMessage: true,
|
||||
includeModes: ["citation"],
|
||||
}).filter((source) => source.mode === "citation"),
|
||||
}))
|
||||
.filter((batch) => batch.sources.length > 0);
|
||||
}, [settings.display.ragModeEnabled, skillDisplayMap, thread.values.messages]);
|
||||
const latestReferenceBatch =
|
||||
referenceBatches.length > 0
|
||||
? referenceBatches[referenceBatches.length - 1]!
|
||||
: null;
|
||||
const activeReferenceBatch =
|
||||
referenceBatches.find((batch) => batch.id === activeReferenceBatchId) ??
|
||||
latestReferenceBatch;
|
||||
const referenceSources = activeReferenceBatch?.sources ?? [];
|
||||
const hasCitationReferences = referenceSources.length > 0;
|
||||
useEffect(() => {
|
||||
if (hasCitationReferences) {
|
||||
@@ -86,7 +96,22 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
}
|
||||
}, [hasCitationReferences]);
|
||||
useEffect(() => {
|
||||
const handleShowReference = () => {
|
||||
if (!latestReferenceBatch) {
|
||||
setActiveReferenceBatchId(null);
|
||||
return;
|
||||
}
|
||||
setActiveReferenceBatchId((current) =>
|
||||
current && referenceBatches.some((batch) => batch.id === current)
|
||||
? current
|
||||
: latestReferenceBatch.id,
|
||||
);
|
||||
}, [latestReferenceBatch?.id, referenceBatches]);
|
||||
useEffect(() => {
|
||||
const handleShowReference = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ batchId?: string }>).detail;
|
||||
if (detail?.batchId) {
|
||||
setActiveReferenceBatchId(detail.batchId);
|
||||
}
|
||||
setReferencesOpen(true);
|
||||
setSideTab("references");
|
||||
};
|
||||
@@ -98,12 +123,22 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
useEffect(() => {
|
||||
if (threadIdRef.current !== threadId) {
|
||||
threadIdRef.current = threadId;
|
||||
artifactsCountRef.current = 0;
|
||||
deselect();
|
||||
setReferencesOpen(true);
|
||||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
setArtifacts(thread.values.artifacts);
|
||||
const nextArtifacts = thread.values.artifacts ?? [];
|
||||
const hadArtifacts = artifactsCountRef.current > 0;
|
||||
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
||||
artifactsCountRef.current = nextArtifacts.length;
|
||||
setArtifacts(nextArtifacts);
|
||||
if ((hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) && !selectedArtifact) {
|
||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}
|
||||
|
||||
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||
// if (
|
||||
@@ -142,10 +177,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
const showTabs = !!selectedArtifact && hasCitationReferences;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedArtifact && hasCitationReferences) {
|
||||
setSideTab("references");
|
||||
} else if (selectedArtifact && !hasCitationReferences) {
|
||||
if (selectedArtifact) {
|
||||
setSideTab("document");
|
||||
} else if (hasCitationReferences) {
|
||||
setSideTab("references");
|
||||
}
|
||||
}, [hasCitationReferences, selectedArtifact]);
|
||||
|
||||
@@ -315,13 +350,22 @@ function ReferencePanel({
|
||||
);
|
||||
}
|
||||
|
||||
function findLatestHumanMessageIndex(messages: Message[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
if (messages[index]?.type === "human") {
|
||||
return index;
|
||||
function findPreviousHumanMessageId(
|
||||
messages: Message[],
|
||||
beforeMessageId?: string,
|
||||
): string | undefined {
|
||||
const beforeIndex =
|
||||
beforeMessageId === undefined
|
||||
? messages.length
|
||||
: messages.findIndex((message) => message.id === beforeMessageId);
|
||||
const end = beforeIndex < 0 ? messages.length : beforeIndex;
|
||||
for (let index = end - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message?.type === "human") {
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export { ChatBox };
|
||||
@@ -28,20 +28,19 @@ export function CitationLink({
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
const batchId =
|
||||
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
|
||||
?.dataset.referenceBatchId;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SHOW_REFERENCE_EVENT, {
|
||||
detail: { referenceId },
|
||||
detail: { batchId, referenceId },
|
||||
}),
|
||||
);
|
||||
const target = document.getElementById(`reference-${referenceId}`);
|
||||
const scroll = () => {
|
||||
const target = document.getElementById(`reference-${referenceId}`);
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
};
|
||||
if (target) {
|
||||
scroll();
|
||||
} else {
|
||||
window.setTimeout(scroll, 220);
|
||||
}
|
||||
window.setTimeout(scroll, 240);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -51,7 +51,6 @@ import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
findDisplayConfigForPayload,
|
||||
findDisplayConfigForTool,
|
||||
isDisplaySamplePayload,
|
||||
normalizeDefaultDisplaySources,
|
||||
@@ -256,9 +255,7 @@ function ToolCall({
|
||||
result !== undefined && isDisplaySamplePayload(result)
|
||||
? null
|
||||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||
(result !== undefined
|
||||
? findDisplayConfigForPayload(name, result, skillDisplayMap)
|
||||
: null));
|
||||
null);
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useMemo, type ComponentType, type SVGProps } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -14,9 +16,15 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import {
|
||||
useAppearanceSettings,
|
||||
useUpdateAppearanceSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
@@ -31,6 +39,24 @@ export function AppearanceSettingsPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const currentTheme = theme;
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const updateAppearance = useUpdateAppearanceSettings();
|
||||
const globalDefault = appearance?.default_theme ?? "light";
|
||||
|
||||
const handleSetGlobalDefault = async (value: "light" | "dark") => {
|
||||
if (value === globalDefault) return;
|
||||
try {
|
||||
await updateAppearance.mutateAsync({ default_theme: value });
|
||||
toast.success(
|
||||
value === "light"
|
||||
? "已将系统默认主题设置为浅色"
|
||||
: "已将系统默认主题设置为深色",
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const themeOptions = useMemo(
|
||||
() => [
|
||||
@@ -76,6 +102,43 @@ export function AppearanceSettingsPage() {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="系统默认主题(管理员)"
|
||||
description="给所有还没有手动切换过主题的用户的默认主题;已经自己切换过的用户保留其个人设置不受影响。"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalDefault === "light" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetGlobalDefault("light")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<SunIcon className="mr-1.5 size-4" />
|
||||
浅色
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalDefault === "dark" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetGlobalDefault("dark")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<MoonIcon className="mr-1.5 size-4" />
|
||||
深色
|
||||
</Button>
|
||||
<p className="text-muted-foreground ml-2 text-xs">
|
||||
当前系统默认:
|
||||
{globalDefault === "light" ? "浅色" : "深色"}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
@@ -124,26 +187,49 @@ export function AppearanceSettingsPage() {
|
||||
}
|
||||
aria-label="回答速度指示器"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">RAG 引用模式</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.display.ragModeEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings("display", { ragModeEnabled: checked })
|
||||
}
|
||||
aria-label="RAG 引用模式"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">RAG 引用模式</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.display.ragModeEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings("display", { ragModeEnabled: checked })
|
||||
}
|
||||
aria-label="RAG 引用模式"
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-medium">参考文献去除词</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-2xl text-xs leading-relaxed">
|
||||
管理员配置。每行一个词或短句,参考文献卡片和详情弹框会自动移除这些广告词、导航词或免责声明。
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
value={(settings.display.citationCleanupWords ?? []).join("\n")}
|
||||
onChange={(event) =>
|
||||
setSettings("display", {
|
||||
citationCleanupWords: event.currentTarget.value
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
placeholder={"广告\n点击下载APP\n打开客户端\n免责声明\n责任编辑"}
|
||||
className="min-h-32 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemePreviewCard({
|
||||
icon: Icon,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
normalizeDisplaySources,
|
||||
} from "@/core/skill-display/display-adapter";
|
||||
import {
|
||||
getSkill,
|
||||
getPersistedSkillDisplaySample,
|
||||
getSkillDisplaySample,
|
||||
} from "@/core/skills/api";
|
||||
@@ -76,6 +77,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
|
||||
const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample));
|
||||
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
|
||||
const [loadingDbConfig, setLoadingDbConfig] = useState(false);
|
||||
|
||||
// Echo server state into the form — on mount, on skill switch, AND after
|
||||
// a successful save (the refetched skill object has a new `display` identity).
|
||||
@@ -88,6 +90,41 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
}, [skill.name, skill.display]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadDbSkillConfig() {
|
||||
setLoadingDbConfig(true);
|
||||
try {
|
||||
const fresh = await getSkill(skill.name);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const display = fresh.display ?? emptyDisplayConfig();
|
||||
setDraft(display);
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
setSampleText(formatSample(fresh.displaySample));
|
||||
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], fresh);
|
||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||
Array.isArray(current)
|
||||
? current.map((item) => (item.name === skill.name ? { ...item, ...fresh } : item))
|
||||
: current,
|
||||
);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoadingDbConfig(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
void loadDbSkillConfig();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [queryClient, skill.name]);
|
||||
|
||||
// Sample text is editor-only state — never reset on save. Only seed it once
|
||||
// per skill, otherwise the user loses the JSON they just pulled in via
|
||||
// 自动识别 every time they hit 保存.
|
||||
@@ -191,11 +228,15 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
|
||||
async function saveDraft() {
|
||||
try {
|
||||
await setDisplay.mutateAsync({
|
||||
const saved = await setDisplay.mutateAsync({
|
||||
skillName: skill.name,
|
||||
display: draft,
|
||||
sample: samplePayload ?? undefined,
|
||||
});
|
||||
const display = saved.display ?? emptyDisplayConfig();
|
||||
setDraft(display);
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
setSampleText(formatSample(saved.displaySample));
|
||||
toast.success("展示适配已保存");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
@@ -222,6 +263,11 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
从技能返回样例中自动识别字段,然后把技能结果显示为编号引用、列表、卡片或自定义样式。
|
||||
</p>
|
||||
{loadingDbConfig && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
正在从数据库读取已保存的配置...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">启用</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -44,7 +45,11 @@ export function SourceCard({
|
||||
referenceTarget?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = source.content || source.snippet;
|
||||
const [settings] = useLocalSettings();
|
||||
const cleanupWords = settings.display.citationCleanupWords ?? [];
|
||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||
const snippet = compactReferenceText(content).slice(0, 280);
|
||||
const paragraphs = segmentReferenceText(content);
|
||||
const score =
|
||||
typeof source.score === "number"
|
||||
? source.score <= 1
|
||||
@@ -90,9 +95,9 @@ export function SourceCard({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{content && (
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground/90 line-clamp-2 text-xs leading-relaxed">
|
||||
{content}
|
||||
{snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -138,9 +143,13 @@ export function SourceCard({
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{content && (
|
||||
<div className="text-foreground max-h-[55vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{content}
|
||||
{paragraphs.length > 0 && (
|
||||
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p key={index} className="whitespace-pre-wrap">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{source.url && (
|
||||
@@ -153,3 +162,66 @@ export function SourceCard({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function cleanReferenceText(
|
||||
raw: string | undefined,
|
||||
cleanupWords: string[],
|
||||
): string {
|
||||
let text = raw ?? "";
|
||||
for (const word of cleanupWords) {
|
||||
const trimmed = word.trim();
|
||||
if (!trimmed) continue;
|
||||
text = text.split(trimmed).join("");
|
||||
}
|
||||
return text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function compactReferenceText(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function segmentReferenceText(text: string): string[] {
|
||||
const explicit = text
|
||||
.split(/\n{2,}/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const base = explicit.length > 1 ? explicit : splitLongParagraph(text);
|
||||
return base.slice(0, 80);
|
||||
}
|
||||
|
||||
function splitLongParagraph(text: string): string[] {
|
||||
const normalized = text
|
||||
.split(/\n+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (normalized.length <= 360) {
|
||||
return normalized ? [normalized] : [];
|
||||
}
|
||||
|
||||
const sentences = normalized
|
||||
.split(/(?<=[。!?!?;;])\s*/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
if (sentences.length <= 1) {
|
||||
return normalized.match(/[\s\S]{1,360}/g) ?? [];
|
||||
}
|
||||
|
||||
const paragraphs: string[] = [];
|
||||
let current = "";
|
||||
for (const sentence of sentences) {
|
||||
if ((current + sentence).length > 360 && current) {
|
||||
paragraphs.push(current.trim());
|
||||
current = "";
|
||||
}
|
||||
current += sentence;
|
||||
}
|
||||
if (current.trim()) {
|
||||
paragraphs.push(current.trim());
|
||||
}
|
||||
return paragraphs;
|
||||
}
|
||||
@@ -4,10 +4,11 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
notification: {
|
||||
enabled: true,
|
||||
},
|
||||
display: {
|
||||
streamSpeedMeterEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
},
|
||||
display: {
|
||||
streamSpeedMeterEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
citationCleanupWords: [],
|
||||
},
|
||||
context: {
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
@@ -38,8 +39,13 @@ export interface LocalSettings {
|
||||
* Global switch for skill result display adapters. When disabled, chat
|
||||
* messages render tool results with the legacy generic search UI only.
|
||||
*/
|
||||
ragModeEnabled: boolean;
|
||||
};
|
||||
ragModeEnabled: boolean;
|
||||
/**
|
||||
* Admin-maintained words or short phrases removed from reference snippets
|
||||
* and dialogs, one entry per line in the settings UI.
|
||||
*/
|
||||
citationCleanupWords: string[];
|
||||
};
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
| "thread_id"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||
|
||||
const DEFAULT_CITATION_MAX_ITEMS = 100;
|
||||
const DEFAULT_LIST_MAX_ITEMS = 20;
|
||||
const MAX_DISPLAY_ITEMS = 100;
|
||||
|
||||
export interface DisplaySourceItem {
|
||||
id?: string;
|
||||
title: string;
|
||||
@@ -29,7 +33,7 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
|
||||
linkStrategy: "auto",
|
||||
linkTemplate: null,
|
||||
citationToken: "doubleBracket",
|
||||
maxItems: 8,
|
||||
maxItems: DEFAULT_CITATION_MAX_ITEMS,
|
||||
customInstruction: "",
|
||||
tableColumns: [],
|
||||
});
|
||||
@@ -92,7 +96,7 @@ export function inferDisplayConfig(samplePayload: unknown, skillName?: string):
|
||||
inferred.enabled = true;
|
||||
inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list";
|
||||
inferred.resultPath = found.path || "results";
|
||||
inferred.maxItems = inferred.mode === "citation" ? 8 : 20;
|
||||
inferred.maxItems = inferMaxItems(found.items.length, inferred.mode);
|
||||
|
||||
for (const [target, candidates] of Object.entries(candidateFields)) {
|
||||
(inferred as unknown as Record<string, string | null>)[target] = pickField(sample, candidates);
|
||||
@@ -159,9 +163,10 @@ function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig):
|
||||
export function normalizeDisplaySources(payload: unknown, config: SkillDisplayConfig): DisplaySourceItem[] {
|
||||
const rawItems = getByPath(payload, config.resultPath);
|
||||
if (!Array.isArray(rawItems)) return [];
|
||||
const maxItems = resolveMaxItems(config, rawItems.length);
|
||||
return rawItems
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
|
||||
.slice(0, config.maxItems)
|
||||
.slice(0, maxItems)
|
||||
.map((item, index) => ({
|
||||
id: config.idField ? stringify(item[config.idField]) : undefined,
|
||||
title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`,
|
||||
@@ -178,3 +183,26 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
|
||||
raw: item,
|
||||
}));
|
||||
}
|
||||
|
||||
function inferMaxItems(
|
||||
itemCount: number,
|
||||
mode: SkillDisplayConfig["mode"],
|
||||
): number {
|
||||
const fallback =
|
||||
mode === "citation" ? DEFAULT_CITATION_MAX_ITEMS : DEFAULT_LIST_MAX_ITEMS;
|
||||
return Math.min(
|
||||
MAX_DISPLAY_ITEMS,
|
||||
Math.max(fallback, itemCount || fallback),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMaxItems(config: SkillDisplayConfig, itemCount: number): number {
|
||||
const configured = Number(config.maxItems) || 0;
|
||||
if (config.mode === "citation" && configured <= 8 && itemCount > configured) {
|
||||
return Math.min(itemCount, MAX_DISPLAY_ITEMS);
|
||||
}
|
||||
return Math.min(
|
||||
MAX_DISPLAY_ITEMS,
|
||||
Math.max(1, configured || inferMaxItems(itemCount, config.mode)),
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export type RuntimeDisplaySource = DisplaySourceItem & {
|
||||
tableColumns?: SkillDisplayConfig["tableColumns"];
|
||||
};
|
||||
|
||||
type RuntimeDisplaySourceDraft = Omit<RuntimeDisplaySource, "index">;
|
||||
|
||||
export type SkillDisplayMap = Map<string, SkillDisplayConfig>;
|
||||
|
||||
type RuntimeToolCall = {
|
||||
@@ -163,6 +165,9 @@ export function collectRuntimeDisplaySources(
|
||||
const sources: RuntimeDisplaySource[] = [];
|
||||
let citationIndex = 0;
|
||||
const displayIndexes = new Map<string, number>();
|
||||
const seenCitationKeys = new Map<string, RuntimeDisplaySource>();
|
||||
const seenDisplayKeys = new Set<string>();
|
||||
const consumedToolMessageIds = new Set<string>();
|
||||
|
||||
for (let index = start; index < limit; index++) {
|
||||
const message = messages[index];
|
||||
@@ -179,16 +184,14 @@ export function collectRuntimeDisplaySources(
|
||||
if (result === undefined) {
|
||||
continue;
|
||||
}
|
||||
const linkedToolMessage = findToolMessageById(toolCall.id, messages);
|
||||
if (linkedToolMessage?.id) {
|
||||
consumedToolMessageIds.add(linkedToolMessage.id);
|
||||
}
|
||||
if (isDisplaySamplePayload(result)) {
|
||||
continue;
|
||||
}
|
||||
const match =
|
||||
toolMatch ??
|
||||
findDisplayConfigForPayload(
|
||||
toolCall.name,
|
||||
result,
|
||||
displays,
|
||||
);
|
||||
const match = toolMatch;
|
||||
const effectiveMode = match?.display.mode ?? "citation";
|
||||
if (allowedModes && !allowedModes.has(effectiveMode)) {
|
||||
continue;
|
||||
@@ -202,24 +205,144 @@ export function collectRuntimeDisplaySources(
|
||||
)
|
||||
? normalizeDefaultDisplaySources(result)
|
||||
: [];
|
||||
for (const item of normalized) {
|
||||
const skillName = match?.skillName ?? toolCall.name;
|
||||
const displayKey = `${skillName}:${effectiveMode}`;
|
||||
const displayIndex = (displayIndexes.get(displayKey) ?? 0) + 1;
|
||||
displayIndexes.set(displayKey, displayIndex);
|
||||
sources.push({
|
||||
appendDisplaySources(
|
||||
sources,
|
||||
normalized.map((item) => ({
|
||||
...item,
|
||||
index: effectiveMode === "citation" ? ++citationIndex : displayIndex,
|
||||
skillName,
|
||||
skillName: match?.skillName ?? toolCall.name,
|
||||
mode: effectiveMode,
|
||||
tableColumns: match?.display.tableColumns,
|
||||
});
|
||||
}
|
||||
})),
|
||||
displayIndexes,
|
||||
seenCitationKeys,
|
||||
seenDisplayKeys,
|
||||
() => ++citationIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = start; index < limit; index++) {
|
||||
const message = messages[index];
|
||||
if (!message || message.type !== "tool" || consumedToolMessageIds.has(message.id ?? "")) {
|
||||
continue;
|
||||
}
|
||||
const result = parseToolMessagePayload(message);
|
||||
if (result === undefined || isDisplaySamplePayload(result)) {
|
||||
continue;
|
||||
}
|
||||
const toolName = extractToolMessageName(message);
|
||||
const match = null;
|
||||
const effectiveMode = match?.display.mode ?? "citation";
|
||||
if (allowedModes && !allowedModes.has(effectiveMode)) {
|
||||
continue;
|
||||
}
|
||||
const normalized = match
|
||||
? normalizeDisplaySources(result, match.display)
|
||||
: shouldUseDefaultCitationMode(toolName, result)
|
||||
? normalizeDefaultDisplaySources(result)
|
||||
: [];
|
||||
appendDisplaySources(
|
||||
sources,
|
||||
normalized.map((item) => ({
|
||||
...item,
|
||||
skillName: match?.skillName ?? toolName,
|
||||
mode: effectiveMode,
|
||||
tableColumns: match?.display.tableColumns,
|
||||
})),
|
||||
displayIndexes,
|
||||
seenCitationKeys,
|
||||
seenDisplayKeys,
|
||||
() => ++citationIndex,
|
||||
);
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function appendDisplaySources(
|
||||
target: RuntimeDisplaySource[],
|
||||
drafts: RuntimeDisplaySourceDraft[],
|
||||
displayIndexes: Map<string, number>,
|
||||
seenCitationKeys: Map<string, RuntimeDisplaySource>,
|
||||
seenDisplayKeys: Set<string>,
|
||||
nextCitationIndex: () => number,
|
||||
) {
|
||||
for (const draft of drafts) {
|
||||
if (draft.mode === "citation") {
|
||||
const key = citationSourceKey(draft);
|
||||
const existing = seenCitationKeys.get(key);
|
||||
if (existing) {
|
||||
continue;
|
||||
}
|
||||
const source = {
|
||||
...draft,
|
||||
index: nextCitationIndex(),
|
||||
};
|
||||
seenCitationKeys.set(key, source);
|
||||
target.push(source);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stableKey = nonCitationSourceKey(draft);
|
||||
if (seenDisplayKeys.has(stableKey)) {
|
||||
continue;
|
||||
}
|
||||
seenDisplayKeys.add(stableKey);
|
||||
const displayKey = `${draft.skillName}:${draft.mode}`;
|
||||
const displayIndex = (displayIndexes.get(displayKey) ?? 0) + 1;
|
||||
displayIndexes.set(displayKey, displayIndex);
|
||||
target.push({
|
||||
...draft,
|
||||
index: displayIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function reindexRuntimeDisplaySources(
|
||||
sources: RuntimeDisplaySource[],
|
||||
globalSources: RuntimeDisplaySource[],
|
||||
): RuntimeDisplaySource[] {
|
||||
if (sources.length === 0 || globalSources.length === 0) {
|
||||
return sources;
|
||||
}
|
||||
const globalByKey = new Map(
|
||||
globalSources.map((source) => [citationSourceKey(source), source]),
|
||||
);
|
||||
return sources.map((source) => {
|
||||
if (source.mode !== "citation") {
|
||||
return source;
|
||||
}
|
||||
const global = globalByKey.get(citationSourceKey(source));
|
||||
return global
|
||||
? {
|
||||
...source,
|
||||
index: global.index,
|
||||
}
|
||||
: source;
|
||||
});
|
||||
}
|
||||
|
||||
function citationSourceKey(source: RuntimeDisplaySourceDraft): string {
|
||||
const raw = source.raw ?? {};
|
||||
const id =
|
||||
source.id ||
|
||||
source.url ||
|
||||
stringifyMaybe(raw.recUuid) ||
|
||||
stringifyMaybe(raw.uuid) ||
|
||||
stringifyMaybe(raw.docId) ||
|
||||
stringifyMaybe(raw.id);
|
||||
if (id) {
|
||||
return normalizeName(`${source.skillName}:${id}`);
|
||||
}
|
||||
const fallback = [source.skillName, source.title, source.source, source.time]
|
||||
.filter(Boolean)
|
||||
.join("|");
|
||||
return normalizeName(fallback) || JSON.stringify(raw).slice(0, 300);
|
||||
}
|
||||
|
||||
function nonCitationSourceKey(source: RuntimeDisplaySourceDraft): string {
|
||||
return `${source.skillName}:${source.mode}:${citationSourceKey(source)}`;
|
||||
}
|
||||
|
||||
function extractRuntimeToolCalls(message: Message): RuntimeToolCall[] {
|
||||
const calls: RuntimeToolCall[] = [];
|
||||
const direct = (message as { tool_calls?: unknown }).tool_calls;
|
||||
@@ -312,6 +435,18 @@ export function findDisplayConfigForPayload(
|
||||
return best ? { skillName: best.skillName, display: best.display } : null;
|
||||
}
|
||||
|
||||
export function isUnconfiguredSkillInvocation(
|
||||
toolName: string,
|
||||
displays: SkillDisplayMap | undefined,
|
||||
toolArgs?: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (!toolArgs || !canInferSkillFromToolArgs(toolName)) {
|
||||
return false;
|
||||
}
|
||||
const skillName = extractSkillNameFromArgs(toolArgs);
|
||||
return !!skillName && !displays?.has(skillName);
|
||||
}
|
||||
|
||||
export function isDisplaySamplePayload(payload: unknown): boolean {
|
||||
if (!isRecord(payload)) {
|
||||
return false;
|
||||
@@ -443,31 +578,59 @@ export function applyInlineCitations(
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeExistingCitationLinks(content, byIndex, byUrl, byTitle);
|
||||
const normalized = normalizeExistingCitationLinks(
|
||||
content,
|
||||
byIndex,
|
||||
byUrl,
|
||||
byTitle,
|
||||
citationSources,
|
||||
);
|
||||
|
||||
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
|
||||
let cursor = 0;
|
||||
let next = "";
|
||||
let replaced = false;
|
||||
for (const match of normalized.matchAll(markdownLinkPattern)) {
|
||||
const start = match.index ?? 0;
|
||||
next += replaceCitationTokens(normalized.slice(cursor, start), byIndex);
|
||||
const replacedSlice = replaceCitationTokens(
|
||||
normalized.slice(cursor, start),
|
||||
byIndex,
|
||||
citationSources,
|
||||
);
|
||||
if (replacedSlice !== normalized.slice(cursor, start)) {
|
||||
replaced = true;
|
||||
}
|
||||
next += replacedSlice;
|
||||
next += match[0];
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
next += replaceCitationTokens(normalized.slice(cursor), byIndex);
|
||||
return next;
|
||||
const tail = replaceCitationTokens(normalized.slice(cursor), byIndex, citationSources);
|
||||
if (tail !== normalized.slice(cursor)) {
|
||||
replaced = true;
|
||||
}
|
||||
next += tail;
|
||||
if (replaced || hasCitationMarkdown(next)) {
|
||||
return next;
|
||||
}
|
||||
return appendCitationSummary(next, citationSources);
|
||||
}
|
||||
|
||||
function replaceCitationTokens(
|
||||
content: string,
|
||||
byIndex: Map<number, RuntimeDisplaySource>,
|
||||
orderedSources: RuntimeDisplaySource[],
|
||||
): string {
|
||||
return content
|
||||
.replace(/\[\[(\d+)\]\]/g, (match, rawIndex: string) =>
|
||||
citationMarkdown(match, Number(rawIndex), byIndex),
|
||||
citationMarkdown(match, Number(rawIndex), byIndex, orderedSources),
|
||||
)
|
||||
.replace(/(^|[^:\[!])\[(\d+)\](?!\()/g, (match, prefix: string, rawIndex: string) =>
|
||||
`${prefix}${citationMarkdown(match.slice(prefix.length), Number(rawIndex), byIndex)}`,
|
||||
`${prefix}${citationMarkdown(
|
||||
match.slice(prefix.length),
|
||||
Number(rawIndex),
|
||||
byIndex,
|
||||
orderedSources,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -476,6 +639,7 @@ function normalizeExistingCitationLinks(
|
||||
byIndex: Map<number, RuntimeDisplaySource>,
|
||||
byUrl: Map<string, RuntimeDisplaySource>,
|
||||
byTitle: Map<string, RuntimeDisplaySource>,
|
||||
orderedSources: RuntimeDisplaySource[],
|
||||
): string {
|
||||
// Resolve every `[citation:LABEL](URL)` link — whether LABEL is a number
|
||||
// ("[1]", "1") or a free-form title — to a numbered reference link that
|
||||
@@ -488,8 +652,9 @@ function normalizeExistingCitationLinks(
|
||||
const numberMatch = label.match(/^\[?(\d+)\]?$/);
|
||||
if (numberMatch) {
|
||||
const index = Number(numberMatch[1]);
|
||||
if (byIndex.has(index)) {
|
||||
return `[citation:[${index}]](#reference-${index})`;
|
||||
const source = byIndex.get(index) ?? orderedSources[index - 1];
|
||||
if (source) {
|
||||
return `[citation:[${source.index}]](#reference-${source.index})`;
|
||||
}
|
||||
}
|
||||
const sourceByUrl = byUrl.get(url);
|
||||
@@ -509,12 +674,32 @@ function citationMarkdown(
|
||||
fallback: string,
|
||||
index: number,
|
||||
byIndex: Map<number, RuntimeDisplaySource>,
|
||||
orderedSources: RuntimeDisplaySource[],
|
||||
): string {
|
||||
const source = byIndex.get(index);
|
||||
const source = byIndex.get(index) ?? orderedSources[index - 1];
|
||||
if (!source) {
|
||||
return fallback;
|
||||
}
|
||||
return `[citation:[${index}]](#reference-${index})`;
|
||||
return `[citation:[${source.index}]](#reference-${source.index})`;
|
||||
}
|
||||
|
||||
function hasCitationMarkdown(content: string): boolean {
|
||||
return /\[citation:\[[^\]]+\]\]\(#reference-[^)]+\)/.test(content);
|
||||
}
|
||||
|
||||
function appendCitationSummary(
|
||||
content: string,
|
||||
citationSources: RuntimeDisplaySource[],
|
||||
): string {
|
||||
const citations = citationSources
|
||||
.slice(0, 5)
|
||||
.map((source) => `[citation:[${source.index}]](#reference-${source.index})`)
|
||||
.join(" ");
|
||||
if (!citations) {
|
||||
return content;
|
||||
}
|
||||
const trimmed = content.trimEnd();
|
||||
return `${trimmed}${trimmed ? "\n\n" : ""}参考来源:${citations}`;
|
||||
}
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
@@ -540,6 +725,10 @@ function findToolResultPayload(
|
||||
if (!toolMessage) {
|
||||
return undefined;
|
||||
}
|
||||
return parseToolMessagePayload(toolMessage);
|
||||
}
|
||||
|
||||
function parseToolMessagePayload(toolMessage: Message): unknown {
|
||||
const structured = extractStructuredToolPayload(toolMessage.content);
|
||||
if (structured !== undefined) {
|
||||
return structured;
|
||||
@@ -555,6 +744,17 @@ function findToolResultPayload(
|
||||
return content;
|
||||
}
|
||||
|
||||
function extractToolMessageName(message: Message): string {
|
||||
return (
|
||||
stringifyMaybe((message as { name?: unknown }).name) ??
|
||||
stringifyMaybe(
|
||||
(message as { additional_kwargs?: Record<string, unknown> }).additional_kwargs
|
||||
?.name,
|
||||
) ??
|
||||
"tool"
|
||||
);
|
||||
}
|
||||
|
||||
function findToolMessageById(
|
||||
toolCallId: string | undefined,
|
||||
messages: Message[],
|
||||
@@ -677,7 +877,7 @@ function parseJSONLikeText(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
return extractJSONFromText(trimmed);
|
||||
return extractJSONFromText(trimmed) ?? extractSearchPayloadFromText(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,6 +900,83 @@ function extractJSONFromText(text: string): unknown {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractSearchPayloadFromText(text: string): unknown {
|
||||
if (!text.includes('"results"') || !text.includes('"recUuid"')) {
|
||||
return undefined;
|
||||
}
|
||||
const results: Record<string, unknown>[] = [];
|
||||
const objectPattern = /\{\s*"index"\s*:\s*\d+[\s\S]*?\n\s*\}/g;
|
||||
for (const match of text.matchAll(objectPattern)) {
|
||||
try {
|
||||
const item = JSON.parse(match[0]) as unknown;
|
||||
if (isRecord(item) && item.recUuid) {
|
||||
results.push(item);
|
||||
}
|
||||
} catch {
|
||||
const item = extractSearchItemFromText(match[0]);
|
||||
if (item) {
|
||||
results.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (results.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
query: extractStringField(text, "query") ?? "",
|
||||
count: results.length,
|
||||
source: extractStringField(text, "source") ?? "tool-output",
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
function extractSearchItemFromText(text: string): Record<string, unknown> | null {
|
||||
const recUuid = extractStringField(text, "recUuid");
|
||||
if (!recUuid) {
|
||||
return null;
|
||||
}
|
||||
const score = extractNumberField(text, "relevance_score");
|
||||
return {
|
||||
index: extractNumberField(text, "index") ?? undefined,
|
||||
recUuid,
|
||||
relevance_score: score,
|
||||
title: extractStringField(text, "title") ?? recUuid,
|
||||
source_category: extractStringField(text, "source_category") ?? "",
|
||||
source: extractStringField(text, "source") ?? "",
|
||||
publish_time: extractStringField(text, "publish_time") ?? "",
|
||||
content_preview: extractStringField(text, "content_preview") ?? "",
|
||||
content_length: extractNumberField(text, "content_length") ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function extractStringField(text: string, field: string): string | undefined {
|
||||
const pattern = new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`);
|
||||
const match = pattern.exec(text);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(`"${match[1]}"`) as string;
|
||||
} catch {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
function extractNumberField(text: string, field: string): number | undefined {
|
||||
const pattern = new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`);
|
||||
const match = pattern.exec(text);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const value = Number(match[1]);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function extractToolText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
setSkillState,
|
||||
validateSkillUpload,
|
||||
} from "./api";
|
||||
import type { SkillState } from "./type";
|
||||
import type { Skill, SkillState } from "./type";
|
||||
|
||||
import { loadSkills } from ".";
|
||||
|
||||
@@ -77,7 +77,13 @@ export function useSetSkillDisplay() {
|
||||
display: Parameters<typeof setSkillDisplay>[1];
|
||||
sample?: Parameters<typeof setSkillDisplay>[2];
|
||||
}) => setSkillDisplay(skillName, display, sample),
|
||||
onSuccess: (_data, { skillName }) => {
|
||||
onSuccess: (data, { skillName }) => {
|
||||
queryClient.setQueryData(["skill", skillName], data);
|
||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||
Array.isArray(current)
|
||||
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
|
||||
: current,
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
},
|
||||
|
||||
@@ -36,6 +36,7 @@ from deerflow.skills.types import SKILL_MD_FILE, SkillCategory
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["skills"])
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -221,7 +222,7 @@ def _skill_to_response(
|
||||
skill: Skill,
|
||||
ownership: dict[str, Any] | None = None,
|
||||
*,
|
||||
display_override: dict[str, Any] | None = None,
|
||||
display_override: dict[str, Any] | None | object = _UNSET,
|
||||
display_sample: dict[str, Any] | None = None,
|
||||
) -> SkillResponse:
|
||||
"""Convert a Skill object to a SkillResponse.
|
||||
@@ -252,8 +253,8 @@ def _skill_to_response(
|
||||
builtin = bool(ownership.get("builtin", owner_user_id is None))
|
||||
detail = ownership.get("detail")
|
||||
|
||||
display = display_override
|
||||
if display is None:
|
||||
display = None if display_override is _UNSET else display_override
|
||||
if display_override is _UNSET:
|
||||
try:
|
||||
skill_config = get_extensions_config().skills.get(skill.name)
|
||||
if skill_config and skill_config.display:
|
||||
@@ -318,6 +319,10 @@ def _json_text_dumps(value: dict[str, Any] | None) -> str | None:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _display_override_from_adapter(adapter: dict[str, Any] | None) -> dict[str, Any] | None | object:
|
||||
return adapter.get("display") if adapter is not None else _UNSET
|
||||
|
||||
|
||||
async def _get_skill_display_adapter(skill_name: str) -> dict[str, Any] | None:
|
||||
if not await _ensure_skill_display_table():
|
||||
return None
|
||||
@@ -698,7 +703,7 @@ async def list_skills(
|
||||
display_adapters = await _list_skill_display_adapters([skill.name for skill in all_skills])
|
||||
responses: list[SkillResponse] = []
|
||||
for skill in all_skills:
|
||||
adapter = display_adapters.get(skill.name, {})
|
||||
adapter = display_adapters.get(skill.name)
|
||||
# Public/built-in skills (no DB row) are visible to everyone.
|
||||
# Custom skills must have an ownership record in the visible set.
|
||||
if skill.category != SkillCategory.CUSTOM:
|
||||
@@ -708,8 +713,8 @@ async def list_skills(
|
||||
_skill_to_response(
|
||||
skill,
|
||||
ownership.get(skill.name),
|
||||
display_override=adapter.get("display"),
|
||||
display_sample=adapter.get("sample"),
|
||||
display_override=_display_override_from_adapter(adapter),
|
||||
display_sample=adapter.get("sample") if adapter else None,
|
||||
)
|
||||
)
|
||||
continue
|
||||
@@ -720,8 +725,8 @@ async def list_skills(
|
||||
_skill_to_response(
|
||||
skill,
|
||||
record,
|
||||
display_override=adapter.get("display"),
|
||||
display_sample=adapter.get("sample"),
|
||||
display_override=_display_override_from_adapter(adapter),
|
||||
display_sample=adapter.get("sample") if adapter else None,
|
||||
)
|
||||
)
|
||||
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
|
||||
@@ -984,16 +989,19 @@ async def list_custom_skills(
|
||||
custom = [s for s in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM]
|
||||
ownership = await _build_ownership_index(skill_store, user_id)
|
||||
display_adapters = await _list_skill_display_adapters([skill.name for skill in custom])
|
||||
responses = [
|
||||
_skill_to_response(
|
||||
skill,
|
||||
ownership[skill.name],
|
||||
display_override=display_adapters.get(skill.name, {}).get("display"),
|
||||
display_sample=display_adapters.get(skill.name, {}).get("sample"),
|
||||
responses = []
|
||||
for skill in custom:
|
||||
if skill.name not in ownership:
|
||||
continue
|
||||
adapter = display_adapters.get(skill.name)
|
||||
responses.append(
|
||||
_skill_to_response(
|
||||
skill,
|
||||
ownership[skill.name],
|
||||
display_override=_display_override_from_adapter(adapter),
|
||||
display_sample=adapter.get("sample") if adapter else None,
|
||||
)
|
||||
)
|
||||
for skill in custom
|
||||
if skill.name in ownership
|
||||
]
|
||||
return SkillsListResponse(skills=responses)
|
||||
except Exception as e:
|
||||
logger.error("Failed to list custom skills: %s", e, exc_info=True)
|
||||
@@ -1008,7 +1016,15 @@ async def get_custom_skill(skill_name: str, config: AppConfig = Depends(get_conf
|
||||
skill = next((s for s in skills if s.name == skill_name and s.category == SkillCategory.CUSTOM), None)
|
||||
if skill is None:
|
||||
raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
|
||||
return CustomSkillContentResponse(**_skill_to_response(skill).model_dump(), content=get_or_new_skill_storage(app_config=config).read_custom_skill(skill_name))
|
||||
adapter = await _get_skill_display_adapter(skill_name)
|
||||
return CustomSkillContentResponse(
|
||||
**_skill_to_response(
|
||||
skill,
|
||||
display_override=_display_override_from_adapter(adapter),
|
||||
display_sample=adapter.get("sample") if adapter else None,
|
||||
).model_dump(),
|
||||
content=get_or_new_skill_storage(app_config=config).read_custom_skill(skill_name),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -1365,7 +1381,13 @@ async def get_skill(
|
||||
|
||||
# Attach ownership (owner/published/detail) when the caller may see it.
|
||||
ownership = await skill_store.get_visible(skill_name, _current_user_id(request))
|
||||
return _skill_to_response(skill, ownership)
|
||||
adapter = await _get_skill_display_adapter(skill_name)
|
||||
return _skill_to_response(
|
||||
skill,
|
||||
ownership,
|
||||
display_override=_display_override_from_adapter(adapter),
|
||||
display_sample=adapter.get("sample") if adapter else None,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -1944,11 +1966,11 @@ async def update_skill_display(
|
||||
updated_skill = next((s for s in skills if s.name == skill_name), None)
|
||||
if updated_skill is None:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after display update")
|
||||
adapter = await _get_skill_display_adapter(skill_name) or {}
|
||||
adapter = await _get_skill_display_adapter(skill_name)
|
||||
return _skill_to_response(
|
||||
updated_skill,
|
||||
display_override=adapter.get("display"),
|
||||
display_sample=adapter.get("sample"),
|
||||
display_override=_display_override_from_adapter(adapter),
|
||||
display_sample=adapter.get("sample") if adapter else None,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
Reference in new issue
Block a user