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>
|
||||
) : 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 = [
|
||||
...(agent.tool_groups ?? []).map((group) => ({
|
||||
key: `tg:${group}`,
|
||||
@@ -532,7 +541,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
setDetailSkillName(skill);
|
||||
}}
|
||||
>
|
||||
{desensitize(skill)}
|
||||
{skillNameZhMap.get(skill) ?? desensitize(skill)}
|
||||
</Badge>
|
||||
),
|
||||
})),
|
||||
@@ -831,7 +840,11 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
title={desensitize(skill.description ?? "")}
|
||||
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
|
||||
type="button"
|
||||
|
||||
@@ -68,6 +68,7 @@ import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
import {
|
||||
useDeleteCustomSkill,
|
||||
useDuplicateSkill,
|
||||
useGenerateMissingSkillNameZh,
|
||||
useInstallSkillUpload,
|
||||
useReconcileCustomSkills,
|
||||
useSetSkillFeatured,
|
||||
@@ -155,6 +156,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [viewMode, setViewMode] = useViewMode("skill");
|
||||
const reconcileMutation = useReconcileCustomSkills();
|
||||
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
|
||||
|
||||
@@ -232,6 +234,23 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
[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 () => {
|
||||
try {
|
||||
const result = await reconcileMutation.mutateAsync();
|
||||
@@ -322,6 +341,23 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
? t.settings.skills.rescanSkillsRunning
|
||||
: t.settings.skills.rescanSkills}
|
||||
</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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -550,8 +586,13 @@ export function SkillRow({
|
||||
const badgeGroup = (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn(isCard && "truncate font-medium")}>
|
||||
{desensitize(skill.name)}
|
||||
{skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)}
|
||||
</span>
|
||||
{skill.name_zh?.trim() && (
|
||||
<span className="text-muted-foreground truncate text-xs font-normal">
|
||||
{desensitize(skill.name)}
|
||||
</span>
|
||||
)}
|
||||
{isBuiltin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t.settings.skills.badgeBuiltin}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
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 { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
useGenerateSkillNameZh,
|
||||
useSetSkillDetail,
|
||||
useSetSkillNameZh,
|
||||
useSkill,
|
||||
useSkillContent,
|
||||
useSkillFiles,
|
||||
@@ -98,9 +101,12 @@ export function SkillDetailDialog({
|
||||
const activeName = open ? skillName : null;
|
||||
const { data: skill, isLoading: isSkillLoading } = useSkill(activeName);
|
||||
const setDetail = useSetSkillDetail();
|
||||
const setNameZh = useSetSkillNameZh();
|
||||
const generateNameZh = useGenerateSkillNameZh();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [nameZhDraft, setNameZhDraft] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"detail" | "display" | "source" | "files" | null
|
||||
>(null);
|
||||
@@ -136,6 +142,10 @@ export function SkillDetailDialog({
|
||||
setOpenedFilePath(null);
|
||||
}, [open, skillName]);
|
||||
|
||||
useEffect(() => {
|
||||
setNameZhDraft(skill?.name_zh ?? "");
|
||||
}, [skill?.name, skill?.name_zh]);
|
||||
|
||||
const skillBody = useMemo(() => {
|
||||
return stripYamlFrontmatter(contentData?.content ?? "");
|
||||
}, [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() {
|
||||
await navigator.clipboard.writeText(contentData?.content ?? "");
|
||||
toast.success("Copied SKILL.md content");
|
||||
@@ -183,8 +217,15 @@ export function SkillDetailDialog({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DialogTitle className="truncate text-base">
|
||||
{desensitize(skill?.name ?? skillName ?? "")}
|
||||
{skill?.name_zh?.trim()
|
||||
? skill.name_zh
|
||||
: desensitize(skill?.name ?? skillName ?? "")}
|
||||
</DialogTitle>
|
||||
{skill?.name_zh?.trim() && (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{desensitize(skill?.name ?? skillName ?? "")}
|
||||
</span>
|
||||
)}
|
||||
{skill?.builtin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{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"
|
||||
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 ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
|
||||
@@ -430,6 +430,65 @@ export async function saveSkillFile(
|
||||
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 {
|
||||
name: string;
|
||||
detail: string | null;
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
deleteCustomSkill,
|
||||
duplicateSkill,
|
||||
enableSkill,
|
||||
generateMissingSkillNameZh,
|
||||
generateSkillNameZh,
|
||||
getSkill,
|
||||
getSkillContent,
|
||||
getSkillFileContent,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
reconcileCustomSkills,
|
||||
saveSkillFile,
|
||||
setSkillDetail,
|
||||
setSkillNameZh,
|
||||
setSkillDisplay,
|
||||
setSkillPinned,
|
||||
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() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface SkillDisplayConfig extends Omit<SkillDisplayResultSetConfig, "k
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
// 用户配置或大模型生成的中文名称;为空时界面回退到使用英文 name。
|
||||
name_zh?: string | null;
|
||||
description: string;
|
||||
category: string;
|
||||
license: string;
|
||||
|
||||
@@ -416,13 +416,7 @@ function VaultPageDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function NoteDetail({
|
||||
noteId,
|
||||
onEntityClick,
|
||||
}: {
|
||||
noteId: string;
|
||||
onEntityClick?: (name: string) => void;
|
||||
}) {
|
||||
function NoteDetail({ noteId }: { noteId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: note, isLoading } = useKnowledgeNote(noteId);
|
||||
const { data: wikiLinks } = useNoteWikilinks(noteId);
|
||||
@@ -591,22 +585,20 @@ function NoteDetail({
|
||||
{note.entities.map((ent) => {
|
||||
const meta = entityMeta(ent.entity_type);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<span
|
||||
key={`${ent.name}-${ent.entity_type ?? ""}`}
|
||||
onClick={() => onEntityClick?.(ent.name)}
|
||||
title={`筛选提及「${ent.name}」的知识`}
|
||||
title={meta.label}
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<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 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}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -792,14 +784,7 @@ export default function KnowledgeBasePage() {
|
||||
|
||||
<div className="min-h-0">
|
||||
{noteId ? (
|
||||
<NoteDetail
|
||||
noteId={noteId}
|
||||
onEntityClick={(name) => {
|
||||
setKeyword(name);
|
||||
setSourceType("all");
|
||||
setStatus("all");
|
||||
}}
|
||||
/>
|
||||
<NoteDetail noteId={noteId} />
|
||||
) : (
|
||||
<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(
|
||||
(skill) =>
|
||||
skill.name.toLowerCase().includes(q) ||
|
||||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
||||
(skill.description ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [enabledSkills, skillSearch]);
|
||||
@@ -590,7 +591,9 @@ export default function NewAgentPage() {
|
||||
!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
|
||||
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
|
||||
- **Injection**: Enabled skills listed in agent system prompt with container paths
|
||||
- **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`)
|
||||
|
||||
|
||||
@@ -307,22 +307,22 @@ def _skill_to_response(
|
||||
published = False
|
||||
builtin = skill.category != SkillCategory.CUSTOM
|
||||
detail: str | None = None
|
||||
name_zh: str | None = None
|
||||
if ownership is not None:
|
||||
owner_user_id = ownership.get("owner_user_id")
|
||||
published = bool(ownership.get("published", False))
|
||||
builtin = bool(ownership.get("builtin", owner_user_id is None))
|
||||
detail = ownership.get("detail")
|
||||
name_zh = ownership.get("name_zh")
|
||||
|
||||
display = None if display_override is _UNSET else display_override
|
||||
name_zh: str | None = None
|
||||
try:
|
||||
skill_config = get_extensions_config().skills.get(skill.name)
|
||||
if skill_config:
|
||||
if display_override is _UNSET and skill_config.display:
|
||||
if display_override is _UNSET:
|
||||
try:
|
||||
skill_config = get_extensions_config().skills.get(skill.name)
|
||||
if skill_config and skill_config.display:
|
||||
display = skill_config.display.model_dump(by_alias=True)
|
||||
name_zh = skill_config.name_zh or None
|
||||
except Exception:
|
||||
logger.debug("Failed to read extensions config for %s", skill.name, exc_info=True)
|
||||
except Exception:
|
||||
logger.debug("Failed to read display adapter for %s", skill.name, exc_info=True)
|
||||
|
||||
return SkillResponse(
|
||||
name=skill.name,
|
||||
@@ -2146,7 +2146,6 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
||||
extensions_config.skills[skill_name] = SkillStateConfig(
|
||||
enabled=request.enabled,
|
||||
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)
|
||||
@@ -2355,6 +2354,276 @@ async def update_skill_detail(
|
||||
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):
|
||||
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")
|
||||
display: SkillDisplayConfig | None = Field(default=None, description="Optional frontend display adapter")
|
||||
name_zh: str | None = Field(default=None, description="用户配置或大模型生成的中文名称(为空表示沿用技能英文名)")
|
||||
|
||||
|
||||
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"),
|
||||
"published": bool(data.get("published", False)),
|
||||
"detail": data.get("detail"),
|
||||
"name_zh": data.get("name_zh"),
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
@@ -85,6 +86,7 @@ class MemorySkillStore(SkillStore):
|
||||
"owner_user_id": data.get("owner_user_id"),
|
||||
"published": bool(data.get("published", True)),
|
||||
"detail": data.get("detail"),
|
||||
"name_zh": data.get("name_zh"),
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
@@ -102,6 +104,8 @@ class MemorySkillStore(SkillStore):
|
||||
record["published"] = bool(data["published"])
|
||||
if "detail" in data:
|
||||
record["detail"] = data["detail"]
|
||||
if "name_zh" in data:
|
||||
record["name_zh"] = data["name_zh"]
|
||||
record["updated_at"] = _now()
|
||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
|
||||
@@ -115,6 +119,8 @@ class MemorySkillStore(SkillStore):
|
||||
record["owner_user_id"] = data["owner_user_id"]
|
||||
if "detail" in data:
|
||||
record["detail"] = data["detail"]
|
||||
if "name_zh" in data:
|
||||
record["name_zh"] = data["name_zh"]
|
||||
record["updated_at"] = _now()
|
||||
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
|
||||
# the shared on-disk SKILL.md file.
|
||||
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))
|
||||
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"),
|
||||
published=bool(data.get("published", False)),
|
||||
detail=data.get("detail"),
|
||||
name_zh=data.get("name_zh"),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -83,6 +84,7 @@ class SkillRepository(SkillStore):
|
||||
owner_user_id=data.get("owner_user_id"),
|
||||
published=bool(data.get("published", True)),
|
||||
detail=data.get("detail"),
|
||||
name_zh=data.get("name_zh"),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -105,6 +107,8 @@ class SkillRepository(SkillStore):
|
||||
row.published = bool(data["published"])
|
||||
if "detail" in data:
|
||||
row.detail = data["detail"]
|
||||
if "name_zh" in data:
|
||||
row.name_zh = data["name_zh"]
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
@@ -121,6 +125,8 @@ class SkillRepository(SkillStore):
|
||||
row.owner_user_id = data["owner_user_id"]
|
||||
if "detail" in data:
|
||||
row.detail = data["detail"]
|
||||
if "name_zh" in data:
|
||||
row.name_zh = data["name_zh"]
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
|
||||
Reference in new issue
Block a user