style(knowledge): 实体改回纯展示(不可点击),chip 精致化
按需求去掉点击筛选,实体为静态彩色 chip:色点 + 名称 + 胶囊型类型标签。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f6af9379b7
commit
1ce15fbb15
15 files changed
+596
-38
No files matched your search
@@ -510,6 +510,15 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
// 技能英文名 → 中文名映射,用于把智能体的技能徽章显示为中文(agent.skills 只存英文名)。
|
||||||
|
const skillNameZhMap = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const s of skills) {
|
||||||
|
if (s.name_zh?.trim()) map.set(s.name, s.name_zh.trim());
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [skills]);
|
||||||
|
|
||||||
const skillBadgeItems = [
|
const skillBadgeItems = [
|
||||||
...(agent.tool_groups ?? []).map((group) => ({
|
...(agent.tool_groups ?? []).map((group) => ({
|
||||||
key: `tg:${group}`,
|
key: `tg:${group}`,
|
||||||
@@ -532,7 +541,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
|||||||
setDetailSkillName(skill);
|
setDetailSkillName(skill);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{desensitize(skill)}
|
{skillNameZhMap.get(skill) ?? desensitize(skill)}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
@@ -831,7 +840,11 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
|||||||
title={desensitize(skill.description ?? "")}
|
title={desensitize(skill.description ?? "")}
|
||||||
className="h-8 min-w-0 flex-1 rounded-r-none"
|
className="h-8 min-w-0 flex-1 rounded-r-none"
|
||||||
>
|
>
|
||||||
<span className="truncate">{desensitize(skill.name)}</span>
|
<span className="truncate">
|
||||||
|
{skill.name_zh?.trim()
|
||||||
|
? skill.name_zh
|
||||||
|
: desensitize(skill.name)}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
|||||||
import {
|
import {
|
||||||
useDeleteCustomSkill,
|
useDeleteCustomSkill,
|
||||||
useDuplicateSkill,
|
useDuplicateSkill,
|
||||||
|
useGenerateMissingSkillNameZh,
|
||||||
useInstallSkillUpload,
|
useInstallSkillUpload,
|
||||||
useReconcileCustomSkills,
|
useReconcileCustomSkills,
|
||||||
useSetSkillFeatured,
|
useSetSkillFeatured,
|
||||||
@@ -155,6 +156,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [viewMode, setViewMode] = useViewMode("skill");
|
const [viewMode, setViewMode] = useViewMode("skill");
|
||||||
const reconcileMutation = useReconcileCustomSkills();
|
const reconcileMutation = useReconcileCustomSkills();
|
||||||
|
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
||||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||||
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
|
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
|
||||||
|
|
||||||
@@ -232,6 +234,23 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
[orderedSkills, page],
|
[orderedSkills, page],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleFillNames = async () => {
|
||||||
|
try {
|
||||||
|
const result = await fillNamesMutation.mutateAsync();
|
||||||
|
const okCount = result.generated.length;
|
||||||
|
const failCount = result.failed.length;
|
||||||
|
if (okCount === 0 && failCount === 0) {
|
||||||
|
toast.info("所有技能均已配置中文名称");
|
||||||
|
} else if (failCount === 0) {
|
||||||
|
toast.success(`已为 ${okCount} 个技能补充中文名称`);
|
||||||
|
} else {
|
||||||
|
toast.warning(`已补充 ${okCount} 个,${failCount} 个生成失败,可稍后重试或手动填写`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRescan = async () => {
|
const handleRescan = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await reconcileMutation.mutateAsync();
|
const result = await reconcileMutation.mutateAsync();
|
||||||
@@ -322,6 +341,23 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
? t.settings.skills.rescanSkillsRunning
|
? t.settings.skills.rescanSkillsRunning
|
||||||
: t.settings.skills.rescanSkills}
|
: t.settings.skills.rescanSkills}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleFillNames()}
|
||||||
|
disabled={
|
||||||
|
fillNamesMutation.isPending ||
|
||||||
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||||
|
}
|
||||||
|
title="为所有尚未配置中文名称的技能自动生成中文名"
|
||||||
|
>
|
||||||
|
{fillNamesMutation.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<SparklesIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
{fillNamesMutation.isPending ? "补充中…" : "一键补充中文名"}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -550,8 +586,13 @@ export function SkillRow({
|
|||||||
const badgeGroup = (
|
const badgeGroup = (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className={cn(isCard && "truncate font-medium")}>
|
<span className={cn(isCard && "truncate font-medium")}>
|
||||||
{desensitize(skill.name)}
|
{skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)}
|
||||||
</span>
|
</span>
|
||||||
|
{skill.name_zh?.trim() && (
|
||||||
|
<span className="text-muted-foreground truncate text-xs font-normal">
|
||||||
|
{desensitize(skill.name)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isBuiltin && (
|
{isBuiltin && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{t.settings.skills.badgeBuiltin}
|
{t.settings.skills.badgeBuiltin}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||||
@@ -33,7 +34,9 @@ import { SkillFileTree } from "@/components/workspace/skills/skill-file-tree";
|
|||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
|
useGenerateSkillNameZh,
|
||||||
useSetSkillDetail,
|
useSetSkillDetail,
|
||||||
|
useSetSkillNameZh,
|
||||||
useSkill,
|
useSkill,
|
||||||
useSkillContent,
|
useSkillContent,
|
||||||
useSkillFiles,
|
useSkillFiles,
|
||||||
@@ -98,9 +101,12 @@ export function SkillDetailDialog({
|
|||||||
const activeName = open ? skillName : null;
|
const activeName = open ? skillName : null;
|
||||||
const { data: skill, isLoading: isSkillLoading } = useSkill(activeName);
|
const { data: skill, isLoading: isSkillLoading } = useSkill(activeName);
|
||||||
const setDetail = useSetSkillDetail();
|
const setDetail = useSetSkillDetail();
|
||||||
|
const setNameZh = useSetSkillNameZh();
|
||||||
|
const generateNameZh = useGenerateSkillNameZh();
|
||||||
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
|
const [nameZhDraft, setNameZhDraft] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
"detail" | "display" | "source" | "files" | null
|
"detail" | "display" | "source" | "files" | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -136,6 +142,10 @@ export function SkillDetailDialog({
|
|||||||
setOpenedFilePath(null);
|
setOpenedFilePath(null);
|
||||||
}, [open, skillName]);
|
}, [open, skillName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNameZhDraft(skill?.name_zh ?? "");
|
||||||
|
}, [skill?.name, skill?.name_zh]);
|
||||||
|
|
||||||
const skillBody = useMemo(() => {
|
const skillBody = useMemo(() => {
|
||||||
return stripYamlFrontmatter(contentData?.content ?? "");
|
return stripYamlFrontmatter(contentData?.content ?? "");
|
||||||
}, [contentData]);
|
}, [contentData]);
|
||||||
@@ -159,6 +169,30 @@ export function SkillDetailDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSaveNameZh() {
|
||||||
|
if (!skill) return;
|
||||||
|
try {
|
||||||
|
await setNameZh.mutateAsync({
|
||||||
|
skillName: skill.name,
|
||||||
|
nameZh: nameZhDraft.trim(),
|
||||||
|
});
|
||||||
|
toast.success("中文名称已保存");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerateNameZh() {
|
||||||
|
if (!skill) return;
|
||||||
|
try {
|
||||||
|
const result = await generateNameZh.mutateAsync(skill.name);
|
||||||
|
setNameZhDraft(result.name_zh ?? "");
|
||||||
|
toast.success("已生成中文名称");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function copySource() {
|
async function copySource() {
|
||||||
await navigator.clipboard.writeText(contentData?.content ?? "");
|
await navigator.clipboard.writeText(contentData?.content ?? "");
|
||||||
toast.success("Copied SKILL.md content");
|
toast.success("Copied SKILL.md content");
|
||||||
@@ -183,8 +217,15 @@ export function SkillDetailDialog({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<DialogTitle className="truncate text-base">
|
<DialogTitle className="truncate text-base">
|
||||||
{desensitize(skill?.name ?? skillName ?? "")}
|
{skill?.name_zh?.trim()
|
||||||
|
? skill.name_zh
|
||||||
|
: desensitize(skill?.name ?? skillName ?? "")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
{skill?.name_zh?.trim() && (
|
||||||
|
<span className="text-muted-foreground truncate text-xs">
|
||||||
|
{desensitize(skill?.name ?? skillName ?? "")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{skill?.builtin && (
|
{skill?.builtin && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{tx.badgeBuiltin}
|
{tx.badgeBuiltin}
|
||||||
@@ -295,6 +336,57 @@ export function SkillDetailDialog({
|
|||||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||||
style={{ overflowY: "auto" }}
|
style={{ overflowY: "auto" }}
|
||||||
>
|
>
|
||||||
|
<div className="mb-5 rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium">中文名称</span>
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => void handleGenerateNameZh()}
|
||||||
|
disabled={generateNameZh.isPending || setNameZh.isPending}
|
||||||
|
>
|
||||||
|
{generateNameZh.isPending ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<SparklesIcon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
AI 生成
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={nameZhDraft}
|
||||||
|
onChange={(event) => setNameZhDraft(event.currentTarget.value)}
|
||||||
|
placeholder="为该技能设置中文名称(留空则使用英文名)"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
disabled={generateNameZh.isPending}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => void handleSaveNameZh()}
|
||||||
|
disabled={
|
||||||
|
setNameZh.isPending ||
|
||||||
|
generateNameZh.isPending ||
|
||||||
|
nameZhDraft.trim() === (skill?.name_zh ?? "").trim()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{setNameZh.isPending && (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t.common.save}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
{skill?.name_zh?.trim() ? skill.name_zh : "未设置"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -430,6 +430,65 @@ export async function saveSkillFile(
|
|||||||
return response.json() as Promise<SkillFileSaveResponse>;
|
return response.json() as Promise<SkillFileSaveResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SkillNameZhResponse {
|
||||||
|
name: string;
|
||||||
|
name_zh: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateMissingSkillNameZhResponse {
|
||||||
|
generated: SkillNameZhResponse[];
|
||||||
|
failed: string[];
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a skill's Chinese display name. An empty string clears it (falls back to
|
||||||
|
* the English name).
|
||||||
|
*/
|
||||||
|
export async function setSkillNameZh(
|
||||||
|
skillName: string,
|
||||||
|
nameZh: string,
|
||||||
|
): Promise<SkillNameZhResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/name-zh`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name_zh: nameZh }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to set skill Chinese name");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<SkillNameZhResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask the LLM to generate and persist a Chinese name for a single skill. */
|
||||||
|
export async function generateSkillNameZh(
|
||||||
|
skillName: string,
|
||||||
|
): Promise<SkillNameZhResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/name-zh/generate`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to generate skill Chinese name");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<SkillNameZhResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-click: generate Chinese names for every skill that lacks one. */
|
||||||
|
export async function generateMissingSkillNameZh(): Promise<GenerateMissingSkillNameZhResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/name-zh/generate-missing`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to bulk-generate skill Chinese names");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<GenerateMissingSkillNameZhResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SetSkillDetailResponse {
|
export interface SetSkillDetailResponse {
|
||||||
name: string;
|
name: string;
|
||||||
detail: string | null;
|
detail: string | null;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
deleteCustomSkill,
|
deleteCustomSkill,
|
||||||
duplicateSkill,
|
duplicateSkill,
|
||||||
enableSkill,
|
enableSkill,
|
||||||
|
generateMissingSkillNameZh,
|
||||||
|
generateSkillNameZh,
|
||||||
getSkill,
|
getSkill,
|
||||||
getSkillContent,
|
getSkillContent,
|
||||||
getSkillFileContent,
|
getSkillFileContent,
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
reconcileCustomSkills,
|
reconcileCustomSkills,
|
||||||
saveSkillFile,
|
saveSkillFile,
|
||||||
setSkillDetail,
|
setSkillDetail,
|
||||||
|
setSkillNameZh,
|
||||||
setSkillDisplay,
|
setSkillDisplay,
|
||||||
setSkillPinned,
|
setSkillPinned,
|
||||||
setSkillFeatured,
|
setSkillFeatured,
|
||||||
@@ -90,6 +93,39 @@ export function useSetSkillDetail() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSetSkillNameZh() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ skillName, nameZh }: { skillName: string; nameZh: string }) =>
|
||||||
|
setSkillNameZh(skillName, nameZh),
|
||||||
|
onSuccess: (_data, { skillName }) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerateSkillNameZh() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (skillName: string) => generateSkillNameZh(skillName),
|
||||||
|
onSuccess: (_data, skillName) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerateMissingSkillNameZh() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => generateMissingSkillNameZh(),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useSetSkillDisplay() {
|
export function useSetSkillDisplay() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export interface SkillDisplayConfig extends Omit<SkillDisplayResultSetConfig, "k
|
|||||||
|
|
||||||
export interface Skill {
|
export interface Skill {
|
||||||
name: string;
|
name: string;
|
||||||
|
// 用户配置或大模型生成的中文名称;为空时界面回退到使用英文 name。
|
||||||
|
name_zh?: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
category: string;
|
category: string;
|
||||||
license: string;
|
license: string;
|
||||||
|
|||||||
@@ -416,13 +416,7 @@ function VaultPageDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoteDetail({
|
function NoteDetail({ noteId }: { noteId: string }) {
|
||||||
noteId,
|
|
||||||
onEntityClick,
|
|
||||||
}: {
|
|
||||||
noteId: string;
|
|
||||||
onEntityClick?: (name: string) => void;
|
|
||||||
}) {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: note, isLoading } = useKnowledgeNote(noteId);
|
const { data: note, isLoading } = useKnowledgeNote(noteId);
|
||||||
const { data: wikiLinks } = useNoteWikilinks(noteId);
|
const { data: wikiLinks } = useNoteWikilinks(noteId);
|
||||||
@@ -591,22 +585,20 @@ function NoteDetail({
|
|||||||
{note.entities.map((ent) => {
|
{note.entities.map((ent) => {
|
||||||
const meta = entityMeta(ent.entity_type);
|
const meta = entityMeta(ent.entity_type);
|
||||||
return (
|
return (
|
||||||
<button
|
<span
|
||||||
type="button"
|
|
||||||
key={`${ent.name}-${ent.entity_type ?? ""}`}
|
key={`${ent.name}-${ent.entity_type ?? ""}`}
|
||||||
onClick={() => onEntityClick?.(ent.name)}
|
title={meta.label}
|
||||||
title={`筛选提及「${ent.name}」的知识`}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"group inline-flex cursor-pointer items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium shadow-sm transition-all hover:-translate-y-px hover:shadow",
|
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
|
||||||
meta.className,
|
meta.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70 transition-transform group-hover:scale-125" />
|
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70" />
|
||||||
<span>{ent.name}</span>
|
<span>{ent.name}</span>
|
||||||
<span className="rounded-sm px-1 text-[10px] opacity-70 ring-1 ring-current ring-inset">
|
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
|
||||||
{meta.label}
|
{meta.label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -792,14 +784,7 @@ export default function KnowledgeBasePage() {
|
|||||||
|
|
||||||
<div className="min-h-0">
|
<div className="min-h-0">
|
||||||
{noteId ? (
|
{noteId ? (
|
||||||
<NoteDetail
|
<NoteDetail noteId={noteId} />
|
||||||
noteId={noteId}
|
|
||||||
onEntityClick={(name) => {
|
|
||||||
setKeyword(name);
|
|
||||||
setSourceType("all");
|
|
||||||
setStatus("all");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
||||||
从左侧选择一条知识查看详情
|
从左侧选择一条知识查看详情
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export default function NewAgentPage() {
|
|||||||
return enabledSkills.filter(
|
return enabledSkills.filter(
|
||||||
(skill) =>
|
(skill) =>
|
||||||
skill.name.toLowerCase().includes(q) ||
|
skill.name.toLowerCase().includes(q) ||
|
||||||
|
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
||||||
(skill.description ?? "").toLowerCase().includes(q),
|
(skill.description ?? "").toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
}, [enabledSkills, skillSearch]);
|
}, [enabledSkills, skillSearch]);
|
||||||
@@ -590,7 +591,9 @@ export default function NewAgentPage() {
|
|||||||
!checked && "text-muted-foreground",
|
!checked && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="truncate">{skill.name}</span>
|
<span className="truncate">
|
||||||
|
{skill.name_zh?.trim() ? skill.name_zh : skill.name}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -380,6 +380,7 @@ Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` /
|
|||||||
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
|
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
|
||||||
- **Injection**: Enabled skills listed in agent system prompt with container paths
|
- **Injection**: Enabled skills listed in agent system prompt with container paths
|
||||||
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
||||||
|
- **Chinese name (`name_zh`)**: a per-deployment Chinese display name stored alongside `enabled`/`display` in `extensions_config.json` (`SkillStateConfig.name_zh`); surfaced on `SkillResponse.name_zh`. Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (user sets/clears it), `POST /api/skills/{name}/name-zh/generate` (LLM generates one for that skill — reuses the curator model setting, falls back to the main model), `POST /api/skills/name-zh/generate-missing` (一键补充: bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor) shows `name_zh || name` and offers per-skill "AI 生成" plus a header "一键补充中文名" button. Tests: `tests/test_skill_name_zh.py`.
|
||||||
|
|
||||||
#### Skill Curator (`packages/harness/deerflow/skills/curator.py`)
|
#### Skill Curator (`packages/harness/deerflow/skills/curator.py`)
|
||||||
|
|
||||||
|
|||||||
@@ -307,22 +307,22 @@ def _skill_to_response(
|
|||||||
published = False
|
published = False
|
||||||
builtin = skill.category != SkillCategory.CUSTOM
|
builtin = skill.category != SkillCategory.CUSTOM
|
||||||
detail: str | None = None
|
detail: str | None = None
|
||||||
|
name_zh: str | None = None
|
||||||
if ownership is not None:
|
if ownership is not None:
|
||||||
owner_user_id = ownership.get("owner_user_id")
|
owner_user_id = ownership.get("owner_user_id")
|
||||||
published = bool(ownership.get("published", False))
|
published = bool(ownership.get("published", False))
|
||||||
builtin = bool(ownership.get("builtin", owner_user_id is None))
|
builtin = bool(ownership.get("builtin", owner_user_id is None))
|
||||||
detail = ownership.get("detail")
|
detail = ownership.get("detail")
|
||||||
|
name_zh = ownership.get("name_zh")
|
||||||
|
|
||||||
display = None if display_override is _UNSET else display_override
|
display = None if display_override is _UNSET else display_override
|
||||||
name_zh: str | None = None
|
if display_override is _UNSET:
|
||||||
try:
|
try:
|
||||||
skill_config = get_extensions_config().skills.get(skill.name)
|
skill_config = get_extensions_config().skills.get(skill.name)
|
||||||
if skill_config:
|
if skill_config and skill_config.display:
|
||||||
if display_override is _UNSET and skill_config.display:
|
|
||||||
display = skill_config.display.model_dump(by_alias=True)
|
display = skill_config.display.model_dump(by_alias=True)
|
||||||
name_zh = skill_config.name_zh or None
|
except Exception:
|
||||||
except Exception:
|
logger.debug("Failed to read display adapter for %s", skill.name, exc_info=True)
|
||||||
logger.debug("Failed to read extensions config for %s", skill.name, exc_info=True)
|
|
||||||
|
|
||||||
return SkillResponse(
|
return SkillResponse(
|
||||||
name=skill.name,
|
name=skill.name,
|
||||||
@@ -2146,7 +2146,6 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
|||||||
extensions_config.skills[skill_name] = SkillStateConfig(
|
extensions_config.skills[skill_name] = SkillStateConfig(
|
||||||
enabled=request.enabled,
|
enabled=request.enabled,
|
||||||
display=current_skill_config.display if current_skill_config else None,
|
display=current_skill_config.display if current_skill_config else None,
|
||||||
name_zh=current_skill_config.name_zh if current_skill_config else None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
config_data = _dump_extensions_config(extensions_config)
|
config_data = _dump_extensions_config(extensions_config)
|
||||||
@@ -2355,6 +2354,276 @@ async def update_skill_detail(
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to update skill detail: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to update skill detail: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 技能中文名称(用户可配置 + 大模型自动生成 + 一键补充)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SKILL_NAME_ZH_SYSTEM_PROMPT = (
|
||||||
|
"你是一名中文产品命名助手。根据给定技能的英文名与功能描述,生成一个简洁、自然、"
|
||||||
|
"易懂的中文名称。要求:2 到 10 个汉字;准确概括技能用途;不要包含标点、引号、英文、"
|
||||||
|
"解释或多余文字;只输出中文名称本身。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_generated_name_zh(raw: str) -> str | None:
|
||||||
|
"""Normalise an LLM-produced Chinese name: first line, strip quotes/punct, cap length."""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
text = str(raw).strip()
|
||||||
|
# Take the first non-empty line only.
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
text = line
|
||||||
|
break
|
||||||
|
# Strip wrapping quotes / common trailing punctuation.
|
||||||
|
text = text.strip().strip("\"'“”‘’`「」《》【】()()[]").strip()
|
||||||
|
text = text.rstrip("。.,,、!!??;;::")
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
# Keep it short — guard against the model returning a sentence.
|
||||||
|
if len(text) > 20:
|
||||||
|
text = text[:20]
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_skill_name_zh(skill: Skill, app_config: AppConfig, model: Any = None) -> str | None:
|
||||||
|
"""Generate a Chinese display name for ``skill`` via the LLM.
|
||||||
|
|
||||||
|
Reuses the curator/security-scan model setting (falls back to the main model).
|
||||||
|
Returns None when the model is unconfigured, the call times out/fails, or the
|
||||||
|
output cannot be normalised into a short Chinese name.
|
||||||
|
"""
|
||||||
|
user_prompt = (
|
||||||
|
f"技能英文名:{skill.name}\n"
|
||||||
|
f"功能描述:{skill.description or '(无描述)'}\n\n"
|
||||||
|
"请为该技能起一个中文名称。"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from deerflow.models import create_chat_model
|
||||||
|
|
||||||
|
if model is None:
|
||||||
|
model_name = app_config.skill_evolution.curator.model_name if app_config.skill_evolution and app_config.skill_evolution.curator else None
|
||||||
|
model = create_chat_model(name=model_name, thinking_enabled=False, app_config=app_config)
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
model.ainvoke(
|
||||||
|
[
|
||||||
|
{"role": "system", "content": _SKILL_NAME_ZH_SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
config={"run_name": "skill_name_zh"},
|
||||||
|
),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
return _clean_generated_name_zh(str(getattr(response, "content", "") or ""))
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("Skill name_zh LLM call timed out for %s", skill.name)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Skill name_zh LLM call failed for %s", skill.name, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_skill_name_zh(
|
||||||
|
skill: Skill,
|
||||||
|
name_zh: str | None,
|
||||||
|
skill_store: SkillStore,
|
||||||
|
user_id: str,
|
||||||
|
*,
|
||||||
|
is_admin: bool,
|
||||||
|
) -> str | None:
|
||||||
|
"""Persist ``name_zh`` into the ``skills`` DB table (never the on-disk SKILL.md).
|
||||||
|
|
||||||
|
Authorization mirrors the ``detail`` annotation: a custom skill is editable by
|
||||||
|
its owner or an admin; a built-in / public skill only by an admin (its DB row
|
||||||
|
is materialized on demand). Returns the stored value.
|
||||||
|
"""
|
||||||
|
if skill.category == SkillCategory.CUSTOM:
|
||||||
|
_record, mode = await _get_editable_skill_or_raise(skill_store, skill.name, user_id, is_admin=is_admin)
|
||||||
|
if mode == "admin":
|
||||||
|
updated = await skill_store.update_admin(skill.name, {"name_zh": name_zh})
|
||||||
|
else:
|
||||||
|
updated = await skill_store.update(skill.name, user_id, {"name_zh": name_zh})
|
||||||
|
else:
|
||||||
|
if not is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Only admin users can edit built-in skill Chinese names")
|
||||||
|
if await skill_store.get_any(skill.name) is None:
|
||||||
|
await skill_store.ensure_legacy({"name": skill.name, "owner_user_id": None, "published": True})
|
||||||
|
updated = await skill_store.update_admin(skill.name, {"name_zh": name_zh})
|
||||||
|
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to persist skill Chinese name")
|
||||||
|
return updated.get("name_zh")
|
||||||
|
|
||||||
|
|
||||||
|
class SkillNameZhRequest(BaseModel):
|
||||||
|
"""Request body for setting a skill's Chinese display name."""
|
||||||
|
|
||||||
|
name_zh: str = Field(default="", description="中文名称;传空字符串表示清空")
|
||||||
|
|
||||||
|
|
||||||
|
class SkillNameZhResponse(BaseModel):
|
||||||
|
"""Response model for a single skill's Chinese-name update."""
|
||||||
|
|
||||||
|
name: str = Field(..., description="Skill name")
|
||||||
|
name_zh: str | None = Field(default=None, description="中文名称(为空表示已清空)")
|
||||||
|
|
||||||
|
|
||||||
|
class SkillNameZhBulkResponse(BaseModel):
|
||||||
|
"""Response model for the one-click bulk fill of missing Chinese names."""
|
||||||
|
|
||||||
|
generated: list[SkillNameZhResponse] = Field(default_factory=list, description="本次成功生成中文名的技能")
|
||||||
|
failed: list[str] = Field(default_factory=list, description="生成失败的技能英文名")
|
||||||
|
skipped: int = Field(default=0, description="已有中文名而被跳过的技能数")
|
||||||
|
|
||||||
|
|
||||||
|
def _can_edit_skill_name_zh(skill: Skill, record: dict[str, Any] | None, user_id: str, *, is_admin: bool) -> bool:
|
||||||
|
"""Whether ``user_id`` may set this skill's Chinese name (mirrors detail auth)."""
|
||||||
|
if is_admin:
|
||||||
|
return True
|
||||||
|
if skill.category != SkillCategory.CUSTOM:
|
||||||
|
return False
|
||||||
|
return record is not None and record.get("owner_user_id") == user_id
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/skills/{skill_name}/name-zh",
|
||||||
|
response_model=SkillNameZhResponse,
|
||||||
|
summary="Set Skill Chinese Name",
|
||||||
|
description="设置技能的中文名称(存数据库,不改源文件)。自定义技能由其所有者或管理员编辑;内置/公开技能仅管理员可编辑。传空字符串表示清空,回退到使用英文名。",
|
||||||
|
)
|
||||||
|
async def set_skill_name_zh(
|
||||||
|
skill_name: str,
|
||||||
|
body: SkillNameZhRequest,
|
||||||
|
request: Request,
|
||||||
|
config: AppConfig = Depends(get_config),
|
||||||
|
skill_store: SkillStore = Depends(get_skill_store),
|
||||||
|
) -> SkillNameZhResponse:
|
||||||
|
try:
|
||||||
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
is_admin = _is_admin_user(request)
|
||||||
|
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||||
|
skill = next((s for s in skills if s.name == skill_name), None)
|
||||||
|
if skill is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||||
|
name_zh = body.name_zh.strip() or None
|
||||||
|
stored = await _persist_skill_name_zh(skill, name_zh, skill_store, user_id, is_admin=is_admin)
|
||||||
|
return SkillNameZhResponse(name=skill_name, name_zh=stored)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to set name_zh for %s: %s", skill_name, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to set skill Chinese name: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/skills/{skill_name}/name-zh/generate",
|
||||||
|
response_model=SkillNameZhResponse,
|
||||||
|
summary="Generate Skill Chinese Name",
|
||||||
|
description="用大模型为该技能生成一个中文名称并保存到数据库。",
|
||||||
|
)
|
||||||
|
async def generate_skill_name_zh(
|
||||||
|
skill_name: str,
|
||||||
|
request: Request,
|
||||||
|
config: AppConfig = Depends(get_config),
|
||||||
|
skill_store: SkillStore = Depends(get_skill_store),
|
||||||
|
) -> SkillNameZhResponse:
|
||||||
|
try:
|
||||||
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
is_admin = _is_admin_user(request)
|
||||||
|
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||||
|
skill = next((s for s in skills if s.name == skill_name), None)
|
||||||
|
if skill is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||||
|
name_zh = await _generate_skill_name_zh(skill, config)
|
||||||
|
if not name_zh:
|
||||||
|
raise HTTPException(status_code=502, detail="模型未能生成有效的中文名称,请稍后重试或手动填写")
|
||||||
|
stored = await _persist_skill_name_zh(skill, name_zh, skill_store, user_id, is_admin=is_admin)
|
||||||
|
return SkillNameZhResponse(name=skill_name, name_zh=stored)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to generate name_zh for %s: %s", skill_name, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to generate skill Chinese name: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/skills/name-zh/generate-missing",
|
||||||
|
response_model=SkillNameZhBulkResponse,
|
||||||
|
summary="Bulk Generate Missing Skill Chinese Names",
|
||||||
|
description="一键为库中所有尚未配置中文名称、且当前用户有权编辑的技能生成中文名(存数据库)。",
|
||||||
|
)
|
||||||
|
async def generate_missing_skill_names_zh(
|
||||||
|
request: Request,
|
||||||
|
config: AppConfig = Depends(get_config),
|
||||||
|
skill_store: SkillStore = Depends(get_skill_store),
|
||||||
|
) -> SkillNameZhBulkResponse:
|
||||||
|
try:
|
||||||
|
from deerflow.models import create_chat_model
|
||||||
|
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
is_admin = _is_admin_user(request)
|
||||||
|
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||||
|
|
||||||
|
# Existing name_zh values come from the DB. Admins see every row; others
|
||||||
|
# see only the rows visible to them (owned / published / built-in).
|
||||||
|
if is_admin:
|
||||||
|
records = {r["name"]: r for r in await skill_store.list_all()}
|
||||||
|
else:
|
||||||
|
records = {r["name"]: r for r in await skill_store.list_visible(user_id)}
|
||||||
|
|
||||||
|
pending: list[Skill] = []
|
||||||
|
skipped = 0
|
||||||
|
for skill in skills:
|
||||||
|
record = records.get(skill.name)
|
||||||
|
if record and record.get("name_zh"):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
# Skip skills the caller is not allowed to annotate (no error — bulk
|
||||||
|
# is best-effort over what the user can edit).
|
||||||
|
if not _can_edit_skill_name_zh(skill, record, user_id, is_admin=is_admin):
|
||||||
|
continue
|
||||||
|
pending.append(skill)
|
||||||
|
|
||||||
|
generated: list[SkillNameZhResponse] = []
|
||||||
|
failed: list[str] = []
|
||||||
|
if pending:
|
||||||
|
# Build the model once and reuse it for every skill.
|
||||||
|
try:
|
||||||
|
model_name = config.skill_evolution.curator.model_name if config.skill_evolution and config.skill_evolution.curator else None
|
||||||
|
model = create_chat_model(name=model_name, thinking_enabled=False, app_config=config)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to build model for bulk name_zh generation", exc_info=True)
|
||||||
|
model = None
|
||||||
|
|
||||||
|
# Generate concurrently with a small bound to avoid overloading the model.
|
||||||
|
semaphore = asyncio.Semaphore(4)
|
||||||
|
|
||||||
|
async def _one(skill: Skill) -> tuple[Skill, str | None]:
|
||||||
|
async with semaphore:
|
||||||
|
return skill, await _generate_skill_name_zh(skill, config, model=model)
|
||||||
|
|
||||||
|
results = await asyncio.gather(*[_one(s) for s in pending])
|
||||||
|
for skill, name_zh in results:
|
||||||
|
if not name_zh:
|
||||||
|
failed.append(skill.name)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
stored = await _persist_skill_name_zh(skill, name_zh, skill_store, user_id, is_admin=is_admin)
|
||||||
|
generated.append(SkillNameZhResponse(name=skill.name, name_zh=stored))
|
||||||
|
except HTTPException:
|
||||||
|
# Permission / persistence issue for this one skill — skip it.
|
||||||
|
failed.append(skill.name)
|
||||||
|
|
||||||
|
return SkillNameZhBulkResponse(generated=generated, failed=failed, skipped=skipped)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to bulk-generate skill Chinese names: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to bulk-generate skill Chinese names: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
class SkillPinRequest(BaseModel):
|
class SkillPinRequest(BaseModel):
|
||||||
pinned: bool = Field(..., description="Whether to pin or unpin the skill")
|
pinned: bool = Field(..., description="Whether to pin or unpin the skill")
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,6 @@ class SkillStateConfig(BaseModel):
|
|||||||
|
|
||||||
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
||||||
display: SkillDisplayConfig | None = Field(default=None, description="Optional frontend display adapter")
|
display: SkillDisplayConfig | None = Field(default=None, description="Optional frontend display adapter")
|
||||||
name_zh: str | None = Field(default=None, description="用户配置或大模型生成的中文名称(为空表示沿用技能英文名)")
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionsConfig(BaseModel):
|
class ExtensionsConfig(BaseModel):
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
"""Add name_zh column to skills table.
|
||||||
|
|
||||||
|
Revision ID: 20260609_01
|
||||||
|
Revises: 20260608_01
|
||||||
|
Create Date: 2026-06-09
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260609_01"
|
||||||
|
down_revision = "20260608_01"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
inspector = inspect(op.get_bind())
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return False
|
||||||
|
inspector = inspect(op.get_bind())
|
||||||
|
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ``skills`` may not exist yet on a brand-new DB — create_all handles that
|
||||||
|
# case with the column already present, so only patch existing tables.
|
||||||
|
if _has_table("skills") and not _has_column("skills", "name_zh"):
|
||||||
|
op.add_column("skills", sa.Column("name_zh", sa.String(length=128), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _has_column("skills", "name_zh"):
|
||||||
|
op.drop_column("skills", "name_zh")
|
||||||
+6
@@ -70,6 +70,7 @@ class MemorySkillStore(SkillStore):
|
|||||||
"owner_user_id": data.get("owner_user_id"),
|
"owner_user_id": data.get("owner_user_id"),
|
||||||
"published": bool(data.get("published", False)),
|
"published": bool(data.get("published", False)),
|
||||||
"detail": data.get("detail"),
|
"detail": data.get("detail"),
|
||||||
|
"name_zh": data.get("name_zh"),
|
||||||
"created_at": _now(),
|
"created_at": _now(),
|
||||||
"updated_at": _now(),
|
"updated_at": _now(),
|
||||||
}
|
}
|
||||||
@@ -85,6 +86,7 @@ class MemorySkillStore(SkillStore):
|
|||||||
"owner_user_id": data.get("owner_user_id"),
|
"owner_user_id": data.get("owner_user_id"),
|
||||||
"published": bool(data.get("published", True)),
|
"published": bool(data.get("published", True)),
|
||||||
"detail": data.get("detail"),
|
"detail": data.get("detail"),
|
||||||
|
"name_zh": data.get("name_zh"),
|
||||||
"created_at": _now(),
|
"created_at": _now(),
|
||||||
"updated_at": _now(),
|
"updated_at": _now(),
|
||||||
}
|
}
|
||||||
@@ -102,6 +104,8 @@ class MemorySkillStore(SkillStore):
|
|||||||
record["published"] = bool(data["published"])
|
record["published"] = bool(data["published"])
|
||||||
if "detail" in data:
|
if "detail" in data:
|
||||||
record["detail"] = data["detail"]
|
record["detail"] = data["detail"]
|
||||||
|
if "name_zh" in data:
|
||||||
|
record["name_zh"] = data["name_zh"]
|
||||||
record["updated_at"] = _now()
|
record["updated_at"] = _now()
|
||||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||||
|
|
||||||
@@ -115,6 +119,8 @@ class MemorySkillStore(SkillStore):
|
|||||||
record["owner_user_id"] = data["owner_user_id"]
|
record["owner_user_id"] = data["owner_user_id"]
|
||||||
if "detail" in data:
|
if "detail" in data:
|
||||||
record["detail"] = data["detail"]
|
record["detail"] = data["detail"]
|
||||||
|
if "name_zh" in data:
|
||||||
|
record["name_zh"] = data["name_zh"]
|
||||||
record["updated_at"] = _now()
|
record["updated_at"] = _now()
|
||||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ class SkillRow(Base):
|
|||||||
# kept in the DB so admins can annotate built-in skills without rewriting
|
# kept in the DB so admins can annotate built-in skills without rewriting
|
||||||
# the shared on-disk SKILL.md file.
|
# the shared on-disk SKILL.md file.
|
||||||
detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||||
|
# User-configured or LLM-generated Chinese display name. Kept in the DB (not
|
||||||
|
# the on-disk SKILL.md) so it can be set for built-in skills too, and so the
|
||||||
|
# shared source file is never rewritten.
|
||||||
|
name_zh: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class SkillRepository(SkillStore):
|
|||||||
owner_user_id=data.get("owner_user_id"),
|
owner_user_id=data.get("owner_user_id"),
|
||||||
published=bool(data.get("published", False)),
|
published=bool(data.get("published", False)),
|
||||||
detail=data.get("detail"),
|
detail=data.get("detail"),
|
||||||
|
name_zh=data.get("name_zh"),
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
@@ -83,6 +84,7 @@ class SkillRepository(SkillStore):
|
|||||||
owner_user_id=data.get("owner_user_id"),
|
owner_user_id=data.get("owner_user_id"),
|
||||||
published=bool(data.get("published", True)),
|
published=bool(data.get("published", True)),
|
||||||
detail=data.get("detail"),
|
detail=data.get("detail"),
|
||||||
|
name_zh=data.get("name_zh"),
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
@@ -105,6 +107,8 @@ class SkillRepository(SkillStore):
|
|||||||
row.published = bool(data["published"])
|
row.published = bool(data["published"])
|
||||||
if "detail" in data:
|
if "detail" in data:
|
||||||
row.detail = data["detail"]
|
row.detail = data["detail"]
|
||||||
|
if "name_zh" in data:
|
||||||
|
row.name_zh = data["name_zh"]
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
@@ -121,6 +125,8 @@ class SkillRepository(SkillStore):
|
|||||||
row.owner_user_id = data["owner_user_id"]
|
row.owner_user_id = data["owner_user_id"]
|
||||||
if "detail" in data:
|
if "detail" in data:
|
||||||
row.detail = data["detail"]
|
row.detail = data["detail"]
|
||||||
|
if "name_zh" in data:
|
||||||
|
row.name_zh = data["name_zh"]
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
|
|||||||
Reference in new issue
Block a user