style(knowledge): 实体改回纯展示(不可点击),chip 精致化

按需求去掉点击筛选,实体为静态彩色 chip:色点 + 名称 + 胶囊型类型标签。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AdministratorandClaude Opus 4.8 committed 2026-06-09 20:34:59 +08:00
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
+59
View File
@@ -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;
+36
View File
@@ -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({
+2
View File
@@ -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;
+8 -23
View File
@@ -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">
从左侧选择一条知识查看详情
+4 -1
View File
@@ -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):
@@ -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")
@@ -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)