- 智能体支持从广场收藏到「我的」(新增 agent_favorites 表与收藏/取消收藏接口) - 定时任务新增「必须生成Markdown文件」开关:注入提示词并校验生成的 .md 文件且正文超过100字,否则记为失败(存于 execution_context,无需迁移) - 聊天问答展示时间(年月日 时分):问题在右下角、回答在左下角 - 常用提示词改为与账号绑定(新增 user_custom_prompts 表与 /api/user-prompts 接口),仅在首页输入框上方以居中标签 + 轮播展示,齿轮置于最右侧管理 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
481 lines
17 KiB
TypeScript
481 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { ExternalLinkIcon, Loader2, WandSparklesIcon } from "lucide-react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
emptyDisplayConfig,
|
|
inferDisplayConfig,
|
|
normalizeDisplaySources,
|
|
} from "@/core/skill-display/display-adapter";
|
|
import {
|
|
getSkill,
|
|
getPersistedSkillDisplaySample,
|
|
getSkillDisplaySample,
|
|
} from "@/core/skills/api";
|
|
import { useSetSkillDisplay } from "@/core/skills/hooks";
|
|
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
|
|
|
|
const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
|
|
["resultPath", "结果列表路径"],
|
|
["titleField", "标题字段"],
|
|
["summaryField", "摘要字段"],
|
|
["contentField", "正文内容字段"],
|
|
["sourceField", "来源字段"],
|
|
["authorField", "作者字段"],
|
|
["typeField", "类型字段"],
|
|
["timeField", "时间字段"],
|
|
["scoreField", "相关度字段"],
|
|
["urlField", "URL 跳转字段"],
|
|
["idField", "ID 跳转字段"],
|
|
["linkTemplate", "ID 跳转链接模板"],
|
|
];
|
|
|
|
const displayModeLabels: Record<SkillDisplayMode, string> = {
|
|
citation: "编号引用",
|
|
list: "列表展示",
|
|
table: "表格展示",
|
|
card: "卡片展示",
|
|
custom: "自定义展示",
|
|
none: "不展示",
|
|
};
|
|
|
|
const linkStrategyLabels: Record<SkillLinkStrategy, string> = {
|
|
auto: "自动判断",
|
|
url: "使用 URL 字段跳转",
|
|
id: "使用 ID 模板跳转",
|
|
};
|
|
|
|
function updateConfig<K extends keyof SkillDisplayConfig>(
|
|
config: SkillDisplayConfig,
|
|
key: K,
|
|
value: SkillDisplayConfig[K],
|
|
): SkillDisplayConfig {
|
|
return { ...config, [key]: value };
|
|
}
|
|
|
|
function formatSample(sample: Record<string, unknown> | null | undefined) {
|
|
return sample ? JSON.stringify(sample, null, 2) : "";
|
|
}
|
|
|
|
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|
const queryClient = useQueryClient();
|
|
const setDisplay = useSetSkillDisplay();
|
|
const lastSkillNameRef = useRef(skill.name);
|
|
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).
|
|
// Important: this drives the "保存后回显" behavior. Without depending on
|
|
// `skill.display`, any backend-side normalization (e.g. default values
|
|
// backfilled by Pydantic, column reordering) would not flow back into the UI.
|
|
useEffect(() => {
|
|
const display = skill.display ?? emptyDisplayConfig();
|
|
setDraft(display);
|
|
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 保存.
|
|
useEffect(() => {
|
|
if (lastSkillNameRef.current !== skill.name) {
|
|
lastSkillNameRef.current = skill.name;
|
|
setSampleText(formatSample(skill.displaySample));
|
|
return;
|
|
}
|
|
if (skill.displaySample) {
|
|
setSampleText(formatSample(skill.displaySample));
|
|
}
|
|
}, [skill.name, skill.displaySample]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
async function loadPersistedSample() {
|
|
try {
|
|
const response = await getPersistedSkillDisplaySample(skill.name);
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
if (response.sample) {
|
|
setSampleText(formatSample(response.sample));
|
|
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], (current) =>
|
|
current ? { ...current, displaySample: response.sample } : current,
|
|
);
|
|
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
|
Array.isArray(current)
|
|
? current.map((item) =>
|
|
item.name === skill.name
|
|
? { ...item, displaySample: response.sample }
|
|
: item,
|
|
)
|
|
: current,
|
|
);
|
|
} else if (!skill.displaySample) {
|
|
setSampleText("");
|
|
}
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
}
|
|
void loadPersistedSample();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [queryClient, skill.name, skill.displaySample]);
|
|
|
|
const samplePayload = useMemo(() => {
|
|
try {
|
|
return JSON.parse(sampleText);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}, [sampleText]);
|
|
|
|
const previewItems = useMemo(() => (
|
|
samplePayload ? normalizeDisplaySources(samplePayload, draft) : []
|
|
), [draft, samplePayload]);
|
|
|
|
function generateDraft() {
|
|
if (!samplePayload) {
|
|
toast.error("样例返回不是有效的 JSON");
|
|
return;
|
|
}
|
|
const nextDraft = inferDisplayConfig(samplePayload, skill.name);
|
|
setDraft(nextDraft);
|
|
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
|
toast.success("已生成展示适配草稿");
|
|
}
|
|
|
|
async function loadSampleAndGenerate() {
|
|
try {
|
|
const response = await getSkillDisplaySample(skill.name);
|
|
const nextSampleText = JSON.stringify(response.sample, null, 2);
|
|
setSampleText(nextSampleText);
|
|
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], (current) =>
|
|
current ? { ...current, displaySample: response.sample } : current,
|
|
);
|
|
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
|
Array.isArray(current)
|
|
? current.map((item) =>
|
|
item.name === skill.name
|
|
? { ...item, displaySample: response.sample }
|
|
: item,
|
|
)
|
|
: current,
|
|
);
|
|
const nextDraft = inferDisplayConfig(response.sample, skill.name);
|
|
setDraft(nextDraft);
|
|
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
|
toast.success("已获取样例并生成展示适配");
|
|
} catch (err) {
|
|
generateDraft();
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
async function saveDraft() {
|
|
try {
|
|
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));
|
|
}
|
|
}
|
|
|
|
async function clearDisplay() {
|
|
try {
|
|
await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
|
|
setDraft(emptyDisplayConfig());
|
|
setTableColumnsText(JSON.stringify([], null, 2));
|
|
toast.success("展示适配已清除");
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="rounded-lg border bg-muted/20 p-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm font-medium">技能展示适配</div>
|
|
<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>
|
|
<Switch
|
|
checked={draft.enabled}
|
|
onCheckedChange={(checked) => setDraft(updateConfig(draft, "enabled", checked))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
|
<label className="space-y-1.5">
|
|
<span className="text-xs text-muted-foreground">展示方式</span>
|
|
<Select
|
|
value={draft.mode}
|
|
onValueChange={(value) => setDraft(updateConfig(draft, "mode", value as SkillDisplayMode))}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(displayModeLabels).map(([value, label]) => (
|
|
<SelectItem key={value} value={value}>{label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</label>
|
|
|
|
<label className="space-y-1.5">
|
|
<span className="text-xs text-muted-foreground">跳转方式</span>
|
|
<Select
|
|
value={draft.linkStrategy}
|
|
onValueChange={(value) => setDraft(updateConfig(draft, "linkStrategy", value as SkillLinkStrategy))}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(linkStrategyLabels).map(([value, label]) => (
|
|
<SelectItem key={value} value={value}>{label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</label>
|
|
|
|
<label className="space-y-1.5">
|
|
<span className="text-xs text-muted-foreground">最多展示条数</span>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={100}
|
|
value={draft.maxItems}
|
|
onChange={(event) => setDraft(updateConfig(draft, "maxItems", Number(event.currentTarget.value) || 1))}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
|
{fieldLabels.map(([field, label]) => (
|
|
<label key={field} className="space-y-1.5">
|
|
<span className="text-xs text-muted-foreground">{label}</span>
|
|
<Input
|
|
value={(draft[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
|
|
onChange={(event) => setDraft(updateConfig(draft, field, event.currentTarget.value as never))}
|
|
placeholder={field === "linkTemplate" ? "https://xxx.xxx.xxx?recuuid={recUuid}" : undefined}
|
|
/>
|
|
</label>
|
|
))}
|
|
</div>
|
|
|
|
{draft.mode === "table" && (
|
|
<label className="mt-4 block space-y-1.5">
|
|
<span className="text-xs text-muted-foreground">表格列配置 JSON</span>
|
|
<Textarea
|
|
value={tableColumnsText}
|
|
onChange={(event) => {
|
|
setTableColumnsText(event.currentTarget.value);
|
|
try {
|
|
const value = JSON.parse(event.currentTarget.value);
|
|
if (Array.isArray(value)) {
|
|
setDraft(updateConfig(draft, "tableColumns", value));
|
|
}
|
|
} catch {
|
|
// Keep typing invalid JSON without crashing the panel.
|
|
}
|
|
}}
|
|
placeholder={'[{"label":"任务名称","field":"title"},{"label":"状态","field":"status"}]'}
|
|
className="min-h-24 font-mono text-xs"
|
|
/>
|
|
</label>
|
|
)}
|
|
|
|
{draft.mode === "custom" && (
|
|
<label className="mt-4 block space-y-1.5">
|
|
<span className="text-xs text-muted-foreground">自定义展示描述</span>
|
|
<Textarea
|
|
value={draft.customInstruction}
|
|
onChange={(event) => setDraft(updateConfig(draft, "customInstruction", event.currentTarget.value))}
|
|
placeholder="例如:按 publish_time 时间倒序显示为时间线,每条包含标题、摘要、来源和查看原文按钮。"
|
|
className="min-h-24"
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-sm font-medium">技能返回样例</div>
|
|
<Button size="sm" variant="outline" onClick={() => void loadSampleAndGenerate()}>
|
|
<WandSparklesIcon className="size-3.5" />
|
|
自动识别
|
|
</Button>
|
|
</div>
|
|
<Textarea
|
|
value={sampleText}
|
|
onChange={(event) => setSampleText(event.currentTarget.value)}
|
|
className="min-h-72 font-mono text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="text-sm font-medium">展示预览</div>
|
|
<div className="min-h-72 space-y-2 rounded-lg border bg-muted/20 p-3">
|
|
{!samplePayload ? (
|
|
sampleText.trim() ? (
|
|
<div className="text-sm text-destructive">样例 JSON 解析失败</div>
|
|
) : (
|
|
<div className="text-sm text-muted-foreground">数据库暂无技能返回样例,请点击自动识别</div>
|
|
)
|
|
) : previewItems.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground">当前字段映射没有解析出结果</div>
|
|
) : draft.mode === "citation" ? (
|
|
<>
|
|
<p className="text-sm leading-relaxed">
|
|
编号引用预览:
|
|
{previewItems.slice(0, 3).map((_item, idx) => (
|
|
<button
|
|
type="button"
|
|
key={idx}
|
|
className="mx-1 inline-flex h-6 min-w-6 items-center justify-center rounded-md border bg-background px-2 text-xs font-semibold"
|
|
>
|
|
[{idx + 1}]
|
|
</button>
|
|
))}
|
|
</p>
|
|
{previewItems.map((item, idx) => (
|
|
<PreviewItem key={idx} index={idx + 1} item={item} />
|
|
))}
|
|
</>
|
|
) : (
|
|
previewItems.map((item, idx) => <PreviewItem key={idx} index={idx + 1} item={item} />)
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button variant="outline" onClick={() => void clearDisplay()} disabled={setDisplay.isPending}>
|
|
清除
|
|
</Button>
|
|
<Button onClick={() => void saveDraft()} disabled={setDisplay.isPending}>
|
|
{setDisplay.isPending && <Loader2 className="size-3.5 animate-spin" />}
|
|
保存展示适配
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PreviewItem({
|
|
index,
|
|
item,
|
|
}: {
|
|
index: number;
|
|
item: ReturnType<typeof normalizeDisplaySources>[number];
|
|
}) {
|
|
return (
|
|
<div className="rounded-md border bg-background p-3 text-xs">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="font-medium">
|
|
[{index}] {item.title}
|
|
</div>
|
|
<div className="mt-1 text-muted-foreground">
|
|
{item.type}
|
|
{item.source ? ` | ${item.source}` : ""}
|
|
{item.time ? ` | ${item.time}` : ""}
|
|
{typeof item.score === "number" ? ` | ${item.score.toFixed(3)}` : ""}
|
|
</div>
|
|
</div>
|
|
{item.url && (
|
|
<a
|
|
href={item.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
title="打开链接"
|
|
>
|
|
<ExternalLinkIcon className="size-4" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
<p className="mt-2 line-clamp-3 leading-relaxed text-muted-foreground">
|
|
{item.snippet || item.content || "暂无摘要"}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|