1648 lines
58 KiB
TypeScript
1648 lines
58 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
AlertCircle,
|
||
Anchor,
|
||
ArchiveIcon,
|
||
ArrowDownUpIcon,
|
||
BookmarkIcon,
|
||
CheckCircle2,
|
||
ChevronLeftIcon,
|
||
ChevronRightIcon,
|
||
ClockIcon,
|
||
CopyIcon,
|
||
EyeOffIcon,
|
||
FileArchive,
|
||
GlobeIcon,
|
||
InfoIcon,
|
||
Loader2,
|
||
LockIcon,
|
||
PinIcon,
|
||
PlusIcon,
|
||
RefreshCwIcon,
|
||
SparklesIcon,
|
||
TagsIcon,
|
||
Trash2Icon,
|
||
TrendingUpIcon,
|
||
Upload,
|
||
UserIcon,
|
||
XIcon,
|
||
} 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 {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
Empty,
|
||
EmptyDescription,
|
||
EmptyHeader,
|
||
EmptyMedia,
|
||
EmptyTitle,
|
||
} from "@/components/ui/empty";
|
||
|
||
import {
|
||
Item,
|
||
ItemActions,
|
||
ItemTitle,
|
||
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 { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
|
||
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";
|
||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||
import { useTheme } from "@/contexts/ThemeContext";
|
||
import { getStoredAuth } from "@/core/auth";
|
||
import { useI18n } from "@/core/i18n/hooks";
|
||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||
import {
|
||
useDeleteBuiltinSkill,
|
||
useDeleteCustomSkill,
|
||
useDuplicateSkill,
|
||
useGenerateMissingSkillNameZh,
|
||
useGenerateSkillNameZh,
|
||
useInstallSkillUpload,
|
||
useReconcileCustomSkills,
|
||
useSetSkillAlwaysOn,
|
||
useSetSkillFeatured,
|
||
useSetSkillNameZh,
|
||
useSetSkillPinned,
|
||
useSetSkillPublished,
|
||
useSkills,
|
||
useValidateSkillUpload,
|
||
} from "@/core/skills/hooks";
|
||
import {
|
||
usePublishSquares,
|
||
useSkillCompressionSettings,
|
||
useViewMode,
|
||
} from "@/core/system-settings/hooks";
|
||
import type { Skill } from "@/core/skills/type";
|
||
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
|
||
import { formatDateTime } from "@/core/utils/datetime";
|
||
import { env } from "@/env";
|
||
import { desensitize } from "@/lib/desensitize";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
import { SettingsSection } from "./settings-section";
|
||
import { SkillStateSheet } from "./skill-state-sheet";
|
||
|
||
export function SkillSettingsPage({
|
||
onClose,
|
||
embedded = false,
|
||
}: {
|
||
onClose?: () => void;
|
||
embedded?: boolean;
|
||
} = {}) {
|
||
const { t } = useI18n();
|
||
if (embedded) return <SkillSettingsList onClose={onClose} />;
|
||
return (
|
||
<SettingsSection
|
||
title={t.settings.skills.title}
|
||
description={t.settings.skills.description}
|
||
>
|
||
<SkillSettingsList onClose={onClose} />
|
||
</SettingsSection>
|
||
);
|
||
}
|
||
|
||
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 = {
|
||
classNames: {
|
||
toast:
|
||
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
|
||
title: "!text-[#FFFFFF]",
|
||
description: "!text-[#FFFFFF]",
|
||
},
|
||
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
|
||
} as const;
|
||
|
||
/** 重新扫描无变更 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
|
||
const rescanInfoToastDarkOptions = {
|
||
classNames: {
|
||
toast:
|
||
"!rounded-[2px] !border !border-[#704812] !bg-[#202222] !text-[#FFFFFF] shadow-md",
|
||
title: "!text-[#FFFFFF]",
|
||
description: "!text-[#FFFFFF]",
|
||
},
|
||
icon: <InfoIcon className="size-4 shrink-0 text-[#FF9900]" />,
|
||
} as const;
|
||
|
||
/** 重新扫描失败 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
|
||
const rescanErrorToastDarkOptions = {
|
||
classNames: {
|
||
toast:
|
||
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
|
||
title: "!text-[#FFFFFF]",
|
||
description: "!text-[#FFFFFF]",
|
||
},
|
||
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");
|
||
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);
|
||
// Publisher filter: click a skill's 发布者 to keep only that user's skills.
|
||
// {id} is owner_user_id; {label} the desensitized display name.
|
||
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
||
null,
|
||
);
|
||
const [viewMode, setViewMode] = useViewMode("skill");
|
||
const { data: publishSquares } = usePublishSquares();
|
||
// Each configured publish square (发布广场) becomes its own browse tab; the
|
||
// backend always returns at least one (the built-in default).
|
||
const squareTabs = publishSquares?.squares ?? [];
|
||
const reconcileMutation = useReconcileCustomSkills();
|
||
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
||
// Optional model override for the admin "一键补充中文名" action (null = default).
|
||
const [fillModel, setFillModel] = useState<string | null>(null);
|
||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||
const currentUserId = storedAuth?.user_id ?? null;
|
||
const isAdmin = storedAuth?.system_role === "admin";
|
||
|
||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||
// A 岗位 (position) no longer *restricts* the list — it grants skills into the
|
||
// member's 我的技能 (each such skill comes back with ``favorited: true``), so
|
||
// the 广场 always shows every visible skill and the 我的 tab includes the
|
||
// position-granted ones. ``scope`` is kept for API compatibility only.
|
||
const { skills, isLoading, error } = useSkills({
|
||
search,
|
||
tagIds,
|
||
sort,
|
||
scope: isAdmin ? undefined : "mine",
|
||
});
|
||
|
||
// 把可见技能分类成 UI 关心的几个桶。
|
||
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
|
||
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
|
||
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
|
||
const all: Skill[] = [];
|
||
const mine: Skill[] = [];
|
||
const stale: Skill[] = [];
|
||
const archived: Skill[] = [];
|
||
for (const skill of skills) {
|
||
const state = skill.state ?? "active";
|
||
const isCustom = skill.category === "custom";
|
||
const isOwnedByMe =
|
||
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
|
||
|
||
if (state === "stale") {
|
||
stale.push(skill);
|
||
continue;
|
||
}
|
||
if (state === "archived") {
|
||
archived.push(skill);
|
||
continue;
|
||
}
|
||
// state === active
|
||
if (isBuiltin || !!skill.published) {
|
||
all.push(skill);
|
||
}
|
||
// "我的" = skills I own OR skills granted into my collection by my 岗位
|
||
// (position) — mirroring how favorited agents land in 我的智能体.
|
||
if (isOwnedByMe || !!skill.favorited) {
|
||
mine.push(skill);
|
||
}
|
||
}
|
||
return {
|
||
activeAll: all,
|
||
activeMine: mine,
|
||
staleVisible: stale,
|
||
archivedVisible: archived,
|
||
};
|
||
}, [skills, currentUserId]);
|
||
|
||
// "mine" tab shows my own skills; every other tab value is a square id whose
|
||
// public skills come from the published pool.
|
||
const isSquareTab = filter !== "mine";
|
||
const scopedSkills = filter === "mine" ? activeMine : activeAll;
|
||
const filteredSkills = useMemo(() => {
|
||
let list = publisher
|
||
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
||
: scopedSkills;
|
||
// On a square tab, keep only that square's skills. The default square also
|
||
// sweeps in legacy/unassigned published skills (square_id === "").
|
||
if (isSquareTab) {
|
||
const isDefault = filter === publishSquares?.default_square_id;
|
||
list = list.filter((s) => {
|
||
const sid = s.square_id ?? "";
|
||
return sid === filter || (isDefault && !sid);
|
||
});
|
||
}
|
||
return list;
|
||
}, [scopedSkills, publisher, filter, isSquareTab, publishSquares?.default_square_id]);
|
||
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, publisher?.id, filter]);
|
||
|
||
// If the active square tab disappears (admin removed that square), fall back
|
||
// to "我的" so the page never points at a dead tab.
|
||
useEffect(() => {
|
||
if (
|
||
filter !== "mine" &&
|
||
squareTabs.length > 0 &&
|
||
!squareTabs.some((sq) => sq.id === filter)
|
||
) {
|
||
setFilter("mine");
|
||
}
|
||
}, [filter, squareTabs]);
|
||
const pagedSkills = useMemo(
|
||
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||
[orderedSkills, page],
|
||
);
|
||
|
||
const handleFillNames = async () => {
|
||
try {
|
||
const result = await fillNamesMutation.mutateAsync(fillModel);
|
||
const okCount = result.generated.length;
|
||
const failCount = result.failed.length;
|
||
if (okCount === 0 && failCount === 0) {
|
||
toast.info("所有技能均已配置中文名称");
|
||
} else if (failCount === 0) {
|
||
toast.success(`已为 ${okCount} 个技能补充中文名称`);
|
||
} else if (result.model_error) {
|
||
// Failures came from the model itself — say so explicitly.
|
||
toast.warning(
|
||
`已补充 ${okCount} 个,${failCount} 个因大模型报错失败:${result.model_error}`,
|
||
);
|
||
} else {
|
||
toast.warning(`已补充 ${okCount} 个,${failCount} 个生成失败,可稍后重试或手动填写`);
|
||
}
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : String(err));
|
||
}
|
||
};
|
||
|
||
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),
|
||
),
|
||
theme === "dark" ? rescanSuccessToastDarkOptions : undefined,
|
||
);
|
||
} else {
|
||
toast.info(
|
||
t.settings.skills.rescanSkillsNoop,
|
||
theme === "dark" ? rescanInfoToastDarkOptions : undefined,
|
||
);
|
||
}
|
||
} catch (err) {
|
||
toast.error(
|
||
err instanceof Error ? err.message : String(err),
|
||
theme === "dark" ? rescanErrorToastDarkOptions : undefined,
|
||
);
|
||
}
|
||
|
||
};
|
||
return (
|
||
<div className="flex w-full flex-col gap-4">
|
||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Tabs value={filter} onValueChange={setFilter}>
|
||
<TabsList variant="line" className="flex-wrap">
|
||
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||
{squareTabs.map((sq) => (
|
||
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
||
{sq.name}
|
||
</TabsTrigger>
|
||
))}
|
||
</TabsList>
|
||
</Tabs>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={staleCount === 0}
|
||
onClick={() => setStaleSheetOpen(true)}
|
||
title={t.settings.skills.badgeStaleTitle}
|
||
>
|
||
<ClockIcon className="size-4" />
|
||
{t.settings.skills.entryStale.replace("{count}", String(staleCount))}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={archivedCount === 0}
|
||
onClick={() => setArchiveSheetOpen(true)}
|
||
title={t.settings.skills.badgeArchivedTitle}
|
||
>
|
||
<ArchiveIcon className="size-4" />
|
||
{t.settings.skills.entryArchived.replace(
|
||
"{count}",
|
||
String(archivedCount),
|
||
)}
|
||
</Button>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => {
|
||
onClose?.();
|
||
router.push("/page/workspace/skills/evolution");
|
||
}}
|
||
>
|
||
<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>
|
||
{isAdmin && (
|
||
<>
|
||
<ModelNameSelect
|
||
value={fillModel}
|
||
onChange={setFillModel}
|
||
disabled={
|
||
fillNamesMutation.isPending ||
|
||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||
}
|
||
triggerClassName="h-8 w-[9.5rem]"
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="gap-1.5 border-violet-300 bg-violet-50 font-medium text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
|
||
onClick={() => void handleFillNames()}
|
||
disabled={
|
||
fillNamesMutation.isPending ||
|
||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||
}
|
||
title="用所选大模型为所有尚未配置中文名称的技能自动生成中文名(仅管理员)"
|
||
>
|
||
{fillNamesMutation.isPending ? (
|
||
<Loader2 className="size-4 animate-spin" />
|
||
) : (
|
||
<SparklesIcon className="size-4" />
|
||
)}
|
||
{fillNamesMutation.isPending ? "补充中…" : "一键补充中文名"}
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setInstallOpen(true)}
|
||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||
>
|
||
<Upload className="size-4" />
|
||
{t.settings.skills.installSkill}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => {
|
||
onClose?.();
|
||
router.push("/page/workspace/chats/new?mode=skill");
|
||
}}
|
||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||
title="在对话中描述需求,由 AI 帮你创建一个新技能"
|
||
>
|
||
<PlusIcon className="size-4" />
|
||
{t.settings.skills.createSkill}
|
||
</Button>
|
||
</div>
|
||
</header>
|
||
<TagSearchBar
|
||
targetType="skill"
|
||
onSearchChange={setSearch}
|
||
tagIds={tagIds}
|
||
onTagIdsChange={setTagIds}
|
||
actions={
|
||
<div className="flex items-center gap-2">
|
||
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||
<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>
|
||
}
|
||
/>
|
||
{!isLoading && !error && (
|
||
<div className="text-muted-foreground flex justify-end text-sm tabular-nums">
|
||
共 {orderedSkills.length} 个技能
|
||
</div>
|
||
)}
|
||
{publisher && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-muted-foreground text-xs">筛选发布者:</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPublisher(null)}
|
||
className="bg-primary/10 text-primary hover:bg-primary/20 inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors"
|
||
title="点击清除发布者筛选"
|
||
>
|
||
{publisher.label}
|
||
<XIcon className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
{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={cn(
|
||
viewMode === "card"
|
||
? "grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3"
|
||
: "flex flex-col gap-3",
|
||
)}
|
||
>
|
||
{pagedSkills.map((skill) => (
|
||
<SkillRow
|
||
key={skill.name}
|
||
skill={skill}
|
||
currentUserId={currentUserId}
|
||
isAdmin={isAdmin}
|
||
layout={viewMode === "card" ? "card" : "row"}
|
||
onPublisherClick={setPublisher}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
{!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)}
|
||
isAdmin={isAdmin}
|
||
/>
|
||
<SkillStateSheet
|
||
kind="stale"
|
||
open={staleSheetOpen}
|
||
onOpenChange={setStaleSheetOpen}
|
||
skills={staleVisible}
|
||
/>
|
||
<SkillStateSheet
|
||
kind="archived"
|
||
open={archiveSheetOpen}
|
||
onOpenChange={setArchiveSheetOpen}
|
||
skills={archivedVisible}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function SkillRow({
|
||
skill,
|
||
currentUserId,
|
||
isAdmin,
|
||
layout = "row",
|
||
onPublisherClick,
|
||
knowledgeBindings = [],
|
||
}: {
|
||
skill: Skill;
|
||
currentUserId: string | null;
|
||
isAdmin: boolean;
|
||
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
|
||
layout?: "row" | "card";
|
||
/** Click the 发布者 line to filter the list to that publisher. Receives the
|
||
* owner_user_id + its desensitized display label. */
|
||
onPublisherClick?: (publisher: { id: string; label: string }) => void;
|
||
/** Admin list can inject its single batched binding query here (never N+1). */
|
||
knowledgeBindings?: SkillKnowledgeBinding[];
|
||
}) {
|
||
const isCard = layout === "card";
|
||
const { t } = useI18n();
|
||
const setPublished = useSetSkillPublished();
|
||
const { data: cardPublishSquares } = usePublishSquares();
|
||
const setPinned = useSetSkillPinned();
|
||
const setFeatured = useSetSkillFeatured();
|
||
const setAlwaysOn = useSetSkillAlwaysOn();
|
||
const { data: compression } = useSkillCompressionSettings();
|
||
const deleteCustom = useDeleteCustomSkill();
|
||
const deleteBuiltin = useDeleteBuiltinSkill();
|
||
const duplicateSkillMutation = useDuplicateSkill();
|
||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||
const [detailOpen, setDetailOpen] = useState(false);
|
||
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
||
const [duplicateName, setDuplicateName] = useState("");
|
||
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
||
|
||
const isCustom = skill.category === "custom";
|
||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||
// the multitenancy fields still classify public skills correctly.
|
||
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
|
||
const isOwned =
|
||
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||
const isFromOthers = isCustom && !isBuiltin && !isOwned;
|
||
// Admin can manage custom skills from any owner; non-admin only their own.
|
||
const canManage = isCustom && (isOwned || isAdmin);
|
||
const canDelete = isAdmin || isOwned;
|
||
const canTogglePublish = canManage && !isBuiltin;
|
||
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
|
||
const canSetAlwaysOn = isAdmin || isOwned;
|
||
|
||
async function handleToggleAlwaysOn(next: boolean) {
|
||
try {
|
||
await setAlwaysOn.mutateAsync({ skillName: skill.name, alwaysOn: next });
|
||
toast.success(next ? "已设为常驻(免压缩)" : "已取消常驻");
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : String(err));
|
||
}
|
||
}
|
||
|
||
async function handleTogglePublished(next: boolean) {
|
||
// Publishing: prompt for which square to publish into (when configured).
|
||
// Unpublishing: just flip it off.
|
||
if (next && (cardPublishSquares?.squares.length ?? 0) > 0) {
|
||
setPublishPickerOpen(true);
|
||
return;
|
||
}
|
||
try {
|
||
await setPublished.mutateAsync({ skillName: skill.name, published: next });
|
||
toast.success(
|
||
next
|
||
? t.settings.skills.publishSuccess
|
||
: t.settings.skills.unpublishSuccess,
|
||
);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : String(err));
|
||
}
|
||
}
|
||
|
||
// Publish the skill into a chosen square. Each skill belongs to exactly one
|
||
// square; re-publishing with a new square_id relocates rather than duplicates.
|
||
async function handlePublishToSquare(squareId: string) {
|
||
try {
|
||
await setPublished.mutateAsync({
|
||
skillName: skill.name,
|
||
published: true,
|
||
squareId,
|
||
});
|
||
const sqName =
|
||
cardPublishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
|
||
toast.success(`已发布到「${sqName}」`);
|
||
setPublishPickerOpen(false);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : String(err));
|
||
}
|
||
}
|
||
|
||
async function handleDelete() {
|
||
try {
|
||
if (isBuiltin) {
|
||
await deleteBuiltin.mutateAsync(skill.name);
|
||
} else {
|
||
await deleteCustom.mutateAsync(skill.name);
|
||
}
|
||
toast.success(t.settings.skills.deleteSuccess);
|
||
setConfirmDeleteOpen(false);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : String(err));
|
||
}
|
||
}
|
||
|
||
function handleOpenDuplicate() {
|
||
// Suggest a "<name>-copy" default that already satisfies the backend's
|
||
// lowercase/digit/-/_ skill-name rule. The user can edit it before saving.
|
||
setDuplicateName(`${skill.name}-copy`);
|
||
setDuplicateOpen(true);
|
||
}
|
||
|
||
async function handleDuplicate() {
|
||
const target = duplicateName.trim();
|
||
if (!target) {
|
||
toast.error("请输入新技能名称");
|
||
return;
|
||
}
|
||
try {
|
||
const result = await duplicateSkillMutation.mutateAsync({
|
||
sourceName: skill.name,
|
||
newName: target,
|
||
});
|
||
toast.success(`已复制为「${result.skill_name}」`);
|
||
setDuplicateOpen(false);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : String(err));
|
||
}
|
||
}
|
||
|
||
async function handleTogglePinned() {
|
||
const next = !skill.pinned;
|
||
try {
|
||
await setPinned.mutateAsync({ skillName: skill.name, pinned: next });
|
||
toast.success(
|
||
next ? t.settings.skills.pinSuccess : t.settings.skills.unpinSuccess,
|
||
);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : String(err));
|
||
}
|
||
}
|
||
|
||
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));
|
||
}
|
||
}
|
||
|
||
const badgeGroup = (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className={cn(isCard && "truncate font-medium")}>
|
||
{skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)}
|
||
</span>
|
||
{skill.name_zh?.trim() && (
|
||
<span className="text-muted-foreground truncate text-xs font-normal">
|
||
{desensitize(skill.name)}
|
||
</span>
|
||
)}
|
||
{isBuiltin && (
|
||
<Badge variant="secondary" className="text-xs">
|
||
{t.settings.skills.badgeBuiltin}
|
||
</Badge>
|
||
)}
|
||
{isOwned && (
|
||
<Badge
|
||
variant="default"
|
||
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||
>
|
||
{t.settings.skills.badgeMine}
|
||
</Badge>
|
||
)}
|
||
{isFromOthers && (
|
||
<Badge
|
||
variant="outline"
|
||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||
title={
|
||
skill.owner_user_id
|
||
? t.settings.skills.badgeFromOthersTitle.replace(
|
||
"{user_id}",
|
||
skill.owner_user_id,
|
||
)
|
||
: undefined
|
||
}
|
||
>
|
||
{t.settings.skills.badgeFromOthers}
|
||
</Badge>
|
||
)}
|
||
{(isOwned || isAdmin) && !isBuiltin && skill.published && (
|
||
<Badge
|
||
variant="outline"
|
||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||
title={t.settings.skills.badgePublishedTitle}
|
||
>
|
||
<GlobeIcon className="size-3" />
|
||
{t.settings.skills.badgePublished}
|
||
</Badge>
|
||
)}
|
||
{(isOwned || isAdmin) && !isBuiltin && !skill.published && (
|
||
<Badge
|
||
variant="outline"
|
||
className="border-zinc-400/40 text-zinc-500 dark:text-zinc-400 text-xs"
|
||
title={t.settings.skills.badgeUnpublishedTitle}
|
||
>
|
||
<EyeOffIcon className="size-3" />
|
||
{t.settings.skills.badgeUnpublished}
|
||
</Badge>
|
||
)}
|
||
{isCustom && !isBuiltin && skill.source === "agent" && (
|
||
<Badge
|
||
variant="outline"
|
||
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
|
||
title={t.settings.skills.badgeSourceAgentTitle}
|
||
>
|
||
{t.settings.skills.badgeSourceAgent}
|
||
</Badge>
|
||
)}
|
||
{isCustom && !isBuiltin && skill.source === "upload" && (
|
||
<Badge
|
||
variant="outline"
|
||
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
|
||
title={t.settings.skills.badgeSourceUploadTitle}
|
||
>
|
||
{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"
|
||
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||
title={t.settings.skills.badgePinnedTitle}
|
||
>
|
||
{t.settings.skills.badgePinned}
|
||
</Badge>
|
||
)}
|
||
{skill.always_on && (
|
||
<Badge
|
||
variant="outline"
|
||
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
|
||
title="常驻(免压缩):开启技能压缩后,本技能仍全文注入系统提示词并被正常检索"
|
||
>
|
||
常驻
|
||
</Badge>
|
||
)}
|
||
{knowledgeBindings.slice(0, 3).map((binding) => (
|
||
<Badge
|
||
key={binding.id}
|
||
variant="outline"
|
||
className="border-indigo-500/30 text-indigo-700 dark:text-indigo-300 text-xs"
|
||
title={`${binding.target_name} · ${binding.status}${binding.remote_business_key ? ` · ${binding.remote_business_key}` : ""}`}
|
||
>
|
||
知识库:{binding.target_name || binding.target_id}
|
||
{binding.status === "stale" ? "(待更新)" : ""}
|
||
</Badge>
|
||
))}
|
||
{knowledgeBindings.length > 3 && (
|
||
<Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const metaNodes = (
|
||
<>
|
||
{!isBuiltin && skill.owner_username && (
|
||
<button
|
||
type="button"
|
||
disabled={!skill.owner_user_id || !onPublisherClick}
|
||
onClick={(e) => {
|
||
if (!skill.owner_user_id || !onPublisherClick) return;
|
||
e.stopPropagation();
|
||
onPublisherClick({
|
||
id: skill.owner_user_id,
|
||
label: desensitize(skill.owner_username!),
|
||
});
|
||
}}
|
||
className={cn(
|
||
"text-muted-foreground mt-1 flex max-w-full items-center gap-1 text-sm font-medium",
|
||
skill.owner_user_id && onPublisherClick &&
|
||
"hover:text-primary cursor-pointer transition-colors hover:underline",
|
||
)}
|
||
title={
|
||
skill.owner_user_id && onPublisherClick
|
||
? `点击筛选发布者:${desensitize(skill.owner_username)}`
|
||
: `发布者:${desensitize(skill.owner_username)}`
|
||
}
|
||
>
|
||
<UserIcon className="size-3.5 shrink-0" />
|
||
<span className="truncate">发布者:{desensitize(skill.owner_username)}</span>
|
||
</button>
|
||
)}
|
||
{skill.use_count !== undefined && (
|
||
<div className="text-muted-foreground mt-1 text-xs">
|
||
{skill.use_count > 0
|
||
? t.settings.skills.useCountInline.replace(
|
||
"{count}",
|
||
String(skill.use_count),
|
||
)
|
||
: t.settings.skills.useCountInlineZero}
|
||
</div>
|
||
)}
|
||
{formatDateTime(skill.created_at) && (
|
||
<div className="text-muted-foreground mt-1 flex items-center gap-1 text-xs">
|
||
<ClockIcon className="size-3.5 shrink-0" />
|
||
<span className="truncate">
|
||
创建时间:{formatDateTime(skill.created_at)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{(skill.tags?.length ?? 0) > 0 && (
|
||
<TagChipList tags={skill.tags!} className="mt-2" />
|
||
)}
|
||
</>
|
||
);
|
||
|
||
const actionButtons = (
|
||
<>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-8 w-8 shrink-0"
|
||
onClick={() => setTagPickerOpen(true)}
|
||
title={t.adminTags.manageTags}
|
||
>
|
||
<TagsIcon className="size-3.5" />
|
||
</Button>
|
||
{canManage && (
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className={cn(
|
||
"h-8 w-8 shrink-0",
|
||
skill.featured && "text-amber-600 hover:text-amber-700",
|
||
)}
|
||
disabled={setFeatured.isPending}
|
||
onClick={() => void handleToggleFeatured()}
|
||
title={
|
||
skill.featured
|
||
? t.settings.skills.unfeatureAction
|
||
: t.settings.skills.featureAction
|
||
}
|
||
>
|
||
<PinIcon
|
||
className={cn("size-3.5", skill.featured && "fill-current")}
|
||
/>
|
||
</Button>
|
||
)}
|
||
{canManage && (
|
||
<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
|
||
}
|
||
>
|
||
<BookmarkIcon
|
||
className={cn("size-3.5", skill.pinned && "fill-current")}
|
||
/>
|
||
</Button>
|
||
)}
|
||
{compression?.enabled && canSetAlwaysOn && (
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className={cn(
|
||
"h-8 w-8 shrink-0",
|
||
skill.always_on && "text-amber-600 hover:text-amber-700",
|
||
)}
|
||
disabled={setAlwaysOn.isPending}
|
||
onClick={() => void handleToggleAlwaysOn(!skill.always_on)}
|
||
title={
|
||
skill.always_on
|
||
? "取消常驻(将被压缩,改由 search_skills 检索)"
|
||
: "设为常驻(免压缩,始终全文注入提示词)"
|
||
}
|
||
>
|
||
<Anchor
|
||
className={cn("size-3.5", skill.always_on && "fill-current")}
|
||
/>
|
||
</Button>
|
||
)}
|
||
{canTogglePublish && (
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-8 w-8 shrink-0"
|
||
disabled={setPublished.isPending}
|
||
onClick={() => void handleTogglePublished(!skill.published)}
|
||
title={
|
||
skill.published
|
||
? t.settings.skills.unpublishAction
|
||
: t.settings.skills.publishAction
|
||
}
|
||
>
|
||
{skill.published ? (
|
||
<LockIcon className="size-3.5" />
|
||
) : (
|
||
<GlobeIcon className="size-3.5" />
|
||
)}
|
||
</Button>
|
||
)}
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-8 w-8 shrink-0"
|
||
disabled={duplicateSkillMutation.isPending}
|
||
onClick={handleOpenDuplicate}
|
||
title="复制为新技能"
|
||
>
|
||
<CopyIcon className="size-3.5" />
|
||
</Button>
|
||
{canDelete && (
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||
onClick={() => setConfirmDeleteOpen(true)}
|
||
title={t.settings.skills.deleteAction}
|
||
>
|
||
<Trash2Icon className="size-3.5" />
|
||
</Button>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{isCard ? (
|
||
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||
<div
|
||
className="min-w-0 flex-1 cursor-pointer"
|
||
onClick={() => setDetailOpen(true)}
|
||
>
|
||
{badgeGroup}
|
||
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
|
||
{desensitize(skill.description ?? "")}
|
||
</p>
|
||
{metaNodes}
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap items-center justify-end gap-1 border-t pt-2 dark:border-[#213D5E]">
|
||
{actionButtons}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
||
<ItemContent
|
||
className="cursor-pointer"
|
||
onClick={() => setDetailOpen(true)}
|
||
>
|
||
<ItemTitle>{badgeGroup}</ItemTitle>
|
||
<ItemDescription className="line-clamp-4">
|
||
{desensitize(skill.description ?? "")}
|
||
</ItemDescription>
|
||
{metaNodes}
|
||
</ItemContent>
|
||
<ItemActions>{actionButtons}</ItemActions>
|
||
</Item>
|
||
)}
|
||
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>复制技能</DialogTitle>
|
||
<DialogDescription>
|
||
将「{desensitize(skill.name)}」复制为一个属于你的新技能。请输入新名称(仅小写字母、数字、- 或 _)。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<Input
|
||
value={duplicateName}
|
||
onChange={(e) => setDuplicateName(e.currentTarget.value)}
|
||
placeholder="new-skill-name"
|
||
autoFocus
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
void handleDuplicate();
|
||
}
|
||
}}
|
||
/>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setDuplicateOpen(false)}
|
||
disabled={duplicateSkillMutation.isPending}
|
||
>
|
||
{t.common.cancel}
|
||
</Button>
|
||
<Button
|
||
onClick={() => void handleDuplicate()}
|
||
disabled={duplicateSkillMutation.isPending}
|
||
>
|
||
{duplicateSkillMutation.isPending ? t.common.loading : "复制"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>{t.settings.skills.deleteAction}</DialogTitle>
|
||
<DialogDescription>
|
||
{desensitize(
|
||
t.settings.skills.deleteConfirm.replace("{name}", skill.name),
|
||
)}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setConfirmDeleteOpen(false)}
|
||
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
|
||
>
|
||
{t.common.cancel}
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
onClick={() => void handleDelete()}
|
||
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
|
||
>
|
||
{deleteCustom.isPending || deleteBuiltin.isPending
|
||
? t.common.loading
|
||
: t.common.delete}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
<TagPickerDialog
|
||
open={tagPickerOpen}
|
||
onOpenChange={setTagPickerOpen}
|
||
targetType="skill"
|
||
targetId={skill.name}
|
||
currentTags={skill.tags ?? []}
|
||
/>
|
||
<SkillDetailDialog
|
||
skillName={skill.name}
|
||
open={detailOpen}
|
||
onOpenChange={setDetailOpen}
|
||
/>
|
||
|
||
<PublishSquarePickerDialog
|
||
open={publishPickerOpen}
|
||
onOpenChange={setPublishPickerOpen}
|
||
squares={cardPublishSquares?.squares ?? []}
|
||
defaultSquareId={skill.square_id || cardPublishSquares?.default_square_id}
|
||
loading={setPublished.isPending}
|
||
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
|
||
description="请选择要把该技能发布到哪个广场。每个技能只能归属一个广场,不会重复出现。"
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function EmptySkill() {
|
||
const { t } = useI18n();
|
||
return (
|
||
<Empty>
|
||
<EmptyHeader>
|
||
<EmptyMedia variant="icon">
|
||
<SparklesIcon />
|
||
</EmptyMedia>
|
||
<EmptyTitle>{t.settings.skills.emptyTitle}</EmptyTitle>
|
||
<EmptyDescription>
|
||
{t.settings.skills.emptyDescription}
|
||
</EmptyDescription>
|
||
</EmptyHeader>
|
||
</Empty>
|
||
);
|
||
}
|
||
|
||
function InstallSkillDialog({
|
||
open,
|
||
onClose,
|
||
isAdmin,
|
||
}: {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
isAdmin: boolean;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const dialog = t.settings.skills.installDialog;
|
||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
|
||
const [validationError, setValidationError] = useState<string | null>(null);
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom");
|
||
// After a successful install the dialog switches to a "set Chinese name" step.
|
||
// `installedName` holds the just-installed skill name (null = still on the upload step).
|
||
const [installedName, setInstalledName] = useState<string | null>(null);
|
||
const [nameZhDraft, setNameZhDraft] = useState("");
|
||
// Optional model override for the "AI generate" button (null = configured default).
|
||
const [nameZhModel, setNameZhModel] = useState<string | null>(null);
|
||
const validateMutation = useValidateSkillUpload();
|
||
const installMutation = useInstallSkillUpload();
|
||
const setNameZhMutation = useSetSkillNameZh();
|
||
const generateNameZhMutation = useGenerateSkillNameZh();
|
||
|
||
const validateFile = useCallback(
|
||
async (selected: File, category: "custom" | "public") => {
|
||
setValidation(null);
|
||
setValidationError(null);
|
||
try {
|
||
const result = await validateMutation.mutateAsync({
|
||
file: selected,
|
||
targetCategory: category,
|
||
});
|
||
setValidation(result);
|
||
} catch (err) {
|
||
setValidationError(err instanceof Error ? err.message : String(err));
|
||
}
|
||
},
|
||
[validateMutation],
|
||
);
|
||
|
||
const reset = useCallback(() => {
|
||
setFile(null);
|
||
setValidation(null);
|
||
setValidationError(null);
|
||
setIsDragging(false);
|
||
setTargetCategory("custom");
|
||
setInstalledName(null);
|
||
setNameZhDraft("");
|
||
setNameZhModel(null);
|
||
if (inputRef.current) inputRef.current.value = "";
|
||
}, []);
|
||
|
||
const handleClose = useCallback(() => {
|
||
if (
|
||
validateMutation.isPending ||
|
||
installMutation.isPending ||
|
||
setNameZhMutation.isPending ||
|
||
generateNameZhMutation.isPending
|
||
)
|
||
return;
|
||
reset();
|
||
onClose();
|
||
}, [
|
||
generateNameZhMutation.isPending,
|
||
installMutation.isPending,
|
||
onClose,
|
||
reset,
|
||
setNameZhMutation.isPending,
|
||
validateMutation.isPending,
|
||
]);
|
||
|
||
const acceptFile = useCallback(
|
||
async (selected: File) => {
|
||
const lower = selected.name.toLowerCase();
|
||
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
|
||
toast.error(dialog.rejectInvalidExt);
|
||
return;
|
||
}
|
||
setFile(selected);
|
||
await validateFile(selected, targetCategory);
|
||
},
|
||
[dialog.rejectInvalidExt, targetCategory, validateFile],
|
||
);
|
||
|
||
const onDrop = useCallback(
|
||
(e: React.DragEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
// Stop the global chat-upload listener on `document` from also receiving this drop.
|
||
// React's stopPropagation alone doesn't reach native document listeners — we need to
|
||
// halt the underlying DOM event too.
|
||
e.stopPropagation();
|
||
e.nativeEvent.stopImmediatePropagation();
|
||
setIsDragging(false);
|
||
const dropped = e.dataTransfer.files?.[0];
|
||
if (dropped) void acceptFile(dropped);
|
||
},
|
||
[acceptFile],
|
||
);
|
||
|
||
const onPick = useCallback(
|
||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const picked = e.target.files?.[0];
|
||
if (picked) void acceptFile(picked);
|
||
},
|
||
[acceptFile],
|
||
);
|
||
|
||
const handleInstall = useCallback(
|
||
async (overwrite: boolean) => {
|
||
if (!file) return;
|
||
try {
|
||
const result = await installMutation.mutateAsync({ file, overwrite, targetCategory });
|
||
toast.success(
|
||
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
|
||
"{name}",
|
||
result.skill_name,
|
||
),
|
||
);
|
||
// Stay open and advance to the optional "set Chinese name" step.
|
||
setInstalledName(result.skill_name);
|
||
setNameZhDraft("");
|
||
setNameZhModel(null);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : String(err));
|
||
}
|
||
},
|
||
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, targetCategory],
|
||
);
|
||
|
||
const handleGenerateNameZh = useCallback(async () => {
|
||
if (!installedName) return;
|
||
try {
|
||
const result = await generateNameZhMutation.mutateAsync({
|
||
skillName: installedName,
|
||
modelName: nameZhModel,
|
||
});
|
||
setNameZhDraft(result.name_zh ?? "");
|
||
toast.success(dialog.nameZhGenerated);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : String(err));
|
||
}
|
||
}, [dialog.nameZhGenerated, generateNameZhMutation, installedName, nameZhModel]);
|
||
|
||
const handleSaveNameZh = useCallback(async () => {
|
||
if (!installedName) return;
|
||
const trimmed = nameZhDraft.trim();
|
||
try {
|
||
// Empty draft = keep whatever is stored (or none); just close.
|
||
if (trimmed) {
|
||
await setNameZhMutation.mutateAsync({ skillName: installedName, nameZh: trimmed });
|
||
toast.success(dialog.nameZhSaved);
|
||
}
|
||
reset();
|
||
onClose();
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : String(err));
|
||
}
|
||
}, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]);
|
||
|
||
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
||
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
||
// Admin targeting public directory can overwrite an existing public skill.
|
||
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
||
const canInstall = !!validation && !validationError && validation.can_install_as_is;
|
||
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
|
||
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
|
||
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
|
||
const customConflictMessage = !validation
|
||
? ""
|
||
: otherCustomConflict
|
||
? dialog.conflictOwnedByOther
|
||
.replace("{name}", validation.skill_name)
|
||
.replace(
|
||
"{owner}",
|
||
validation.owner_username ?? validation.owner_user_id ?? "—",
|
||
)
|
||
: validation.owned_by_me
|
||
? (ownCustomCanOverwrite
|
||
? dialog.conflictOwnedByMe
|
||
: dialog.conflictOwnedByMeWrongTarget
|
||
).replace("{name}", validation.skill_name)
|
||
: dialog.conflictCustom.replace("{name}", validation.skill_name);
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||
<DialogContent className="max-w-xl">
|
||
<DialogHeader>
|
||
<DialogTitle>{installedName ? dialog.nameZhStepTitle : dialog.title}</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
{installedName && (
|
||
<>
|
||
<div className="flex flex-col gap-4">
|
||
<p className="text-muted-foreground text-sm">
|
||
{dialog.nameZhStepHint.replace("{name}", desensitize(installedName))}
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
value={nameZhDraft}
|
||
onChange={(e) => setNameZhDraft(e.currentTarget.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") void handleSaveNameZh();
|
||
}}
|
||
placeholder={dialog.nameZhPlaceholder}
|
||
className="h-9 flex-1"
|
||
autoFocus
|
||
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
|
||
/>
|
||
<ModelNameSelect
|
||
value={nameZhModel}
|
||
onChange={setNameZhModel}
|
||
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
|
||
triggerClassName="h-9 w-[9rem] shrink-0"
|
||
/>
|
||
<Button
|
||
variant="outline"
|
||
className="h-9 shrink-0 gap-1 border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
|
||
onClick={() => void handleGenerateNameZh()}
|
||
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
|
||
title={dialog.nameZhGenerate}
|
||
>
|
||
{generateNameZhMutation.isPending ? (
|
||
<Loader2 className="size-4 animate-spin" />
|
||
) : (
|
||
<SparklesIcon className="size-4" />
|
||
)}
|
||
{dialog.nameZhGenerate}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleClose}
|
||
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
|
||
>
|
||
{dialog.nameZhSkip}
|
||
</Button>
|
||
<Button
|
||
onClick={() => void handleSaveNameZh()}
|
||
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
|
||
>
|
||
{setNameZhMutation.isPending && <Loader2 className="size-4 animate-spin" />}
|
||
{t.common.save}
|
||
</Button>
|
||
</DialogFooter>
|
||
</>
|
||
)}
|
||
|
||
{!installedName && (
|
||
<>
|
||
<div className="flex flex-col gap-4">
|
||
<div
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.nativeEvent.stopImmediatePropagation();
|
||
setIsDragging(true);
|
||
}}
|
||
onDragLeave={() => setIsDragging(false)}
|
||
onDrop={onDrop}
|
||
className={cn(
|
||
"flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-6 text-center transition-colors",
|
||
isDragging ? "border-primary bg-accent/40" : "border-muted-foreground/30",
|
||
)}
|
||
>
|
||
<FileArchive className="text-muted-foreground size-8" />
|
||
<div className="text-muted-foreground text-sm">
|
||
{dialog.dropHint}
|
||
<button
|
||
type="button"
|
||
className="text-primary ml-1 underline underline-offset-4"
|
||
onClick={() => inputRef.current?.click()}
|
||
>
|
||
{dialog.chooseFile}
|
||
</button>
|
||
</div>
|
||
{file && (
|
||
<div className="text-muted-foreground mt-1 text-xs">
|
||
{file.name} · {(file.size / 1024).toFixed(1)} KB
|
||
</div>
|
||
)}
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
accept=".zip,.skill,application/zip"
|
||
hidden
|
||
onChange={onPick}
|
||
/>
|
||
</div>
|
||
|
||
{isAdmin && (
|
||
<div className="flex items-center gap-3 text-sm">
|
||
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
|
||
<div className="flex gap-4">
|
||
{(["custom", "public"] as const).map((cat) => (
|
||
<label key={cat} className="flex cursor-pointer items-center gap-1.5">
|
||
<input
|
||
type="radio"
|
||
name="targetCategory"
|
||
value={cat}
|
||
checked={targetCategory === cat}
|
||
onChange={() => {
|
||
setTargetCategory(cat);
|
||
if (file) void validateFile(file, cat);
|
||
}}
|
||
className="accent-primary"
|
||
/>
|
||
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{validateMutation.isPending && (
|
||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||
<Loader2 className="size-4 animate-spin" />
|
||
{dialog.validating}
|
||
</div>
|
||
)}
|
||
|
||
{validationError && (
|
||
<div className="text-destructive flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm">
|
||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||
<span>{validationError}</span>
|
||
</div>
|
||
)}
|
||
|
||
{validation && !validationError && (
|
||
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm">
|
||
<div className="flex items-center gap-2 text-emerald-600">
|
||
<CheckCircle2 className="size-4" />
|
||
<span className="font-medium">{desensitize(validation.skill_name)}</span>
|
||
</div>
|
||
<Field label={dialog.descriptionLabel}>
|
||
<span className="line-clamp-3 whitespace-pre-wrap">
|
||
{validation.description ? desensitize(validation.description) : "—"}
|
||
</span>
|
||
</Field>
|
||
{validation.license && (
|
||
<Field label={dialog.licenseLabel}>{desensitize(validation.license)}</Field>
|
||
)}
|
||
{validation.allowed_tools.length > 0 && (
|
||
<Field label={dialog.toolsLabel}>
|
||
<div className="flex flex-wrap gap-1">
|
||
{validation.allowed_tools.map((tool) => (
|
||
<Badge key={tool} variant="secondary" className="text-xs">
|
||
{desensitize(tool)}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</Field>
|
||
)}
|
||
{validation.warnings.length > 0 && (
|
||
<Field label={dialog.warningsLabel}>
|
||
<ul className="list-disc pl-4 text-xs">
|
||
{validation.warnings.map((w, idx) => (
|
||
<li key={idx}>{desensitize(w)}</li>
|
||
))}
|
||
</ul>
|
||
</Field>
|
||
)}
|
||
{isCustomConflict && (
|
||
<div
|
||
className={cn(
|
||
"mt-1 flex items-start gap-2 text-xs",
|
||
ownCustomCanOverwrite ? "text-amber-600" : "text-destructive",
|
||
)}
|
||
>
|
||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||
<span>
|
||
{desensitize(customConflictMessage)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{isPublicConflict && (
|
||
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
|
||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||
<span>
|
||
{desensitize(
|
||
(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name),
|
||
)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleClose}
|
||
disabled={installMutation.isPending}
|
||
>
|
||
{dialog.cancel}
|
||
</Button>
|
||
{canOverwrite && (
|
||
<Button
|
||
variant="destructive"
|
||
onClick={() => handleInstall(true)}
|
||
disabled={installMutation.isPending}
|
||
>
|
||
{installMutation.isPending && (
|
||
<Loader2 className="size-4 animate-spin" />
|
||
)}
|
||
{dialog.replace}
|
||
</Button>
|
||
)}
|
||
<Button
|
||
onClick={() => handleInstall(false)}
|
||
disabled={!canInstall || installMutation.isPending}
|
||
>
|
||
{installMutation.isPending && (
|
||
<Loader2 className="size-4 animate-spin" />
|
||
)}
|
||
{installMutation.isPending ? dialog.installing : dialog.install}
|
||
</Button>
|
||
</DialogFooter>
|
||
</>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
function Field({
|
||
label,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="grid grid-cols-[80px_1fr] gap-2">
|
||
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
|
||
<div className="min-w-0">{children}</div>
|
||
</div>
|
||
);
|
||
}
|