技能展示适配:支持多结果集(result sets)渲染
- 后端 extensions_config 新增 SkillDisplayResultSetConfig,SkillDisplayConfig 增加 resultSets 字段 - threads 引用响应增加 resultSetKey / resultSetLabel,runtime/references 适配多结果集 - 前端 skill-display 适配器/运行时/类型支持多结果集渲染,聊天框、消息组、技能展示面板相应更新 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
00e74a5e45
commit
c860ee85f7
10 files changed
+517
-78
No files matched your search
@@ -57,6 +57,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dayjs": "^1.11.21",
|
||||||
"docx": "^9.5.1",
|
"docx": "^9.5.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ import { apiFetch } from "@/core/api";
|
|||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||||
|
import {
|
||||||
|
displayConfigFromResultSet,
|
||||||
|
normalizeDisplaySources,
|
||||||
|
} from "@/core/skill-display/display-adapter";
|
||||||
import {
|
import {
|
||||||
buildSkillDisplayMap,
|
buildSkillDisplayMap,
|
||||||
collectRuntimeDisplaySources,
|
collectRuntimeDisplaySources,
|
||||||
@@ -603,9 +607,10 @@ function adaptBackendSourceWithSkillDisplay(
|
|||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const display = findDisplayConfigForBackendSource(source, match.display);
|
||||||
const normalized = normalizeDisplaySources(
|
const normalized = normalizeDisplaySources(
|
||||||
buildPayloadAtPath(match.display.resultPath || "results", [rawItem]),
|
buildPayloadAtPath(display.resultPath || "results", [rawItem]),
|
||||||
match.display,
|
display,
|
||||||
)[0];
|
)[0];
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return null;
|
return null;
|
||||||
@@ -614,13 +619,27 @@ function adaptBackendSourceWithSkillDisplay(
|
|||||||
...source,
|
...source,
|
||||||
...normalized,
|
...normalized,
|
||||||
skillName: match.skillName,
|
skillName: match.skillName,
|
||||||
mode: match.display.mode,
|
mode: display.mode,
|
||||||
tableColumns: match.display.tableColumns,
|
tableColumns: display.tableColumns,
|
||||||
index: source.index,
|
index: source.index,
|
||||||
raw: source.raw,
|
raw: source.raw,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findDisplayConfigForBackendSource(
|
||||||
|
source: BackendReferenceSource,
|
||||||
|
display: SkillDisplayConfig,
|
||||||
|
) {
|
||||||
|
const resultSetKey = source.resultSetKey;
|
||||||
|
if (resultSetKey) {
|
||||||
|
const resultSet = display.resultSets?.find((item) => item.key === resultSetKey);
|
||||||
|
if (resultSet) {
|
||||||
|
return displayConfigFromResultSet(display, resultSet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...display, resultSets: [] };
|
||||||
|
}
|
||||||
|
|
||||||
function findBackendSourceDisplay(
|
function findBackendSourceDisplay(
|
||||||
source: BackendReferenceSource,
|
source: BackendReferenceSource,
|
||||||
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
|
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
|
||||||
|
|||||||
@@ -258,12 +258,12 @@ function ToolCall({
|
|||||||
null);
|
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) => ({
|
||||||
...item,
|
...item,
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
skillName: displayMatch.skillName,
|
skillName: displayMatch.skillName,
|
||||||
mode: displayMatch.display.mode,
|
mode: item.displayMode ?? displayMatch.display.mode,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
if (displayItems.length > 0) {
|
if (displayItems.length > 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
|
displayConfigFromResultSet,
|
||||||
emptyDisplayConfig,
|
emptyDisplayConfig,
|
||||||
inferDisplayConfig,
|
inferDisplayConfig,
|
||||||
normalizeDisplaySources,
|
normalizeDisplaySources,
|
||||||
@@ -27,9 +28,29 @@ import {
|
|||||||
getSkillDisplaySample,
|
getSkillDisplaySample,
|
||||||
} from "@/core/skills/api";
|
} from "@/core/skills/api";
|
||||||
import { useSetSkillDisplay } from "@/core/skills/hooks";
|
import { useSetSkillDisplay } from "@/core/skills/hooks";
|
||||||
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
|
import type {
|
||||||
|
Skill,
|
||||||
|
SkillDisplayConfig,
|
||||||
|
SkillDisplayMode,
|
||||||
|
SkillDisplayResultSetConfig,
|
||||||
|
SkillLinkStrategy,
|
||||||
|
} from "@/core/skills/type";
|
||||||
|
|
||||||
const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
|
type DisplayFieldKey =
|
||||||
|
| "resultPath"
|
||||||
|
| "titleField"
|
||||||
|
| "summaryField"
|
||||||
|
| "contentField"
|
||||||
|
| "sourceField"
|
||||||
|
| "authorField"
|
||||||
|
| "typeField"
|
||||||
|
| "timeField"
|
||||||
|
| "scoreField"
|
||||||
|
| "urlField"
|
||||||
|
| "idField"
|
||||||
|
| "linkTemplate";
|
||||||
|
|
||||||
|
const fieldLabels: Array<[DisplayFieldKey, string]> = [
|
||||||
["resultPath", "结果列表路径"],
|
["resultPath", "结果列表路径"],
|
||||||
["titleField", "标题字段"],
|
["titleField", "标题字段"],
|
||||||
["summaryField", "摘要字段"],
|
["summaryField", "摘要字段"],
|
||||||
@@ -71,14 +92,56 @@ function formatSample(sample: Record<string, unknown> | null | undefined) {
|
|||||||
return sample ? JSON.stringify(sample, null, 2) : "";
|
return sample ? JSON.stringify(sample, null, 2) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firstResultSetKey(display: SkillDisplayConfig | null | undefined) {
|
||||||
|
return display?.resultSets?.[0]?.key ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const setDisplay = useSetSkillDisplay();
|
const setDisplay = useSetSkillDisplay();
|
||||||
const lastSkillNameRef = useRef(skill.name);
|
const lastSkillNameRef = useRef(skill.name);
|
||||||
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
|
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
|
||||||
|
const [activeResultSetKey, setActiveResultSetKey] = useState<string | null>(() =>
|
||||||
|
firstResultSetKey(skill.display),
|
||||||
|
);
|
||||||
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(() => {
|
||||||
|
const display = skill.display ?? emptyDisplayConfig();
|
||||||
|
const resultSet = display.resultSets?.[0];
|
||||||
|
return JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2);
|
||||||
|
});
|
||||||
const [loadingDbConfig, setLoadingDbConfig] = useState(false);
|
const [loadingDbConfig, setLoadingDbConfig] = useState(false);
|
||||||
|
const resultSets = draft.resultSets ?? [];
|
||||||
|
const activeResultSet =
|
||||||
|
resultSets.find((resultSet) => resultSet.key === activeResultSetKey) ?? null;
|
||||||
|
const editingConfig = activeResultSet ?? draft;
|
||||||
|
const previewConfig = activeResultSet
|
||||||
|
? displayConfigFromResultSet(draft, activeResultSet)
|
||||||
|
: draft;
|
||||||
|
|
||||||
|
function updateEditingConfig<K extends keyof SkillDisplayResultSetConfig>(
|
||||||
|
key: K,
|
||||||
|
value: SkillDisplayResultSetConfig[K],
|
||||||
|
) {
|
||||||
|
if (!activeResultSet) {
|
||||||
|
setDraft(updateConfig(draft, key as keyof SkillDisplayConfig, value as never));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
resultSets: resultSets.map((resultSet) =>
|
||||||
|
resultSet.key === activeResultSet.key
|
||||||
|
? { ...resultSet, [key]: value }
|
||||||
|
: resultSet,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectResultSet(key: string) {
|
||||||
|
const resultSet = resultSets.find((item) => item.key === key) ?? null;
|
||||||
|
setActiveResultSetKey(resultSet?.key ?? null);
|
||||||
|
setTableColumnsText(JSON.stringify((resultSet ?? draft).tableColumns ?? [], null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
// 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,7 +151,9 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const display = skill.display ?? emptyDisplayConfig();
|
const display = skill.display ?? emptyDisplayConfig();
|
||||||
setDraft(display);
|
setDraft(display);
|
||||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
const resultSet = display.resultSets?.[0];
|
||||||
|
setActiveResultSetKey(resultSet?.key ?? null);
|
||||||
|
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
|
||||||
}, [skill.name, skill.display]);
|
}, [skill.name, skill.display]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -101,8 +166,10 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const display = fresh.display ?? emptyDisplayConfig();
|
const display = fresh.display ?? emptyDisplayConfig();
|
||||||
|
const resultSet = display.resultSets?.[0];
|
||||||
setDraft(display);
|
setDraft(display);
|
||||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
setActiveResultSetKey(resultSet?.key ?? null);
|
||||||
|
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
|
||||||
setSampleText(formatSample(fresh.displaySample));
|
setSampleText(formatSample(fresh.displaySample));
|
||||||
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], fresh);
|
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], fresh);
|
||||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||||
@@ -186,8 +253,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
}, [sampleText]);
|
}, [sampleText]);
|
||||||
|
|
||||||
const previewItems = useMemo(() => (
|
const previewItems = useMemo(() => (
|
||||||
samplePayload ? normalizeDisplaySources(samplePayload, draft) : []
|
samplePayload ? normalizeDisplaySources(samplePayload, previewConfig) : []
|
||||||
), [draft, samplePayload]);
|
), [previewConfig, samplePayload]);
|
||||||
|
|
||||||
function generateDraft() {
|
function generateDraft() {
|
||||||
if (!samplePayload) {
|
if (!samplePayload) {
|
||||||
@@ -196,8 +263,14 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
}
|
}
|
||||||
const nextDraft = inferDisplayConfig(samplePayload, skill.name);
|
const nextDraft = inferDisplayConfig(samplePayload, skill.name);
|
||||||
setDraft(nextDraft);
|
setDraft(nextDraft);
|
||||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
const resultSet = nextDraft.resultSets?.[0];
|
||||||
toast.success("已生成展示适配草稿");
|
setActiveResultSetKey(resultSet?.key ?? null);
|
||||||
|
setTableColumnsText(JSON.stringify((resultSet ?? nextDraft).tableColumns ?? [], null, 2));
|
||||||
|
toast.success(
|
||||||
|
resultSet
|
||||||
|
? `已识别 ${nextDraft.resultSets?.length ?? 0} 个结果集,可分别配置`
|
||||||
|
: "已生成展示适配草稿",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSampleAndGenerate() {
|
async function loadSampleAndGenerate() {
|
||||||
@@ -219,8 +292,14 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
);
|
);
|
||||||
const nextDraft = inferDisplayConfig(response.sample, skill.name);
|
const nextDraft = inferDisplayConfig(response.sample, skill.name);
|
||||||
setDraft(nextDraft);
|
setDraft(nextDraft);
|
||||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
const resultSet = nextDraft.resultSets?.[0];
|
||||||
toast.success("已获取样例并生成展示适配");
|
setActiveResultSetKey(resultSet?.key ?? null);
|
||||||
|
setTableColumnsText(JSON.stringify((resultSet ?? nextDraft).tableColumns ?? [], null, 2));
|
||||||
|
toast.success(
|
||||||
|
resultSet
|
||||||
|
? `已获取样例,并识别 ${nextDraft.resultSets?.length ?? 0} 个结果集`
|
||||||
|
: "已获取样例并生成展示适配",
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
generateDraft();
|
generateDraft();
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
@@ -235,8 +314,10 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
sample: samplePayload ?? undefined,
|
sample: samplePayload ?? undefined,
|
||||||
});
|
});
|
||||||
const display = saved.display ?? emptyDisplayConfig();
|
const display = saved.display ?? emptyDisplayConfig();
|
||||||
|
const resultSet = display.resultSets?.find((item) => item.key === activeResultSetKey) ?? display.resultSets?.[0];
|
||||||
setDraft(display);
|
setDraft(display);
|
||||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
setActiveResultSetKey(resultSet?.key ?? null);
|
||||||
|
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
|
||||||
setSampleText(formatSample(saved.displaySample));
|
setSampleText(formatSample(saved.displaySample));
|
||||||
toast.success("展示适配已保存");
|
toast.success("展示适配已保存");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -248,6 +329,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
try {
|
try {
|
||||||
await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
|
await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
|
||||||
setDraft(emptyDisplayConfig());
|
setDraft(emptyDisplayConfig());
|
||||||
|
setActiveResultSetKey(null);
|
||||||
setTableColumnsText(JSON.stringify([], null, 2));
|
setTableColumnsText(JSON.stringify([], null, 2));
|
||||||
toast.success("展示适配已清除");
|
toast.success("展示适配已清除");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -283,8 +365,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
<label className="space-y-1.5">
|
<label className="space-y-1.5">
|
||||||
<span className="text-xs text-muted-foreground">展示方式</span>
|
<span className="text-xs text-muted-foreground">展示方式</span>
|
||||||
<Select
|
<Select
|
||||||
value={draft.mode}
|
value={editingConfig.mode}
|
||||||
onValueChange={(value) => setDraft(updateConfig(draft, "mode", value as SkillDisplayMode))}
|
onValueChange={(value) => updateEditingConfig("mode", value as SkillDisplayMode)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -300,8 +382,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
<label className="space-y-1.5">
|
<label className="space-y-1.5">
|
||||||
<span className="text-xs text-muted-foreground">跳转方式</span>
|
<span className="text-xs text-muted-foreground">跳转方式</span>
|
||||||
<Select
|
<Select
|
||||||
value={draft.linkStrategy}
|
value={editingConfig.linkStrategy}
|
||||||
onValueChange={(value) => setDraft(updateConfig(draft, "linkStrategy", value as SkillLinkStrategy))}
|
onValueChange={(value) => updateEditingConfig("linkStrategy", value as SkillLinkStrategy)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -320,26 +402,58 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
value={draft.maxItems}
|
value={editingConfig.maxItems}
|
||||||
onChange={(event) => setDraft(updateConfig(draft, "maxItems", Number(event.currentTarget.value) || 1))}
|
onChange={(event) => updateEditingConfig("maxItems", Number(event.currentTarget.value) || 1)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{resultSets.length > 0 && (
|
||||||
|
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
|
||||||
|
<label className="space-y-1.5">
|
||||||
|
<span className="text-xs text-muted-foreground">结果集配置</span>
|
||||||
|
<Select
|
||||||
|
value={activeResultSet?.key ?? resultSets[0]?.key}
|
||||||
|
onValueChange={selectResultSet}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{resultSets.map((resultSet) => (
|
||||||
|
<SelectItem key={resultSet.key} value={resultSet.key}>
|
||||||
|
{resultSet.label || resultSet.resultPath}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
{activeResultSet && (
|
||||||
|
<label className="flex items-center justify-end gap-2 pb-2">
|
||||||
|
<span className="text-xs text-muted-foreground">启用当前结果集</span>
|
||||||
|
<Switch
|
||||||
|
checked={activeResultSet.enabled}
|
||||||
|
onCheckedChange={(checked) => updateEditingConfig("enabled", checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
{fieldLabels.map(([field, label]) => (
|
{fieldLabels.map(([field, label]) => (
|
||||||
<label key={field} className="space-y-1.5">
|
<label key={field} className="space-y-1.5">
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
<Input
|
<Input
|
||||||
value={(draft[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
|
value={(editingConfig[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
|
||||||
onChange={(event) => setDraft(updateConfig(draft, field, event.currentTarget.value as never))}
|
onChange={(event) => updateEditingConfig(field, event.currentTarget.value as never)}
|
||||||
placeholder={field === "linkTemplate" ? "https://xxx.xxx.xxx?recuuid={recUuid}" : undefined}
|
placeholder={field === "linkTemplate" ? "https://xxx.xxx.xxx?recuuid={recUuid}" : undefined}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{draft.mode === "table" && (
|
{editingConfig.mode === "table" && (
|
||||||
<label className="mt-4 block space-y-1.5">
|
<label className="mt-4 block space-y-1.5">
|
||||||
<span className="text-xs text-muted-foreground">表格列配置 JSON</span>
|
<span className="text-xs text-muted-foreground">表格列配置 JSON</span>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -349,7 +463,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
try {
|
try {
|
||||||
const value = JSON.parse(event.currentTarget.value);
|
const value = JSON.parse(event.currentTarget.value);
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
setDraft(updateConfig(draft, "tableColumns", value));
|
updateEditingConfig("tableColumns", value);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Keep typing invalid JSON without crashing the panel.
|
// Keep typing invalid JSON without crashing the panel.
|
||||||
@@ -361,12 +475,12 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{draft.mode === "custom" && (
|
{editingConfig.mode === "custom" && (
|
||||||
<label className="mt-4 block space-y-1.5">
|
<label className="mt-4 block space-y-1.5">
|
||||||
<span className="text-xs text-muted-foreground">自定义展示描述</span>
|
<span className="text-xs text-muted-foreground">自定义展示描述</span>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={draft.customInstruction}
|
value={editingConfig.customInstruction}
|
||||||
onChange={(event) => setDraft(updateConfig(draft, "customInstruction", event.currentTarget.value))}
|
onChange={(event) => updateEditingConfig("customInstruction", event.currentTarget.value)}
|
||||||
placeholder="例如:按 publish_time 时间倒序显示为时间线,每条包含标题、摘要、来源和查看原文按钮。"
|
placeholder="例如:按 publish_time 时间倒序显示为时间线,每条包含标题、摘要、来源和查看原文按钮。"
|
||||||
className="min-h-24"
|
className="min-h-24"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
import type {
|
||||||
|
SkillDisplayConfig,
|
||||||
|
SkillDisplayResultSetConfig,
|
||||||
|
} from "@/core/skills/type";
|
||||||
|
|
||||||
const DEFAULT_CITATION_MAX_ITEMS = 100;
|
const DEFAULT_CITATION_MAX_ITEMS = 100;
|
||||||
const DEFAULT_LIST_MAX_ITEMS = 20;
|
const DEFAULT_LIST_MAX_ITEMS = 20;
|
||||||
@@ -15,6 +18,10 @@ export interface DisplaySourceItem {
|
|||||||
url?: string;
|
url?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
score?: number;
|
score?: number;
|
||||||
|
displayMode?: SkillDisplayConfig["mode"];
|
||||||
|
resultSetKey?: string;
|
||||||
|
resultSetLabel?: string | null;
|
||||||
|
tableColumns?: SkillDisplayConfig["tableColumns"];
|
||||||
raw: Record<string, unknown>;
|
raw: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +45,7 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
|
|||||||
maxItems: DEFAULT_CITATION_MAX_ITEMS,
|
maxItems: DEFAULT_CITATION_MAX_ITEMS,
|
||||||
customInstruction: "",
|
customInstruction: "",
|
||||||
tableColumns: [],
|
tableColumns: [],
|
||||||
|
resultSets: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const candidateFields: Record<string, string[]> = {
|
const candidateFields: Record<string, string[]> = {
|
||||||
@@ -53,7 +61,7 @@ const candidateFields: Record<string, string[]> = {
|
|||||||
idField: ["recUuid", "rec_uuid", "uuid", "docId", "taskId", "recordId", "id"],
|
idField: ["recUuid", "rec_uuid", "uuid", "docId", "taskId", "recordId", "id"],
|
||||||
};
|
};
|
||||||
|
|
||||||
function getByPath(value: unknown, path: string): unknown {
|
function getByPath(value: unknown, path: string | null | undefined): unknown {
|
||||||
if (!path) return value;
|
if (!path) return value;
|
||||||
return path.split(".").reduce<unknown>((acc, key) => {
|
return path.split(".").reduce<unknown>((acc, key) => {
|
||||||
if (acc && typeof acc === "object") {
|
if (acc && typeof acc === "object") {
|
||||||
@@ -63,19 +71,55 @@ function getByPath(value: unknown, path: string): unknown {
|
|||||||
}, value);
|
}, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findFirstArray(
|
export type ResultArrayCandidate = {
|
||||||
value: unknown,
|
key: string;
|
||||||
path = "",
|
label: string;
|
||||||
): { path: string; items: Record<string, unknown>[] } | null {
|
path: string;
|
||||||
if (Array.isArray(value) && value.every((item) => item && typeof item === "object")) {
|
items: Record<string, unknown>[];
|
||||||
return { path, items: value as Record<string, unknown>[] };
|
};
|
||||||
|
|
||||||
|
export function findResultArrayCandidates(samplePayload: unknown): ResultArrayCandidate[] {
|
||||||
|
const candidates: ResultArrayCandidate[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
function visit(value: unknown, path = "", depth = 0) {
|
||||||
|
if (depth > 8 || candidates.length >= 20) return;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const objectItems = value.filter(
|
||||||
|
(item): item is Record<string, unknown> =>
|
||||||
|
!!item && typeof item === "object" && !Array.isArray(item),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(value.length === 0 || objectItems.length > 0) &&
|
||||||
|
objectItems.length === value.length &&
|
||||||
|
!seen.has(path)
|
||||||
|
) {
|
||||||
|
seen.add(path);
|
||||||
|
candidates.push({
|
||||||
|
key: buildResultSetKey(path || "root"),
|
||||||
|
label: buildResultSetLabel(path || "根数组", objectItems.length),
|
||||||
|
path,
|
||||||
|
items: objectItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
visit(child, path ? `${path}.${key}` : key, depth + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!value || typeof value !== "object") return null;
|
|
||||||
for (const [key, child] of Object.entries(value)) {
|
visit(samplePayload);
|
||||||
const found = findFirstArray(child, path ? `${path}.${key}` : key);
|
return candidates;
|
||||||
if (found) return found;
|
}
|
||||||
}
|
|
||||||
return null;
|
function buildResultSetKey(path: string): string {
|
||||||
|
return path.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "root";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResultSetLabel(path: string, count: number): string {
|
||||||
|
return `${path}(${count} 条)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickField(sample: Record<string, unknown>, candidates: string[]): string | null {
|
function pickField(sample: Record<string, unknown>, candidates: string[]): string | null {
|
||||||
@@ -93,13 +137,31 @@ function pickField(sample: Record<string, unknown>, candidates: string[]): strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function inferDisplayConfig(samplePayload: unknown, skillName?: string): SkillDisplayConfig {
|
export function inferDisplayConfig(samplePayload: unknown, skillName?: string): SkillDisplayConfig {
|
||||||
const found = findFirstArray(samplePayload) ?? { path: "results", items: [] };
|
const candidates = findResultArrayCandidates(samplePayload);
|
||||||
const sample = found.items[0] ?? {};
|
const found = candidates[0] ?? {
|
||||||
|
key: "results",
|
||||||
|
label: "results(0 条)",
|
||||||
|
path: "results",
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
const inferred = buildConfigFromCandidate(found, skillName);
|
||||||
|
inferred.resultSets =
|
||||||
|
candidates.length > 1
|
||||||
|
? candidates.map((candidate) => buildResultSetConfigFromCandidate(candidate, skillName))
|
||||||
|
: [];
|
||||||
|
return inferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfigFromCandidate(
|
||||||
|
candidate: ResultArrayCandidate,
|
||||||
|
skillName?: string,
|
||||||
|
): SkillDisplayConfig {
|
||||||
|
const sample = candidate.items[0] ?? {};
|
||||||
const inferred = emptyDisplayConfig();
|
const inferred = emptyDisplayConfig();
|
||||||
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 = candidate.path || "results";
|
||||||
inferred.maxItems = inferMaxItems(found.items.length, inferred.mode);
|
inferred.maxItems = inferMaxItems(candidate.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);
|
||||||
@@ -123,6 +185,65 @@ export function inferDisplayConfig(samplePayload: unknown, skillName?: string):
|
|||||||
return inferred;
|
return inferred;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildResultSetConfigFromCandidate(
|
||||||
|
candidate: ResultArrayCandidate,
|
||||||
|
skillName?: string,
|
||||||
|
): SkillDisplayResultSetConfig {
|
||||||
|
const config = buildConfigFromCandidate(candidate, skillName);
|
||||||
|
return {
|
||||||
|
key: candidate.key,
|
||||||
|
label: candidate.label,
|
||||||
|
enabled: true,
|
||||||
|
mode: config.mode,
|
||||||
|
resultPath: config.resultPath,
|
||||||
|
titleField: config.titleField,
|
||||||
|
summaryField: config.summaryField,
|
||||||
|
contentField: config.contentField,
|
||||||
|
sourceField: config.sourceField,
|
||||||
|
authorField: config.authorField,
|
||||||
|
typeField: config.typeField,
|
||||||
|
timeField: config.timeField,
|
||||||
|
scoreField: config.scoreField,
|
||||||
|
urlField: config.urlField,
|
||||||
|
idField: config.idField,
|
||||||
|
linkStrategy: config.linkStrategy,
|
||||||
|
linkTemplate: config.linkTemplate,
|
||||||
|
citationToken: config.citationToken,
|
||||||
|
maxItems: config.maxItems,
|
||||||
|
customInstruction: config.customInstruction,
|
||||||
|
tableColumns: config.tableColumns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayConfigFromResultSet(
|
||||||
|
base: SkillDisplayConfig,
|
||||||
|
resultSet: SkillDisplayResultSetConfig,
|
||||||
|
): SkillDisplayConfig {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
enabled: resultSet.enabled,
|
||||||
|
mode: resultSet.mode,
|
||||||
|
resultPath: resultSet.resultPath,
|
||||||
|
titleField: resultSet.titleField,
|
||||||
|
summaryField: resultSet.summaryField,
|
||||||
|
contentField: resultSet.contentField,
|
||||||
|
sourceField: resultSet.sourceField,
|
||||||
|
authorField: resultSet.authorField,
|
||||||
|
typeField: resultSet.typeField,
|
||||||
|
timeField: resultSet.timeField,
|
||||||
|
scoreField: resultSet.scoreField,
|
||||||
|
urlField: resultSet.urlField,
|
||||||
|
idField: resultSet.idField,
|
||||||
|
linkStrategy: resultSet.linkStrategy,
|
||||||
|
linkTemplate: resultSet.linkTemplate,
|
||||||
|
citationToken: resultSet.citationToken,
|
||||||
|
maxItems: resultSet.maxItems,
|
||||||
|
customInstruction: resultSet.customInstruction,
|
||||||
|
tableColumns: resultSet.tableColumns,
|
||||||
|
resultSets: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildDefaultTableColumns(
|
function buildDefaultTableColumns(
|
||||||
sample: Record<string, unknown>,
|
sample: Record<string, unknown>,
|
||||||
config: SkillDisplayConfig,
|
config: SkillDisplayConfig,
|
||||||
@@ -161,13 +282,13 @@ function stringify(value: unknown, fallback = ""): string {
|
|||||||
|
|
||||||
function renderTemplate(template: string, item: Record<string, unknown>): string {
|
function renderTemplate(template: string, item: Record<string, unknown>): string {
|
||||||
return template.replace(/\{([^}]+)\}/g, (_match, key: string) =>
|
return template.replace(/\{([^}]+)\}/g, (_match, key: string) =>
|
||||||
encodeURIComponent(stringify(item[key])),
|
encodeURIComponent(stringify(getByPath(item, key.trim()))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined {
|
function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined {
|
||||||
const url = config.urlField ? stringify(item[config.urlField]) : "";
|
const url = config.urlField ? stringify(getByPath(item, config.urlField)) : "";
|
||||||
const id = config.idField ? stringify(item[config.idField]) : "";
|
const id = config.idField ? stringify(getByPath(item, config.idField)) : "";
|
||||||
if (config.linkStrategy === "url") return url || undefined;
|
if (config.linkStrategy === "url") return url || undefined;
|
||||||
if (config.linkStrategy === "id") {
|
if (config.linkStrategy === "id") {
|
||||||
return id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined;
|
return id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined;
|
||||||
@@ -176,6 +297,30 @@ 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 configuredResultSets = config.resultSets ?? [];
|
||||||
|
const resultSets = configuredResultSets.filter(
|
||||||
|
(resultSet) => resultSet.enabled && resultSet.mode !== "none",
|
||||||
|
);
|
||||||
|
if (configuredResultSets.length > 0) {
|
||||||
|
return resultSets.flatMap((resultSet) =>
|
||||||
|
normalizeDisplaySourcesForConfig(
|
||||||
|
payload,
|
||||||
|
displayConfigFromResultSet(config, resultSet),
|
||||||
|
resultSet,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!config.enabled || config.mode === "none") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return normalizeDisplaySourcesForConfig(payload, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDisplaySourcesForConfig(
|
||||||
|
payload: unknown,
|
||||||
|
config: SkillDisplayConfig,
|
||||||
|
resultSet?: SkillDisplayResultSetConfig,
|
||||||
|
): 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);
|
const maxItems = resolveMaxItems(config, rawItems.length);
|
||||||
@@ -183,19 +328,23 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
|
|||||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
|
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
|
||||||
.slice(0, maxItems)
|
.slice(0, maxItems)
|
||||||
.map((item, index) => ({
|
.map((item, index) => ({
|
||||||
id: config.idField ? stringify(item[config.idField]) : undefined,
|
id: config.idField ? stringify(getByPath(item, config.idField)) : undefined,
|
||||||
title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`,
|
title: config.titleField ? stringify(getByPath(item, config.titleField), `结果 ${index + 1}`) : `结果 ${index + 1}`,
|
||||||
type: config.typeField ? stringify(item[config.typeField], "result") : "result",
|
type: config.typeField ? stringify(getByPath(item, config.typeField), "result") : "result",
|
||||||
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
|
snippet: config.summaryField ? stringify(getByPath(item, config.summaryField)) : "",
|
||||||
content: config.contentField ? stringify(item[config.contentField]) : undefined,
|
content: config.contentField ? stringify(getByPath(item, config.contentField)) : undefined,
|
||||||
source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
|
source: config.sourceField ? stringify(getByPath(item, config.sourceField)) : undefined,
|
||||||
author: config.authorField ? stringify(item[config.authorField]) : undefined,
|
author: config.authorField ? stringify(getByPath(item, config.authorField)) : undefined,
|
||||||
time: config.timeField ? stringify(item[config.timeField]) : undefined,
|
time: config.timeField ? stringify(getByPath(item, config.timeField)) : undefined,
|
||||||
score:
|
score:
|
||||||
config.scoreField && typeof item[config.scoreField] === "number"
|
config.scoreField && typeof getByPath(item, config.scoreField) === "number"
|
||||||
? (item[config.scoreField] as number)
|
? (getByPath(item, config.scoreField) as number)
|
||||||
: undefined,
|
: undefined,
|
||||||
url: resolveLink(item, config),
|
url: resolveLink(item, config),
|
||||||
|
displayMode: config.mode,
|
||||||
|
resultSetKey: resultSet?.key,
|
||||||
|
resultSetLabel: resultSet?.label,
|
||||||
|
tableColumns: config.tableColumns,
|
||||||
raw: item,
|
raw: item,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,8 +202,8 @@ export function collectRuntimeDisplaySources(
|
|||||||
normalized.map((item) => ({
|
normalized.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
skillName: match?.skillName ?? toolCall.name,
|
skillName: match?.skillName ?? toolCall.name,
|
||||||
mode: effectiveMode,
|
mode: item.displayMode ?? effectiveMode,
|
||||||
tableColumns: match?.display.tableColumns,
|
tableColumns: item.tableColumns ?? match?.display.tableColumns,
|
||||||
})),
|
})),
|
||||||
seenCitationKeys,
|
seenCitationKeys,
|
||||||
() => ++citationIndex,
|
() => ++citationIndex,
|
||||||
@@ -242,8 +242,8 @@ export function collectRuntimeDisplaySources(
|
|||||||
normalized.map((item) => ({
|
normalized.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
skillName: match?.skillName ?? toolName,
|
skillName: match?.skillName ?? toolName,
|
||||||
mode: effectiveMode,
|
mode: item.displayMode ?? effectiveMode,
|
||||||
tableColumns: match?.display.tableColumns,
|
tableColumns: item.tableColumns ?? match?.display.tableColumns,
|
||||||
})),
|
})),
|
||||||
seenCitationKeys,
|
seenCitationKeys,
|
||||||
() => ++citationIndex,
|
() => ++citationIndex,
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export interface SkillDisplayTableColumn {
|
|||||||
width?: string | null;
|
width?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillDisplayConfig {
|
export interface SkillDisplayResultSetConfig {
|
||||||
|
key: string;
|
||||||
|
label?: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
mode: SkillDisplayMode;
|
mode: SkillDisplayMode;
|
||||||
resultPath: string;
|
resultPath: string;
|
||||||
@@ -34,6 +36,10 @@ export interface SkillDisplayConfig {
|
|||||||
tableColumns?: SkillDisplayTableColumn[];
|
tableColumns?: SkillDisplayTableColumn[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SkillDisplayConfig extends Omit<SkillDisplayResultSetConfig, "key" | "label"> {
|
||||||
|
resultSets?: SkillDisplayResultSetConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Skill {
|
export interface Skill {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ class ReferenceSourceResponse(BaseModel):
|
|||||||
id: str | None = None
|
id: str | None = None
|
||||||
skillName: str = "backend-reference"
|
skillName: str = "backend-reference"
|
||||||
mode: str = "citation"
|
mode: str = "citation"
|
||||||
|
resultSetKey: str | None = None
|
||||||
|
resultSetLabel: str | None = None
|
||||||
title: str
|
title: str
|
||||||
type: str = "result"
|
type: str = "result"
|
||||||
snippet: str = ""
|
snippet: str = ""
|
||||||
|
|||||||
+51
@@ -62,6 +62,52 @@ class SkillDisplayTableColumn(BaseModel):
|
|||||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillDisplayResultSetConfig(BaseModel):
|
||||||
|
"""Display adapter for one result array inside a skill response."""
|
||||||
|
|
||||||
|
key: str = Field(description="Stable key for this result set")
|
||||||
|
label: str | None = Field(default=None, description="Human-readable result set label")
|
||||||
|
enabled: bool = Field(default=True, description="Whether this result set is enabled")
|
||||||
|
mode: Literal["citation", "list", "table", "card", "custom", "none"] = Field(
|
||||||
|
default="citation",
|
||||||
|
description="How the frontend should render this result set",
|
||||||
|
)
|
||||||
|
result_path: str = Field(default="results", description="Dot path to the array of result items", alias="resultPath")
|
||||||
|
title_field: str | None = Field(default=None, description="Field used as item title", alias="titleField")
|
||||||
|
summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField")
|
||||||
|
content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField")
|
||||||
|
source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField")
|
||||||
|
author_field: str | None = Field(default=None, description="Field used as author/byline label", alias="authorField")
|
||||||
|
type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField")
|
||||||
|
time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField")
|
||||||
|
score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField")
|
||||||
|
url_field: str | None = Field(default=None, description="Field containing a direct URL", alias="urlField")
|
||||||
|
id_field: str | None = Field(default=None, description="Field containing an ID for template URLs", alias="idField")
|
||||||
|
link_strategy: Literal["auto", "url", "id"] = Field(
|
||||||
|
default="auto",
|
||||||
|
description="Whether links use a URL field, an ID template, or automatic fallback",
|
||||||
|
alias="linkStrategy",
|
||||||
|
)
|
||||||
|
link_template: str | None = Field(default=None, description="Template used when linkStrategy is id", alias="linkTemplate")
|
||||||
|
citation_token: Literal["doubleBracket", "singleBracket"] = Field(
|
||||||
|
default="singleBracket",
|
||||||
|
description="Citation token style expected in model output",
|
||||||
|
alias="citationToken",
|
||||||
|
)
|
||||||
|
max_items: int = Field(default=100, ge=1, le=100, description="Maximum displayed items", alias="maxItems")
|
||||||
|
custom_instruction: str = Field(
|
||||||
|
default="",
|
||||||
|
description="User-authored instruction for custom rendering schema generation",
|
||||||
|
alias="customInstruction",
|
||||||
|
)
|
||||||
|
table_columns: list[SkillDisplayTableColumn] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Columns used when mode is table",
|
||||||
|
alias="tableColumns",
|
||||||
|
)
|
||||||
|
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
class SkillDisplayConfig(BaseModel):
|
class SkillDisplayConfig(BaseModel):
|
||||||
"""Frontend display adapter for structured skill results."""
|
"""Frontend display adapter for structured skill results."""
|
||||||
|
|
||||||
@@ -103,6 +149,11 @@ class SkillDisplayConfig(BaseModel):
|
|||||||
description="Columns used when mode is table",
|
description="Columns used when mode is table",
|
||||||
alias="tableColumns",
|
alias="tableColumns",
|
||||||
)
|
)
|
||||||
|
result_sets: list[SkillDisplayResultSetConfig] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Optional per-result-array display adapters",
|
||||||
|
alias="resultSets",
|
||||||
|
)
|
||||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import re
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from deerflow.config.extensions_config import SkillDisplayConfig, get_extensions_config
|
from deerflow.config.extensions_config import (
|
||||||
|
SkillDisplayConfig,
|
||||||
|
SkillDisplayResultSetConfig,
|
||||||
|
get_extensions_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_SKILL_PATH_RE = re.compile(r"[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)")
|
_SKILL_PATH_RE = re.compile(r"[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)")
|
||||||
@@ -222,7 +226,11 @@ def adapt_reference_batches_with_displays(
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
if not config.enabled or config.mode == "none":
|
has_enabled_result_set = any(
|
||||||
|
result_set.enabled and result_set.mode != "none"
|
||||||
|
for result_set in config.result_sets
|
||||||
|
)
|
||||||
|
if not config.enabled or (config.mode == "none" and not has_enabled_result_set):
|
||||||
continue
|
continue
|
||||||
normalized_displays[skill_name] = config
|
normalized_displays[skill_name] = config
|
||||||
|
|
||||||
@@ -257,13 +265,30 @@ def adapt_reference_batches_with_displays(
|
|||||||
raw = source.get("raw")
|
raw = source.get("raw")
|
||||||
if display is not None and isinstance(raw, dict):
|
if display is not None and isinstance(raw, dict):
|
||||||
index_value = int(source.get("index") or len(sources) + 1)
|
index_value = int(source.get("index") or len(sources) + 1)
|
||||||
|
display_for_source: SkillDisplayConfig | SkillDisplayResultSetConfig = display
|
||||||
|
result_set_key = source.get("resultSetKey")
|
||||||
|
if result_set_key:
|
||||||
|
display_for_source = next(
|
||||||
|
(
|
||||||
|
result_set
|
||||||
|
for result_set in display.result_sets
|
||||||
|
if result_set.key == result_set_key
|
||||||
|
),
|
||||||
|
display,
|
||||||
|
)
|
||||||
|
elif display.result_sets and display.mode == "none":
|
||||||
|
sources.append(source)
|
||||||
|
continue
|
||||||
configured = _normalize_with_display_config(
|
configured = _normalize_with_display_config(
|
||||||
raw,
|
raw,
|
||||||
index_value,
|
index_value,
|
||||||
skill_name,
|
skill_name,
|
||||||
display,
|
display_for_source,
|
||||||
)
|
)
|
||||||
if configured is not None:
|
if configured is not None:
|
||||||
|
if isinstance(display_for_source, SkillDisplayResultSetConfig):
|
||||||
|
configured["resultSetKey"] = display_for_source.key
|
||||||
|
configured["resultSetLabel"] = display_for_source.label
|
||||||
sources.append(
|
sources.append(
|
||||||
{
|
{
|
||||||
**source,
|
**source,
|
||||||
@@ -318,6 +343,8 @@ def _source_debug_item(source: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"id": source.get("id"),
|
"id": source.get("id"),
|
||||||
"skillName": source.get("skillName"),
|
"skillName": source.get("skillName"),
|
||||||
"mode": source.get("mode"),
|
"mode": source.get("mode"),
|
||||||
|
"resultSetKey": source.get("resultSetKey"),
|
||||||
|
"resultSetLabel": source.get("resultSetLabel"),
|
||||||
"title": source.get("title"),
|
"title": source.get("title"),
|
||||||
"source": source.get("source"),
|
"source": source.get("source"),
|
||||||
"author": source.get("author"),
|
"author": source.get("author"),
|
||||||
@@ -359,6 +386,16 @@ def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
|
|||||||
tool_contexts_by_id,
|
tool_contexts_by_id,
|
||||||
)
|
)
|
||||||
payload = _extract_tool_payload(message)
|
payload = _extract_tool_payload(message)
|
||||||
|
configured_sources = _collect_configured_sources(
|
||||||
|
payload,
|
||||||
|
len(sources) + 1,
|
||||||
|
tool_context,
|
||||||
|
)
|
||||||
|
if configured_sources is not None:
|
||||||
|
for normalized in configured_sources:
|
||||||
|
normalized["index"] = len(sources) + 1
|
||||||
|
sources.append(normalized)
|
||||||
|
continue
|
||||||
for raw_item in _first_list(payload):
|
for raw_item in _first_list(payload):
|
||||||
normalized = _normalize_reference_item(
|
normalized = _normalize_reference_item(
|
||||||
raw_item,
|
raw_item,
|
||||||
@@ -373,6 +410,44 @@ def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
|
|||||||
return sources
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_configured_sources(
|
||||||
|
payload: Any,
|
||||||
|
fallback_index: int,
|
||||||
|
tool_context: dict[str, Any] | None,
|
||||||
|
) -> list[dict[str, Any]] | None:
|
||||||
|
display_match = _resolve_display_match({}, payload, tool_context)
|
||||||
|
if display_match is None:
|
||||||
|
return None
|
||||||
|
skill_name, display = display_match
|
||||||
|
configs: list[SkillDisplayConfig | SkillDisplayResultSetConfig] = [
|
||||||
|
result_set
|
||||||
|
for result_set in display.result_sets
|
||||||
|
if result_set.enabled and result_set.mode != "none"
|
||||||
|
]
|
||||||
|
if not configs:
|
||||||
|
configs = [display]
|
||||||
|
|
||||||
|
sources: list[dict[str, Any]] = []
|
||||||
|
for config in configs:
|
||||||
|
raw_items = _list_at_path(payload, config.result_path)
|
||||||
|
if raw_items is None:
|
||||||
|
continue
|
||||||
|
for raw_item in raw_items:
|
||||||
|
normalized = _normalize_with_display_config(
|
||||||
|
raw_item,
|
||||||
|
fallback_index + len(sources),
|
||||||
|
skill_name,
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
if normalized is None:
|
||||||
|
continue
|
||||||
|
if isinstance(config, SkillDisplayResultSetConfig):
|
||||||
|
normalized["resultSetKey"] = config.key
|
||||||
|
normalized["resultSetLabel"] = config.label
|
||||||
|
sources.append(normalized)
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
def _find_reference_batch_anchor(messages: list[Any], start: int, end: int) -> Any | None:
|
def _find_reference_batch_anchor(messages: list[Any], start: int, end: int) -> Any | None:
|
||||||
answer = _find_last_assistant_answer(messages, start, end)
|
answer = _find_last_assistant_answer(messages, start, end)
|
||||||
if answer is not None:
|
if answer is not None:
|
||||||
@@ -622,6 +697,19 @@ def _first_list(payload: Any) -> list[Any]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _list_at_path(payload: Any, path: str | None) -> list[dict[str, Any]] | None:
|
||||||
|
cursor = payload
|
||||||
|
if path:
|
||||||
|
for part in path.split("."):
|
||||||
|
if isinstance(cursor, dict):
|
||||||
|
cursor = cursor.get(part)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if not isinstance(cursor, list):
|
||||||
|
return None
|
||||||
|
return [item for item in cursor if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
def _nested_records(raw: Any) -> list[dict[str, Any]]:
|
def _nested_records(raw: Any) -> list[dict[str, Any]]:
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
return []
|
return []
|
||||||
@@ -654,7 +742,13 @@ def _enabled_skill_displays() -> dict[str, SkillDisplayConfig]:
|
|||||||
if state.display is not None
|
if state.display is not None
|
||||||
and state.enabled
|
and state.enabled
|
||||||
and state.display.enabled
|
and state.display.enabled
|
||||||
and state.display.mode != "none"
|
and (
|
||||||
|
state.display.mode != "none"
|
||||||
|
or any(
|
||||||
|
result_set.enabled and result_set.mode != "none"
|
||||||
|
for result_set in state.display.result_sets
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return {}
|
||||||
@@ -705,7 +799,7 @@ def _normalize_with_display_config(
|
|||||||
raw: dict[str, Any],
|
raw: dict[str, Any],
|
||||||
fallback_index: int,
|
fallback_index: int,
|
||||||
skill_name: str,
|
skill_name: str,
|
||||||
display: SkillDisplayConfig,
|
display: SkillDisplayConfig | SkillDisplayResultSetConfig,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
title = _stringify(_get_by_path(raw, display.title_field), f"结果 {fallback_index}")
|
title = _stringify(_get_by_path(raw, display.title_field), f"结果 {fallback_index}")
|
||||||
snippet = _stringify(_get_by_path(raw, display.summary_field), "")
|
snippet = _stringify(_get_by_path(raw, display.summary_field), "")
|
||||||
@@ -737,7 +831,10 @@ def _normalize_with_display_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _resolve_display_link(raw: dict[str, Any], display: SkillDisplayConfig) -> str | None:
|
def _resolve_display_link(
|
||||||
|
raw: dict[str, Any],
|
||||||
|
display: SkillDisplayConfig | SkillDisplayResultSetConfig,
|
||||||
|
) -> str | None:
|
||||||
url = _stringify(_get_by_path(raw, display.url_field), "")
|
url = _stringify(_get_by_path(raw, display.url_field), "")
|
||||||
item_id = _stringify(_get_by_path(raw, display.id_field), "")
|
item_id = _stringify(_get_by_path(raw, display.id_field), "")
|
||||||
if display.link_strategy == "url":
|
if display.link_strategy == "url":
|
||||||
|
|||||||
Reference in new issue
Block a user