feat: 新增技能置顶功能

- 后端:新增 PUT /skills/custom/{name}/featured 接口,在 usage 中实现 set_featured 与 featured 字段(与 pinned 正交,仅影响排序)
- 前端:新增 setSkillFeatured API、置顶/取消置顶操作及中英文文案
- ScheduledTasksPage:优化助手选择下拉框的宽度与文本换行
- config.yaml:整理 log_system 配置块

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-05 15:02:50 +08:00
1 parent b117bb1dc2
commit eaa061dd06
11 files changed
+156 -9

No files matched your search

@@ -4,6 +4,7 @@ import {
AlertCircle, AlertCircle,
ArchiveIcon, ArchiveIcon,
ArrowDownUpIcon, ArrowDownUpIcon,
BookmarkIcon,
CheckCircle2, CheckCircle2,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
@@ -67,6 +68,7 @@ import {
useDuplicateSkill, useDuplicateSkill,
useInstallSkillUpload, useInstallSkillUpload,
useReconcileCustomSkills, useReconcileCustomSkills,
useSetSkillFeatured,
useSetSkillPinned, useSetSkillPinned,
useSetSkillPublished, useSetSkillPublished,
useSkills, useSkills,
@@ -201,8 +203,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
filteredSkills filteredSkills
.map((skill, index) => ({ skill, index })) .map((skill, index) => ({ skill, index }))
.sort((a, b) => { .sort((a, b) => {
const pinnedDelta = Number(!!b.skill.pinned) - Number(!!a.skill.pinned); // 置顶(featured)决定排序;钉选(pinned)只管防归档,不影响顺序。
return pinnedDelta || a.index - b.index; const featuredDelta =
Number(!!b.skill.featured) - Number(!!a.skill.featured);
return featuredDelta || a.index - b.index;
}) })
.map(({ skill }) => skill), .map(({ skill }) => skill),
[filteredSkills], [filteredSkills],
@@ -431,6 +435,7 @@ export function SkillRow({
const { t } = useI18n(); const { t } = useI18n();
const setPublished = useSetSkillPublished(); const setPublished = useSetSkillPublished();
const setPinned = useSetSkillPinned(); const setPinned = useSetSkillPinned();
const setFeatured = useSetSkillFeatured();
const deleteSkill = useDeleteCustomSkill(); const deleteSkill = useDeleteCustomSkill();
const duplicateSkillMutation = useDuplicateSkill(); const duplicateSkillMutation = useDuplicateSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
@@ -510,6 +515,20 @@ export function SkillRow({
} }
} }
async function handleToggleFeatured() {
const next = !skill.featured;
try {
await setFeatured.mutateAsync({ skillName: skill.name, featured: next });
toast.success(
next
? t.settings.skills.featureSuccess
: t.settings.skills.unfeatureSuccess,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return ( return (
<> <>
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline"> <Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
@@ -577,6 +596,15 @@ export function SkillRow({
{t.settings.skills.badgeSourceUpload} {t.settings.skills.badgeSourceUpload}
</Badge> </Badge>
)} )}
{skill.featured && (
<Badge
variant="outline"
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
title={t.settings.skills.badgeFeaturedTitle}
>
{t.settings.skills.badgeFeatured}
</Badge>
)}
{skill.pinned && ( {skill.pinned && (
<Badge <Badge
variant="outline" variant="outline"
@@ -617,6 +645,27 @@ export function SkillRow({
<TagsIcon className="size-3.5" /> <TagsIcon className="size-3.5" />
</Button> </Button>
)} )}
{canManage && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
skill.featured && "text-amber-600 hover:text-amber-700",
)}
disabled={setFeatured.isPending}
onClick={() => void handleToggleFeatured()}
title={
skill.featured
? t.settings.skills.unfeatureAction
: t.settings.skills.featureAction
}
>
<PinIcon
className={cn("size-3.5", skill.featured && "fill-current")}
/>
</Button>
)}
{canManage && ( {canManage && (
<Button <Button
size="icon" size="icon"
@@ -633,7 +682,9 @@ export function SkillRow({
: t.settings.skills.pinAction : t.settings.skills.pinAction
} }
> >
<PinIcon className={cn("size-3.5", skill.pinned && "fill-current")} /> <BookmarkIcon
className={cn("size-3.5", skill.pinned && "fill-current")}
/>
</Button> </Button>
)} )}
{canTogglePublish && ( {canTogglePublish && (
@@ -476,6 +476,8 @@ export const enUS: Translations = {
badgeArchivedTitle: "Archived — recoverable from the archive drawer", badgeArchivedTitle: "Archived — recoverable from the archive drawer",
badgePinned: "Pinned", badgePinned: "Pinned",
badgePinnedTitle: "Pinned — the curator will never auto-archive this skill", badgePinnedTitle: "Pinned — the curator will never auto-archive this skill",
badgeFeatured: "Top",
badgeFeaturedTitle: "Pinned to the top of the list",
badgeSourceAgent: "Agent-created", badgeSourceAgent: "Agent-created",
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation", badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
badgeSourceUpload: "Uploaded", badgeSourceUpload: "Uploaded",
@@ -494,6 +496,10 @@ export const enUS: Translations = {
unpinAction: "Unpin", unpinAction: "Unpin",
pinSuccess: "Pinned", pinSuccess: "Pinned",
unpinSuccess: "Unpinned", unpinSuccess: "Unpinned",
featureAction: "Pin to top",
unfeatureAction: "Unpin from top",
featureSuccess: "Pinned to top",
unfeatureSuccess: "Unpinned from top",
deleteAction: "Delete skill", deleteAction: "Delete skill",
deleteConfirm: deleteConfirm:
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.", "Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
@@ -409,6 +409,8 @@ export interface Translations {
badgeArchivedTitle: string; badgeArchivedTitle: string;
badgePinned: string; badgePinned: string;
badgePinnedTitle: string; badgePinnedTitle: string;
badgeFeatured: string;
badgeFeaturedTitle: string;
badgeSourceAgent: string; badgeSourceAgent: string;
badgeSourceAgentTitle: string; badgeSourceAgentTitle: string;
badgeSourceUpload: string; badgeSourceUpload: string;
@@ -427,6 +429,10 @@ export interface Translations {
unpinAction: string; unpinAction: string;
pinSuccess: string; pinSuccess: string;
unpinSuccess: string; unpinSuccess: string;
featureAction: string;
unfeatureAction: string;
featureSuccess: string;
unfeatureSuccess: string;
deleteAction: string; deleteAction: string;
deleteConfirm: string; deleteConfirm: string;
deleteSuccess: string; deleteSuccess: string;
@@ -456,6 +456,8 @@ export const zhCN: Translations = {
badgeArchivedTitle: "已被归档,可在归档抽屉中恢复", badgeArchivedTitle: "已被归档,可在归档抽屉中恢复",
badgePinned: "钉选", badgePinned: "钉选",
badgePinnedTitle: "已钉选,策展程序不会自动归档此技能", badgePinnedTitle: "已钉选,策展程序不会自动归档此技能",
badgeFeatured: "置顶",
badgeFeaturedTitle: "已置顶,固定显示在列表顶部",
badgeSourceAgent: "Agent 创建", badgeSourceAgent: "Agent 创建",
badgeSourceAgentTitle: "在对话中由 Agent 自动生成", badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
badgeSourceUpload: "用户上传", badgeSourceUpload: "用户上传",
@@ -474,6 +476,10 @@ export const zhCN: Translations = {
unpinAction: "取消钉选", unpinAction: "取消钉选",
pinSuccess: "已钉选", pinSuccess: "已钉选",
unpinSuccess: "已取消钉选", unpinSuccess: "已取消钉选",
featureAction: "置顶",
unfeatureAction: "取消置顶",
featureSuccess: "已置顶",
unfeatureSuccess: "已取消置顶",
deleteAction: "删除技能", deleteAction: "删除技能",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。", deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
deleteSuccess: "技能已删除", deleteSuccess: "技能已删除",
+18
View File
@@ -298,6 +298,24 @@ export async function setSkillPinned(
return response.json() as Promise<Skill>; return response.json() as Promise<Skill>;
} }
export async function setSkillFeatured(
skillName: string,
featured: boolean,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/featured`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ featured }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill featured state");
}
return response.json() as Promise<Skill>;
}
export interface SkillContentResponse { export interface SkillContentResponse {
name: string; name: string;
category: string; category: string;
+12
View File
@@ -19,6 +19,7 @@ import {
setSkillDetail, setSkillDetail,
setSkillDisplay, setSkillDisplay,
setSkillPinned, setSkillPinned,
setSkillFeatured,
setSkillPublished, setSkillPublished,
setSkillState, setSkillState,
validateSkillUpload, validateSkillUpload,
@@ -201,6 +202,17 @@ export function useSetSkillPinned() {
}); });
} }
export function useSetSkillFeatured() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, featured }: { skillName: string; featured: boolean }) =>
setSkillFeatured(skillName, featured),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useDuplicateSkill() { export function useDuplicateSkill() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
+2
View File
@@ -60,6 +60,8 @@ export interface Skill {
// so stale / archived skills appear in dedicated drawers instead. // so stale / archived skills appear in dedicated drawers instead.
state?: SkillState; state?: SkillState;
pinned?: boolean; pinned?: boolean;
// 置顶 —— 仅影响列表排序,与 pinned(防自动归档)正交。
featured?: boolean;
// Provenance — distinguishes skills created by an agent during a chat // Provenance — distinguishes skills created by an agent during a chat
// (``agent``) from skills the user uploaded / authored manually (``upload``). // (``agent``) from skills the user uploaded / authored manually (``upload``).
source?: SkillSource; source?: SkillSource;
@@ -991,12 +991,12 @@ export default function ScheduledTasksPage() {
</SelectTrigger> </SelectTrigger>
<SelectContent <SelectContent
position="popper" position="popper"
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]" className="max-h-[40vh] w-[max(var(--radix-select-trigger-width),28rem)] max-w-[min(90vw,40rem)]"
> >
<SelectItem value="__default__">默认助手(不指定)</SelectItem> <SelectItem value="__default__">默认助手(不指定)</SelectItem>
{agents.map((agent) => ( {agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}> <SelectItem key={agent.id} value={agent.id}>
<span className="block truncate"> <span className="block whitespace-normal break-words">
{desensitize(agent.name)} {desensitize(agent.name)}
{agent.description ? ` — ${desensitize(agent.description)}` : ""} {agent.description ? ` — ${desensitize(agent.description)}` : ""}
</span> </span>
@@ -122,6 +122,7 @@ class SkillResponse(BaseModel):
created_at: datetime | None = Field(default=None, description="Skill creation timestamp when known") created_at: datetime | None = Field(default=None, description="Skill creation timestamp when known")
state: str = Field(default="active", description="Lifecycle state: active, stale, or archived") state: str = Field(default="active", description="Lifecycle state: active, stale, or archived")
pinned: bool = Field(default=False, description="Whether this skill is pinned (excluded from curation)") pinned: bool = Field(default=False, description="Whether this skill is pinned (excluded from curation)")
featured: bool = Field(default=False, description="Whether this skill is pinned to the top of the list (sort only)")
source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded") source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded")
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)") owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users") published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
@@ -294,6 +295,7 @@ def _skill_to_response(
created_at=created_at, created_at=created_at,
state=entry.get("state", "active"), state=entry.get("state", "active"),
pinned=entry.get("pinned", False), pinned=entry.get("pinned", False),
featured=entry.get("featured", False),
source=entry.get("source", "upload"), source=entry.get("source", "upload"),
owner_user_id=owner_user_id, owner_user_id=owner_user_id,
published=published, published=published,
@@ -1469,6 +1471,42 @@ async def update_custom_skill_pinned(
raise HTTPException(status_code=500, detail=f"Failed to update pinned: {e}") raise HTTPException(status_code=500, detail=f"Failed to update pinned: {e}")
class SkillFeaturedRequest(BaseModel):
"""Request body for pinning a custom skill to the top of the list."""
featured: bool = Field(..., description="Whether to pin the skill to the top of the list (sort only)")
@router.put(
"/skills/custom/{skill_name}/featured",
response_model=SkillResponse,
summary="Pin or Unpin a Custom Skill to Top",
description="Pin a skill to the top of the list (sort only; orthogonal to pinned/anti-archive). Only the owner or admins may invoke.",
)
async def update_custom_skill_featured(
skill_name: str,
body: SkillFeaturedRequest,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillResponse:
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
from deerflow.skills.usage import set_featured as _usage_set_featured
_usage_set_featured(skill_name, body.featured)
return _reload_skill_response(skill_name, record, config)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to update featured for %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update featured: {e}")
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History") @router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse: async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
try: try:
+6 -4
View File
@@ -210,10 +210,7 @@ auth_login:
# 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口, # 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口,
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。 # 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
log_system:
enabled: true
url: "https://your-log-system.example.com"
label: "日志系统"
database: database:
backend: sqlite backend: sqlite
@@ -300,3 +297,8 @@ ai_writing:
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型 keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型 rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20 max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
log_system:
enabled: true
url: "https://your-log-system.example.com"
label: "日志系统"
@@ -36,6 +36,7 @@ _DEFAULT_ENTRY: dict = {
"created_at": None, "created_at": None,
"state": STATE_ACTIVE, "state": STATE_ACTIVE,
"pinned": False, "pinned": False,
"featured": False,
"archived_at": None, "archived_at": None,
"source": SOURCE_UPLOAD, "source": SOURCE_UPLOAD,
} }
@@ -141,6 +142,11 @@ def set_pinned(name: str, pinned: bool) -> None:
_update_entry(name, {"pinned": pinned}) _update_entry(name, {"pinned": pinned})
def set_featured(name: str, featured: bool) -> None:
"""置顶/取消置顶。与 pinned(防归档)正交,仅影响列表排序展示。"""
_update_entry(name, {"featured": featured})
def forget(name: str) -> None: def forget(name: str) -> None:
try: try:
with _usage_lock: with _usage_lock: