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
+318 -171

No files matched your search

@@ -1,10 +1,11 @@
"use client"; "use client";
import { import {
AlertCircle, AlertCircle,
ArchiveIcon, ArchiveIcon,
ArrowDownUpIcon, ArrowDownUpIcon,
CheckCircle2, BookmarkIcon,
CheckCircle2,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
ClockIcon, ClockIcon,
@@ -12,22 +13,22 @@ import {
FileArchive, FileArchive,
GlobeIcon, GlobeIcon,
InfoIcon, InfoIcon,
Loader2, Loader2,
LockIcon, LockIcon,
PinIcon, PinIcon,
RefreshCwIcon, RefreshCwIcon,
SparklesIcon, SparklesIcon,
TagsIcon, TagsIcon,
Trash2Icon, Trash2Icon,
TrendingUpIcon, TrendingUpIcon,
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -37,8 +38,8 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { import {
Empty, Empty,
EmptyDescription, EmptyDescription,
EmptyHeader, EmptyHeader,
EmptyMedia, EmptyMedia,
EmptyTitle, EmptyTitle,
@@ -51,9 +52,9 @@ import {
ItemContent, ItemContent,
ItemDescription, ItemDescription,
} from "@/components/ui/item"; } from "@/components/ui/item";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog"; import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
@@ -67,6 +68,7 @@ import {
useDuplicateSkill, useDuplicateSkill,
useInstallSkillUpload, useInstallSkillUpload,
useReconcileCustomSkills, useReconcileCustomSkills,
useSetSkillFeatured,
useSetSkillPinned, useSetSkillPinned,
useSetSkillPublished, useSetSkillPublished,
useSkills, useSkills,
@@ -92,16 +94,16 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
); );
} }
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
type SkillSort = type SkillSort =
| "time_desc" | "time_desc"
| "time_asc" | "time_asc"
| "activity_desc" | "activity_desc"
| "activity_asc" | "activity_asc"
| "usage_desc" | "usage_desc"
| "usage_asc" | "usage_asc"
| "name_asc" | "name_asc"
| "name_desc"; | "name_desc";
/** 重新扫描成功 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */ /** 重新扫描成功 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
const rescanSuccessToastDarkOptions = { const rescanSuccessToastDarkOptions = {
@@ -136,21 +138,21 @@ const rescanErrorToastDarkOptions = {
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />, icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const; } as const;
function SkillSettingsList({ onClose }: { onClose?: () => void }) { function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const { t } = useI18n(); const { t } = useI18n();
const { theme } = useTheme(); const { theme } = useTheme();
const router = useRouter(); const router = useRouter();
const [filter, setFilter] = useState<string>("mine"); const [filter, setFilter] = useState<string>("mine");
const [installOpen, setInstallOpen] = useState(false); const [installOpen, setInstallOpen] = useState(false);
const [staleSheetOpen, setStaleSheetOpen] = useState(false); const [staleSheetOpen, setStaleSheetOpen] = useState(false);
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false); const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]); const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<SkillSort>("time_desc"); const [sort, setSort] = useState<SkillSort>("time_desc");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const reconcileMutation = useReconcileCustomSkills(); const reconcileMutation = useReconcileCustomSkills();
// 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 });
const storedAuth = useMemo(() => getStoredAuth(), []); const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null; const currentUserId = storedAuth?.user_id ?? null;
@@ -195,36 +197,38 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
}; };
}, [skills, currentUserId]); }, [skills, currentUserId]);
const filteredSkills = filter === "mine" ? activeMine : activeAll; const filteredSkills = filter === "mine" ? activeMine : activeAll;
const orderedSkills = useMemo( const orderedSkills = useMemo(
() => () =>
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);
.map(({ skill }) => skill), return featuredDelta || a.index - b.index;
[filteredSkills], })
); .map(({ skill }) => skill),
const staleCount = staleVisible.length; [filteredSkills],
const archivedCount = archivedVisible.length; );
const staleCount = staleVisible.length;
const totalPages = Math.max(1, Math.ceil(orderedSkills.length / PAGE_SIZE)); const archivedCount = archivedVisible.length;
const totalPages = Math.max(1, Math.ceil(orderedSkills.length / PAGE_SIZE));
// Filter / search / tag changes may shrink the result set — pull the page // Filter / search / tag changes may shrink the result set — pull the page
// back into range without forcing a reset on every keystroke. // back into range without forcing a reset on every keystroke.
useEffect(() => { useEffect(() => {
if (page > totalPages) setPage(totalPages); if (page > totalPages) setPage(totalPages);
}, [page, totalPages]); }, [page, totalPages]);
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
}, [search, tagIds, sort]); }, [search, tagIds, sort]);
const pagedSkills = useMemo( const pagedSkills = useMemo(
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), () => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[orderedSkills, page], [orderedSkills, page],
); );
const handleRescan = async () => { const handleRescan = async () => {
try { try {
const result = await reconcileMutation.mutateAsync(); const result = await reconcileMutation.mutateAsync();
if (result.adopted.length > 0) { if (result.adopted.length > 0) {
@@ -323,54 +327,54 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<Upload className="size-4" /> <Upload className="size-4" />
{t.settings.skills.installSkill} {t.settings.skills.installSkill}
</Button> </Button>
</div> </div>
</header> </header>
<TagSearchBar <TagSearchBar
targetType="skill" targetType="skill"
onSearchChange={setSearch} onSearchChange={setSearch}
tagIds={tagIds} tagIds={tagIds}
onTagIdsChange={setTagIds} onTagIdsChange={setTagIds}
actions={ actions={
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}> <Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
<SelectTrigger <SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]" className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序" title="排序"
> >
<ArrowDownUpIcon className="text-muted-foreground size-3.5" /> <ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}> <SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem> <SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem> <SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="activity_desc">最近使用</SelectItem> <SelectItem value="activity_desc">最近使用</SelectItem>
<SelectItem value="activity_asc">最早使用</SelectItem> <SelectItem value="activity_asc">最早使用</SelectItem>
<SelectItem value="usage_desc">使用最多</SelectItem> <SelectItem value="usage_desc">使用最多</SelectItem>
<SelectItem value="usage_asc">使用最少</SelectItem> <SelectItem value="usage_asc">使用最少</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem> <SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem> <SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
} }
/> />
{isLoading && ( {isLoading && (
<div className="text-muted-foreground text-sm">{t.common.loading}</div> <div className="text-muted-foreground text-sm">{t.common.loading}</div>
)} )}
{error && <div className="text-destructive text-sm">Error: {error.message}</div>} {error && <div className="text-destructive text-sm">Error: {error.message}</div>}
{!isLoading && !error && filteredSkills.length === 0 && ( {!isLoading && !error && filteredSkills.length === 0 && (
<EmptySkill /> <EmptySkill />
)} )}
{!isLoading && !error && filteredSkills.length > 0 && ( {!isLoading && !error && filteredSkills.length > 0 && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{pagedSkills.map((skill) => ( {pagedSkills.map((skill) => (
<SkillRow <SkillRow
key={skill.name} key={skill.name}
skill={skill} skill={skill}
currentUserId={currentUserId} currentUserId={currentUserId}
isAdmin={isAdmin} isAdmin={isAdmin}
/> />
))} ))}
</div> </div>
)} )}
{!isLoading && !error && totalPages > 1 && ( {!isLoading && !error && totalPages > 1 && (
<div className="flex items-center justify-end gap-2 pt-1"> <div className="flex items-center justify-end gap-2 pt-1">
<Button <Button
@@ -419,18 +423,19 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
); );
} }
export function SkillRow({ export function SkillRow({
skill, skill,
currentUserId, currentUserId,
isAdmin, isAdmin,
}: { }: {
skill: Skill; skill: Skill;
currentUserId: string | null; currentUserId: string | null;
isAdmin: boolean; isAdmin: boolean;
}) { }) {
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,9 +515,23 @@ 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">
<ItemContent <ItemContent
className="cursor-pointer" className="cursor-pointer"
onClick={() => setDetailOpen(true)} onClick={() => setDetailOpen(true)}
@@ -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"
@@ -618,24 +646,47 @@ export function SkillRow({
</Button> </Button>
)} )}
{canManage && ( {canManage && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className={cn( className={cn(
"h-8 w-8 shrink-0", "h-8 w-8 shrink-0",
skill.pinned && "text-emerald-600 hover:text-emerald-700", skill.featured && "text-amber-600 hover:text-amber-700",
)} )}
disabled={setPinned.isPending} disabled={setFeatured.isPending}
onClick={() => void handleTogglePinned()} onClick={() => void handleToggleFeatured()}
title={ title={
skill.featured
? t.settings.skills.unfeatureAction
: t.settings.skills.featureAction
}
>
<PinIcon
className={cn("size-3.5", skill.featured && "fill-current")}
/>
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
skill.pinned && "text-emerald-600 hover:text-emerald-700",
)}
disabled={setPinned.isPending}
onClick={() => void handleTogglePinned()}
title={
skill.pinned skill.pinned
? t.settings.skills.unpinAction ? t.settings.skills.unpinAction
: t.settings.skills.pinAction : t.settings.skills.pinAction
} }
> >
<PinIcon className={cn("size-3.5", skill.pinned && "fill-current")} /> <BookmarkIcon
</Button> className={cn("size-3.5", skill.pinned && "fill-current")}
)} />
</Button>
)}
{canTogglePublish && ( {canTogglePublish && (
<Button <Button
size="icon" size="icon"
@@ -678,8 +729,8 @@ export function SkillRow({
</Button> </Button>
)} )}
</ItemActions> </ItemActions>
</Item> </Item>
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}> <Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>复制技能</DialogTitle> <DialogTitle>复制技能</DialogTitle>
@@ -762,10 +813,10 @@ export function SkillRow({
); );
} }
function EmptySkill() { function EmptySkill() {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<Empty> <Empty>
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant="icon"> <EmptyMedia variant="icon">
<SparklesIcon /> <SparklesIcon />
@@ -775,9 +826,9 @@ function EmptySkill() {
{t.settings.skills.emptyDescription} {t.settings.skills.emptyDescription}
</EmptyDescription> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>
); );
} }
function InstallSkillDialog({ function InstallSkillDialog({
open, open,
@@ -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: "技能已删除",
+26 -8
View File
@@ -3,17 +3,17 @@ import { apiFetch } from "@/core/api";
import type { Skill, SkillDisplayConfig, SkillState } from "./type"; import type { Skill, SkillDisplayConfig, SkillState } from "./type";
export interface ListSkillsParams { export interface ListSkillsParams {
search?: string; search?: string;
tagIds?: string[]; tagIds?: string[];
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc"; sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
} }
export async function loadSkills(params: ListSkillsParams = {}) { export async function loadSkills(params: ListSkillsParams = {}) {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search); if (params.search) qs.set("search", params.search);
for (const id of params.tagIds ?? []) qs.append("tag_ids", id); for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
if (params.sort) qs.set("sort", params.sort); if (params.sort) qs.set("sort", params.sort);
const query = qs.toString(); const query = qs.toString();
const skills = await apiFetch( const skills = await apiFetch(
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`, `${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
@@ -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;
+22 -10
View File
@@ -1,4 +1,4 @@
import { import {
keepPreviousData, keepPreviousData,
useMutation, useMutation,
useQuery, useQuery,
@@ -19,11 +19,12 @@ import {
setSkillDetail, setSkillDetail,
setSkillDisplay, setSkillDisplay,
setSkillPinned, setSkillPinned,
setSkillFeatured,
setSkillPublished, setSkillPublished,
setSkillState, setSkillState,
validateSkillUpload, validateSkillUpload,
} from "./api"; } from "./api";
import type { ListSkillsParams } from "./api"; import type { ListSkillsParams } from "./api";
import type { Skill, SkillState } from "./type"; import type { Skill, SkillState } from "./type";
import { loadAllSkillsAdmin, loadSkills } from "."; import { loadAllSkillsAdmin, loadSkills } from ".";
@@ -34,13 +35,13 @@ import { loadAllSkillsAdmin, loadSkills } from ".";
// forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage). // forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage).
const EMPTY_SKILLS: Skill[] = []; const EMPTY_SKILLS: Skill[] = [];
export function useSkills(params: ListSkillsParams = {}) { export function useSkills(params: ListSkillsParams = {}) {
const search = params.search ?? ""; const search = params.search ?? "";
const tagIds = params.tagIds ?? []; const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc"; const sort = params.sort ?? "time_desc";
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["skills", search, [...tagIds].sort(), sort], queryKey: ["skills", search, [...tagIds].sort(), sort],
queryFn: () => loadSkills({ search, tagIds, sort }), queryFn: () => loadSkills({ search, tagIds, sort }),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
return { skills: data ?? EMPTY_SKILLS, isLoading, error }; return { skills: data ?? EMPTY_SKILLS, isLoading, error };
@@ -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: