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";
|
"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: "技能已删除",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user