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:
1 parent
b117bb1dc2
commit
eaa061dd06
11 files changed
+318
-171
No files matched your search
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
ArchiveIcon,
|
||||
ArrowDownUpIcon,
|
||||
CheckCircle2,
|
||||
import {
|
||||
AlertCircle,
|
||||
ArchiveIcon,
|
||||
ArrowDownUpIcon,
|
||||
BookmarkIcon,
|
||||
CheckCircle2,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
@@ -12,22 +13,22 @@ import {
|
||||
FileArchive,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
Loader2,
|
||||
LockIcon,
|
||||
PinIcon,
|
||||
RefreshCwIcon,
|
||||
Loader2,
|
||||
LockIcon,
|
||||
PinIcon,
|
||||
RefreshCwIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
TrendingUpIcon,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -37,8 +38,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
@@ -51,9 +52,9 @@ import {
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
useDuplicateSkill,
|
||||
useInstallSkillUpload,
|
||||
useReconcileCustomSkills,
|
||||
useSetSkillFeatured,
|
||||
useSetSkillPinned,
|
||||
useSetSkillPublished,
|
||||
useSkills,
|
||||
@@ -92,16 +94,16 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
type SkillSort =
|
||||
| "time_desc"
|
||||
| "time_asc"
|
||||
| "activity_desc"
|
||||
| "activity_asc"
|
||||
| "usage_desc"
|
||||
| "usage_asc"
|
||||
| "name_asc"
|
||||
| "name_desc";
|
||||
const PAGE_SIZE = 10;
|
||||
type SkillSort =
|
||||
| "time_desc"
|
||||
| "time_asc"
|
||||
| "activity_desc"
|
||||
| "activity_asc"
|
||||
| "usage_desc"
|
||||
| "usage_asc"
|
||||
| "name_asc"
|
||||
| "name_desc";
|
||||
|
||||
/** 重新扫描成功 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
|
||||
const rescanSuccessToastDarkOptions = {
|
||||
@@ -136,21 +138,21 @@ const rescanErrorToastDarkOptions = {
|
||||
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
||||
} as const;
|
||||
|
||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const { theme } = useTheme();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<string>("mine");
|
||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const { theme } = useTheme();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<string>("mine");
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [sort, setSort] = useState<SkillSort>("time_desc");
|
||||
const [page, setPage] = useState(1);
|
||||
const reconcileMutation = useReconcileCustomSkills();
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [sort, setSort] = useState<SkillSort>("time_desc");
|
||||
const [page, setPage] = useState(1);
|
||||
const reconcileMutation = useReconcileCustomSkills();
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
@@ -195,36 +197,38 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
};
|
||||
}, [skills, currentUserId]);
|
||||
|
||||
const filteredSkills = filter === "mine" ? activeMine : activeAll;
|
||||
const orderedSkills = useMemo(
|
||||
() =>
|
||||
filteredSkills
|
||||
.map((skill, index) => ({ skill, index }))
|
||||
.sort((a, b) => {
|
||||
const pinnedDelta = Number(!!b.skill.pinned) - Number(!!a.skill.pinned);
|
||||
return pinnedDelta || a.index - b.index;
|
||||
})
|
||||
.map(({ skill }) => skill),
|
||||
[filteredSkills],
|
||||
);
|
||||
const staleCount = staleVisible.length;
|
||||
const archivedCount = archivedVisible.length;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(orderedSkills.length / PAGE_SIZE));
|
||||
const filteredSkills = filter === "mine" ? activeMine : activeAll;
|
||||
const orderedSkills = useMemo(
|
||||
() =>
|
||||
filteredSkills
|
||||
.map((skill, index) => ({ skill, index }))
|
||||
.sort((a, b) => {
|
||||
// 置顶(featured)决定排序;钉选(pinned)只管防归档,不影响顺序。
|
||||
const featuredDelta =
|
||||
Number(!!b.skill.featured) - Number(!!a.skill.featured);
|
||||
return featuredDelta || a.index - b.index;
|
||||
})
|
||||
.map(({ skill }) => skill),
|
||||
[filteredSkills],
|
||||
);
|
||||
const staleCount = staleVisible.length;
|
||||
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
|
||||
// back into range without forcing a reset on every keystroke.
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, tagIds, sort]);
|
||||
const pagedSkills = useMemo(
|
||||
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[orderedSkills, page],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, tagIds, sort]);
|
||||
const pagedSkills = useMemo(
|
||||
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[orderedSkills, page],
|
||||
);
|
||||
|
||||
const handleRescan = async () => {
|
||||
const handleRescan = async () => {
|
||||
try {
|
||||
const result = await reconcileMutation.mutateAsync();
|
||||
if (result.adopted.length > 0) {
|
||||
@@ -323,54 +327,54 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
<Upload className="size-4" />
|
||||
{t.settings.skills.installSkill}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<TagSearchBar
|
||||
targetType="skill"
|
||||
onSearchChange={setSearch}
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
actions={
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
|
||||
<SelectTrigger
|
||||
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
|
||||
title="排序"
|
||||
>
|
||||
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectItem value="time_desc">最新创建</SelectItem>
|
||||
<SelectItem value="time_asc">最早创建</SelectItem>
|
||||
<SelectItem value="activity_desc">最近使用</SelectItem>
|
||||
<SelectItem value="activity_asc">最早使用</SelectItem>
|
||||
<SelectItem value="usage_desc">使用最多</SelectItem>
|
||||
<SelectItem value="usage_asc">使用最少</SelectItem>
|
||||
<SelectItem value="name_asc">名称 A-Z</SelectItem>
|
||||
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<TagSearchBar
|
||||
targetType="skill"
|
||||
onSearchChange={setSearch}
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
actions={
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
|
||||
<SelectTrigger
|
||||
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
|
||||
title="排序"
|
||||
>
|
||||
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectItem value="time_desc">最新创建</SelectItem>
|
||||
<SelectItem value="time_asc">最早创建</SelectItem>
|
||||
<SelectItem value="activity_desc">最近使用</SelectItem>
|
||||
<SelectItem value="activity_asc">最早使用</SelectItem>
|
||||
<SelectItem value="usage_desc">使用最多</SelectItem>
|
||||
<SelectItem value="usage_asc">使用最少</SelectItem>
|
||||
<SelectItem value="name_asc">名称 A-Z</SelectItem>
|
||||
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
)}
|
||||
{error && <div className="text-destructive text-sm">Error: {error.message}</div>}
|
||||
{!isLoading && !error && filteredSkills.length === 0 && (
|
||||
<EmptySkill />
|
||||
)}
|
||||
{!isLoading && !error && filteredSkills.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{pagedSkills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && filteredSkills.length === 0 && (
|
||||
<EmptySkill />
|
||||
)}
|
||||
{!isLoading && !error && filteredSkills.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{pagedSkills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && totalPages > 1 && (
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
@@ -419,18 +423,19 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillRow({
|
||||
skill,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
}: {
|
||||
skill: Skill;
|
||||
currentUserId: string | null;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
export function SkillRow({
|
||||
skill,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
}: {
|
||||
skill: Skill;
|
||||
currentUserId: string | null;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const setPublished = useSetSkillPublished();
|
||||
const setPinned = useSetSkillPinned();
|
||||
const setFeatured = useSetSkillFeatured();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const duplicateSkillMutation = useDuplicateSkill();
|
||||
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 (
|
||||
<>
|
||||
<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
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
@@ -577,6 +596,15 @@ export function SkillRow({
|
||||
{t.settings.skills.badgeSourceUpload}
|
||||
</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 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -618,24 +646,47 @@ export function SkillRow({
|
||||
</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={
|
||||
<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 && (
|
||||
<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
|
||||
? t.settings.skills.unpinAction
|
||||
: t.settings.skills.pinAction
|
||||
}
|
||||
>
|
||||
<PinIcon className={cn("size-3.5", skill.pinned && "fill-current")} />
|
||||
</Button>
|
||||
)}
|
||||
: t.settings.skills.pinAction
|
||||
}
|
||||
>
|
||||
<BookmarkIcon
|
||||
className={cn("size-3.5", skill.pinned && "fill-current")}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{canTogglePublish && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -678,8 +729,8 @@ export function SkillRow({
|
||||
</Button>
|
||||
)}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
|
||||
</Item>
|
||||
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>复制技能</DialogTitle>
|
||||
@@ -762,10 +813,10 @@ export function SkillRow({
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySkill() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Empty>
|
||||
function EmptySkill() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SparklesIcon />
|
||||
@@ -775,9 +826,9 @@ function EmptySkill() {
|
||||
{t.settings.skills.emptyDescription}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallSkillDialog({
|
||||
open,
|
||||
|
||||
@@ -476,6 +476,8 @@ export const enUS: Translations = {
|
||||
badgeArchivedTitle: "Archived — recoverable from the archive drawer",
|
||||
badgePinned: "Pinned",
|
||||
badgePinnedTitle: "Pinned — the curator will never auto-archive this skill",
|
||||
badgeFeatured: "Top",
|
||||
badgeFeaturedTitle: "Pinned to the top of the list",
|
||||
badgeSourceAgent: "Agent-created",
|
||||
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
|
||||
badgeSourceUpload: "Uploaded",
|
||||
@@ -494,6 +496,10 @@ export const enUS: Translations = {
|
||||
unpinAction: "Unpin",
|
||||
pinSuccess: "Pinned",
|
||||
unpinSuccess: "Unpinned",
|
||||
featureAction: "Pin to top",
|
||||
unfeatureAction: "Unpin from top",
|
||||
featureSuccess: "Pinned to top",
|
||||
unfeatureSuccess: "Unpinned from top",
|
||||
deleteAction: "Delete skill",
|
||||
deleteConfirm:
|
||||
"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;
|
||||
badgePinned: string;
|
||||
badgePinnedTitle: string;
|
||||
badgeFeatured: string;
|
||||
badgeFeaturedTitle: string;
|
||||
badgeSourceAgent: string;
|
||||
badgeSourceAgentTitle: string;
|
||||
badgeSourceUpload: string;
|
||||
@@ -427,6 +429,10 @@ export interface Translations {
|
||||
unpinAction: string;
|
||||
pinSuccess: string;
|
||||
unpinSuccess: string;
|
||||
featureAction: string;
|
||||
unfeatureAction: string;
|
||||
featureSuccess: string;
|
||||
unfeatureSuccess: string;
|
||||
deleteAction: string;
|
||||
deleteConfirm: string;
|
||||
deleteSuccess: string;
|
||||
|
||||
@@ -456,6 +456,8 @@ export const zhCN: Translations = {
|
||||
badgeArchivedTitle: "已被归档,可在归档抽屉中恢复",
|
||||
badgePinned: "钉选",
|
||||
badgePinnedTitle: "已钉选,策展程序不会自动归档此技能",
|
||||
badgeFeatured: "置顶",
|
||||
badgeFeaturedTitle: "已置顶,固定显示在列表顶部",
|
||||
badgeSourceAgent: "Agent 创建",
|
||||
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
|
||||
badgeSourceUpload: "用户上传",
|
||||
@@ -474,6 +476,10 @@ export const zhCN: Translations = {
|
||||
unpinAction: "取消钉选",
|
||||
pinSuccess: "已钉选",
|
||||
unpinSuccess: "已取消钉选",
|
||||
featureAction: "置顶",
|
||||
unfeatureAction: "取消置顶",
|
||||
featureSuccess: "已置顶",
|
||||
unfeatureSuccess: "已取消置顶",
|
||||
deleteAction: "删除技能",
|
||||
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
|
||||
deleteSuccess: "技能已删除",
|
||||
|
||||
@@ -3,17 +3,17 @@ import { apiFetch } from "@/core/api";
|
||||
|
||||
import type { Skill, SkillDisplayConfig, SkillState } from "./type";
|
||||
|
||||
export interface ListSkillsParams {
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
|
||||
}
|
||||
export interface ListSkillsParams {
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
|
||||
}
|
||||
|
||||
export async function loadSkills(params: ListSkillsParams = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
if (params.sort) qs.set("sort", params.sort);
|
||||
if (params.search) qs.set("search", params.search);
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
if (params.sort) qs.set("sort", params.sort);
|
||||
const query = qs.toString();
|
||||
const skills = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
|
||||
@@ -298,6 +298,24 @@ export async function setSkillPinned(
|
||||
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 {
|
||||
name: string;
|
||||
category: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
@@ -19,11 +19,12 @@ import {
|
||||
setSkillDetail,
|
||||
setSkillDisplay,
|
||||
setSkillPinned,
|
||||
setSkillFeatured,
|
||||
setSkillPublished,
|
||||
setSkillState,
|
||||
validateSkillUpload,
|
||||
} from "./api";
|
||||
import type { ListSkillsParams } from "./api";
|
||||
} from "./api";
|
||||
import type { ListSkillsParams } from "./api";
|
||||
import type { Skill, SkillState } from "./type";
|
||||
|
||||
import { loadAllSkillsAdmin, loadSkills } from ".";
|
||||
@@ -34,13 +35,13 @@ import { loadAllSkillsAdmin, loadSkills } from ".";
|
||||
// forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage).
|
||||
const EMPTY_SKILLS: Skill[] = [];
|
||||
|
||||
export function useSkills(params: ListSkillsParams = {}) {
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const sort = params.sort ?? "time_desc";
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills", search, [...tagIds].sort(), sort],
|
||||
queryFn: () => loadSkills({ search, tagIds, sort }),
|
||||
export function useSkills(params: ListSkillsParams = {}) {
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const sort = params.sort ?? "time_desc";
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills", search, [...tagIds].sort(), sort],
|
||||
queryFn: () => loadSkills({ search, tagIds, sort }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
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() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface Skill {
|
||||
// so stale / archived skills appear in dedicated drawers instead.
|
||||
state?: SkillState;
|
||||
pinned?: boolean;
|
||||
// 置顶 —— 仅影响列表排序,与 pinned(防自动归档)正交。
|
||||
featured?: boolean;
|
||||
// Provenance — distinguishes skills created by an agent during a chat
|
||||
// (``agent``) from skills the user uploaded / authored manually (``upload``).
|
||||
source?: SkillSource;
|
||||
|
||||
@@ -991,12 +991,12 @@ export default function ScheduledTasksPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
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>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
<span className="block truncate">
|
||||
<span className="block whitespace-normal break-words">
|
||||
{desensitize(agent.name)}
|
||||
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
|
||||
</span>
|
||||
|
||||
@@ -122,6 +122,7 @@ class SkillResponse(BaseModel):
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
@@ -294,6 +295,7 @@ def _skill_to_response(
|
||||
created_at=created_at,
|
||||
state=entry.get("state", "active"),
|
||||
pinned=entry.get("pinned", False),
|
||||
featured=entry.get("featured", False),
|
||||
source=entry.get("source", "upload"),
|
||||
owner_user_id=owner_user_id,
|
||||
published=published,
|
||||
@@ -1469,6 +1471,42 @@ async def update_custom_skill_pinned(
|
||||
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")
|
||||
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
||||
try:
|
||||
|
||||
@@ -210,10 +210,7 @@ auth_login:
|
||||
|
||||
# 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口,
|
||||
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
|
||||
log_system:
|
||||
enabled: true
|
||||
url: "https://your-log-system.example.com"
|
||||
label: "日志系统"
|
||||
|
||||
|
||||
database:
|
||||
backend: sqlite
|
||||
@@ -300,3 +297,8 @@ ai_writing:
|
||||
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
|
||||
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
|
||||
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,
|
||||
"state": STATE_ACTIVE,
|
||||
"pinned": False,
|
||||
"featured": False,
|
||||
"archived_at": None,
|
||||
"source": SOURCE_UPLOAD,
|
||||
}
|
||||
@@ -141,6 +142,11 @@ def set_pinned(name: str, pinned: bool) -> None:
|
||||
_update_entry(name, {"pinned": pinned})
|
||||
|
||||
|
||||
def set_featured(name: str, featured: bool) -> None:
|
||||
"""置顶/取消置顶。与 pinned(防归档)正交,仅影响列表排序展示。"""
|
||||
_update_entry(name, {"featured": featured})
|
||||
|
||||
|
||||
def forget(name: str) -> None:
|
||||
try:
|
||||
with _usage_lock:
|
||||
|
||||
Reference in new issue
Block a user