新增技能目录重新扫描接口与分页,关掉技能安全扫描的配置开关

- 后端新增 POST /api/skills/reconcile-custom:扫描 custom 目录,把磁盘存在但 DB 无记录的技能登记到调用者名下(published=false),附 4 个单元测试
- 前端技能列表加"重新扫描技能目录"按钮,列表分页每页 10 条
- SkillsConfig 新增 security_scan.enabled(默认 true)用于关闭 LLM 安全扫描;scan_skill_content 在开关关闭时直接放行
- 智能体卡片/对话页长名 truncate 显示 + loading 占位

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 committed 2026-05-22 16:22:42 +08:00
1 parent b6a2c9336e
commit 80fc2a9240
14 files changed
+366 -11

No files matched your search

@@ -190,12 +190,12 @@ export function AgentCard({ agent }: AgentCardProps) {
<Card className="group flex flex-col transition-shadow hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0">
<CardTitle className="truncate text-base">
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-base" title={agent.name}>
{agent.name}
</CardTitle>
{agent.model && (
@@ -4,6 +4,8 @@ import {
AlertCircle,
ArchiveIcon,
CheckCircle2,
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
FileArchive,
GlobeIcon,
@@ -11,6 +13,7 @@ import {
LockIcon,
PinIcon,
PinOffIcon,
RefreshCwIcon,
SparklesIcon,
TagsIcon,
Trash2Icon,
@@ -18,7 +21,7 @@ import {
Upload,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -57,6 +60,7 @@ import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useDeleteCustomSkill,
useInstallSkillUpload,
useReconcileCustomSkills,
useSetSkillPinned,
useSetSkillPublished,
useSkills,
@@ -81,6 +85,8 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
);
}
const PAGE_SIZE = 10;
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const { t } = useI18n();
const router = useRouter();
@@ -90,6 +96,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
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 });
@@ -140,10 +148,39 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const staleCount = staleVisible.length;
const archivedCount = archivedVisible.length;
const totalPages = Math.max(1, Math.ceil(filteredSkills.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]);
const pagedSkills = useMemo(
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[filteredSkills, page],
);
const handleCreateSkill = () => {
onClose?.();
router.push("/?mode=skill");
};
const handleRescan = async () => {
try {
const result = await reconcileMutation.mutateAsync();
if (result.adopted.length > 0) {
toast.success(
t.settings.skills.rescanSkillsAdopted.replace(
"{count}",
String(result.adopted.length),
),
);
} else {
toast.info(t.settings.skills.rescanSkillsNoop);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<div className="flex w-full flex-col gap-4">
<header className="flex flex-wrap items-center justify-between gap-3">
@@ -190,6 +227,25 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<TrendingUpIcon className="size-4" />
{t.settings.skills.goToEvolution}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => void handleRescan()}
disabled={
reconcileMutation.isPending ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
title={t.settings.skills.rescanSkills}
>
{reconcileMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
{reconcileMutation.isPending
? t.settings.skills.rescanSkillsRunning
: t.settings.skills.rescanSkills}
</Button>
<Button
size="sm"
variant="outline"
@@ -220,7 +276,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
)}
{!isLoading && !error &&
filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
pagedSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
@@ -228,6 +284,33 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
isAdmin={isAdmin}
/>
))}
{!isLoading && !error && totalPages > 1 && (
<div className="flex items-center justify-end gap-2 pt-1">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeftIcon className="size-4" />
{t.settings.skills.paginationPrev}
</Button>
<span className="text-muted-foreground text-xs">
{t.settings.skills.paginationPageOf
.replace("{page}", String(page))
.replace("{total}", String(totalPages))}
</span>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
{t.settings.skills.paginationNext}
<ChevronRightIcon className="size-4" />
</Button>
</div>
)}
<InstallSkillDialog
open={installOpen}
onClose={() => setInstallOpen(false)}