deerflow-code/frontend-web/src/components/workspace/settings/skill-settings-page.tsx
2026-09-07 18:24:55 +08:00

1648 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}