feat: add workspace view mode settings
This commit is contained in:
1 parent
9471e72171
commit
690d09a3d1
13 files changed
+817
-493
No files matched your search
@@ -70,6 +70,9 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
interface AgentCardProps {
|
interface AgentCardProps {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
|
/** "card" (default) renders the full gallery card; "list" renders a compact
|
||||||
|
* single-row layout for the list view. All dialogs/actions are shared. */
|
||||||
|
layout?: "card" | "list";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BadgeItem {
|
interface BadgeItem {
|
||||||
@@ -181,7 +184,8 @@ function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentCard({ agent }: AgentCardProps) {
|
export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||||
|
const isList = layout === "list";
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const deleteAgent = useDeleteAgent();
|
const deleteAgent = useDeleteAgent();
|
||||||
@@ -434,274 +438,328 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
|
|
||||||
const enabledSkills = skills.filter((skill) => skill.enabled);
|
const enabledSkills = skills.filter((skill) => skill.enabled);
|
||||||
|
|
||||||
return (
|
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
|
||||||
|
// both the card header and the compact list row.
|
||||||
|
const statusBadges = (
|
||||||
<>
|
<>
|
||||||
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
|
{isBuiltin && (
|
||||||
<CardHeader className="min-w-0 pb-3">
|
<Badge variant="secondary" className="text-xs">
|
||||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
内置
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
</Badge>
|
||||||
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
)}
|
||||||
<BotIcon className="h-5 w-5" />
|
{isOwned && (
|
||||||
</div>
|
<Badge
|
||||||
<div className="min-w-0 flex-1 overflow-hidden">
|
variant="default"
|
||||||
<CardTitle
|
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||||
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
|
>
|
||||||
title={desensitize(agent.name)}
|
我的
|
||||||
>
|
</Badge>
|
||||||
{desensitize(agent.name)}
|
)}
|
||||||
</CardTitle>
|
{isFromOthers && (
|
||||||
{agent.model && (
|
<Badge
|
||||||
<Badge variant="secondary" className="mt-0.5 text-xs">
|
variant="outline"
|
||||||
{desensitize(agent.model)}
|
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||||
</Badge>
|
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
|
||||||
)}
|
>
|
||||||
</div>
|
来自他人
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isOwned && agent.published && !agent.is_private && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||||
|
title="该智能体已对所有用户可见"
|
||||||
|
>
|
||||||
|
<GlobeIcon className="size-3" />
|
||||||
|
已发布
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{agent.is_private && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||||
|
title="该智能体位于私密广场,需要密码才能查看"
|
||||||
|
>
|
||||||
|
<ShieldIcon className="size-3" />
|
||||||
|
私密
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{agent.featured_order != null && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
|
||||||
|
title="已加入首页选择器"
|
||||||
|
>
|
||||||
|
<SparklesIcon className="size-3" />
|
||||||
|
首页推荐
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const skillBadgeItems = [
|
||||||
|
...(agent.tool_groups ?? []).map((group) => ({
|
||||||
|
key: `tg:${group}`,
|
||||||
|
node: (
|
||||||
|
<Badge key={`tg:${group}`} variant="outline" className="text-xs">
|
||||||
|
{desensitize(group)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
...(agent.skills ?? []).map((skill) => ({
|
||||||
|
key: `sk:${skill}`,
|
||||||
|
node: (
|
||||||
|
<Badge
|
||||||
|
key={`sk:${skill}`}
|
||||||
|
variant="secondary"
|
||||||
|
className="hover:bg-secondary/70 cursor-pointer text-xs"
|
||||||
|
title={t.settings.skills.viewDetailAction}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDetailSkillName(skill);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{desensitize(skill)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const hasSkillsOrTools =
|
||||||
|
(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
const showActionRow =
|
||||||
|
canEdit || canDelete || isOwned || isAdmin || canFavorite;
|
||||||
|
|
||||||
|
// The icon action buttons, shared by both layouts.
|
||||||
|
const actionButtons = (
|
||||||
|
<>
|
||||||
|
{canFavorite && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 shrink-0",
|
||||||
|
!isList && "mr-auto",
|
||||||
|
isFavorite && "text-amber-500 hover:text-amber-600",
|
||||||
|
)}
|
||||||
|
onClick={() => void handleToggleFavorite()}
|
||||||
|
disabled={toggleFavorite.isPending}
|
||||||
|
title={isFavorite ? "取消收藏" : "收藏到「我的」"}
|
||||||
|
>
|
||||||
|
<StarIcon
|
||||||
|
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 shrink-0",
|
||||||
|
isPinned && "text-emerald-600 hover:text-emerald-700",
|
||||||
|
!isList && !canFavorite && "mr-auto",
|
||||||
|
)}
|
||||||
|
onClick={() => void handleTogglePin()}
|
||||||
|
disabled={togglePin.isPending}
|
||||||
|
title={isPinned ? "取消置顶" : "置顶"}
|
||||||
|
>
|
||||||
|
<PinIcon className={cn("h-3.5 w-3.5", isPinned && "fill-current")} />
|
||||||
|
</Button>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 shrink-0",
|
||||||
|
agent.featured_order != null &&
|
||||||
|
"text-amber-600 hover:text-amber-700",
|
||||||
|
)}
|
||||||
|
onClick={() => void handleToggleFeatured()}
|
||||||
|
disabled={setFeatured.isPending}
|
||||||
|
title={
|
||||||
|
agent.featured_order != null
|
||||||
|
? "从首页选择器移除"
|
||||||
|
: "加入首页选择器"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SparklesIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={() => setTagPickerOpen(true)}
|
||||||
|
title={t.adminTags.manageTags}
|
||||||
|
>
|
||||||
|
<TagsIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isOwned && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={() => void handleTogglePublished()}
|
||||||
|
disabled={updateAgent.isPending}
|
||||||
|
title={agent.published ? "取消发布" : "发布"}
|
||||||
|
>
|
||||||
|
{agent.published ? (
|
||||||
|
<LockIcon className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<GlobeIcon className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(isOwned || isAdmin) && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 shrink-0",
|
||||||
|
agent.is_private && "text-purple-600 hover:text-purple-700",
|
||||||
|
)}
|
||||||
|
onClick={() => void handleTogglePrivate()}
|
||||||
|
disabled={updateAgent.isPending || setPrivate.isPending}
|
||||||
|
title={agent.is_private ? "移出私密广场" : "放入私密广场"}
|
||||||
|
>
|
||||||
|
<ShieldIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={() => void handleDuplicate()}
|
||||||
|
disabled={createAgent.isPending}
|
||||||
|
title="复制为新智能体"
|
||||||
|
>
|
||||||
|
<CopyIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
{canEdit && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
title="编辑智能体"
|
||||||
|
>
|
||||||
|
<SquarePenIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
title={t.agents.delete}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const chatButton = (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className={cn(isList ? "shrink-0" : "w-full")}
|
||||||
|
onClick={handleChat}
|
||||||
|
>
|
||||||
|
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{t.agents.chat}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardLayout = (
|
||||||
|
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
|
||||||
|
<CardHeader className="min-w-0 pb-2">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||||
|
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||||
|
<BotIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
{isBuiltin && (
|
<CardTitle
|
||||||
<Badge variant="secondary" className="text-xs">
|
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
|
||||||
内置
|
title={desensitize(agent.name)}
|
||||||
</Badge>
|
>
|
||||||
)}
|
{desensitize(agent.name)}
|
||||||
{isOwned && (
|
</CardTitle>
|
||||||
<Badge
|
{agent.model && (
|
||||||
variant="default"
|
<Badge variant="secondary" className="mt-0.5 text-xs">
|
||||||
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
{desensitize(agent.model)}
|
||||||
>
|
|
||||||
我的
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{isFromOthers && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
|
||||||
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
|
|
||||||
>
|
|
||||||
来自他人
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{isOwned && agent.published && !agent.is_private && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
|
||||||
title="该智能体已对所有用户可见"
|
|
||||||
>
|
|
||||||
<GlobeIcon className="size-3" />
|
|
||||||
已发布
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{agent.is_private && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
|
||||||
title="该智能体位于私密广场,需要密码才能查看"
|
|
||||||
>
|
|
||||||
<ShieldIcon className="size-3" />
|
|
||||||
私密
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{agent.featured_order != null && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
|
|
||||||
title="已加入首页选择器"
|
|
||||||
>
|
|
||||||
<SparklesIcon className="size-3" />
|
|
||||||
首页推荐
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{agent.description && (
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
|
||||||
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
{statusBadges}
|
||||||
{desensitize(agent.description)}
|
</div>
|
||||||
</CardDescription>
|
</div>
|
||||||
)}
|
{agent.description && (
|
||||||
</CardHeader>
|
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
||||||
|
{desensitize(agent.description)}
|
||||||
{(agent.tags?.length ?? 0) > 0 && (
|
</CardDescription>
|
||||||
<CardContent className="pt-0 pb-2">
|
|
||||||
<TagChipList tags={agent.tags!} />
|
|
||||||
</CardContent>
|
|
||||||
)}
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
|
{(agent.tags?.length ?? 0) > 0 && (
|
||||||
<CardContent className="pt-0 pb-3">
|
<CardContent className="pt-0 pb-2">
|
||||||
<ClampedBadgeRow
|
<TagChipList tags={agent.tags!} />
|
||||||
items={[
|
</CardContent>
|
||||||
...(agent.tool_groups ?? []).map((group) => ({
|
)}
|
||||||
key: `tg:${group}`,
|
|
||||||
node: (
|
{hasSkillsOrTools && (
|
||||||
<Badge
|
<CardContent className="pt-0 pb-2">
|
||||||
key={`tg:${group}`}
|
<ClampedBadgeRow items={skillBadgeItems} />
|
||||||
variant="outline"
|
</CardContent>
|
||||||
className="text-xs"
|
)}
|
||||||
>
|
|
||||||
{desensitize(group)}
|
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
|
||||||
</Badge>
|
{showActionRow && (
|
||||||
),
|
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||||
})),
|
{actionButtons}
|
||||||
...(agent.skills ?? []).map((skill) => ({
|
</div>
|
||||||
key: `sk:${skill}`,
|
|
||||||
node: (
|
|
||||||
<Badge
|
|
||||||
key={`sk:${skill}`}
|
|
||||||
variant="secondary"
|
|
||||||
className="hover:bg-secondary/70 cursor-pointer text-xs"
|
|
||||||
title={t.settings.skills.viewDetailAction}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setDetailSkillName(skill);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{desensitize(skill)}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
)}
|
||||||
|
{chatButton}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
|
const listLayout = (
|
||||||
{(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
|
<div className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||||
{canFavorite && (
|
<BotIcon className="h-5 w-5" />
|
||||||
<Button
|
</div>
|
||||||
size="icon"
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
variant="ghost"
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
className={cn(
|
<span
|
||||||
"mr-auto h-8 w-8 shrink-0",
|
className="min-w-0 truncate text-sm font-medium"
|
||||||
isFavorite && "text-amber-500 hover:text-amber-600",
|
title={desensitize(agent.name)}
|
||||||
)}
|
>
|
||||||
onClick={() => void handleToggleFavorite()}
|
{desensitize(agent.name)}
|
||||||
disabled={toggleFavorite.isPending}
|
</span>
|
||||||
title={isFavorite ? "取消收藏" : "收藏到「我的」"}
|
<div className="flex shrink-0 items-center gap-1">{statusBadges}</div>
|
||||||
>
|
</div>
|
||||||
<StarIcon
|
{agent.description && (
|
||||||
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
|
<p className="text-muted-foreground mt-0.5 truncate text-xs">
|
||||||
/>
|
{desensitize(agent.description)}
|
||||||
</Button>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button
|
</div>
|
||||||
size="icon"
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
variant="ghost"
|
{showActionRow && actionButtons}
|
||||||
className={cn(
|
{chatButton}
|
||||||
"h-8 w-8 shrink-0",
|
</div>
|
||||||
isPinned && "text-emerald-600 hover:text-emerald-700",
|
</div>
|
||||||
!canFavorite && "mr-auto",
|
);
|
||||||
)}
|
|
||||||
onClick={() => void handleTogglePin()}
|
return (
|
||||||
disabled={togglePin.isPending}
|
<>
|
||||||
title={isPinned ? "取消置顶" : "置顶"}
|
{isList ? listLayout : cardLayout}
|
||||||
>
|
|
||||||
<PinIcon className={cn("h-3.5 w-3.5", isPinned && "fill-current")} />
|
|
||||||
</Button>
|
|
||||||
{isAdmin && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-8 shrink-0",
|
|
||||||
agent.featured_order != null &&
|
|
||||||
"text-amber-600 hover:text-amber-700",
|
|
||||||
)}
|
|
||||||
onClick={() => void handleToggleFeatured()}
|
|
||||||
disabled={setFeatured.isPending}
|
|
||||||
title={
|
|
||||||
agent.featured_order != null
|
|
||||||
? "从首页选择器移除"
|
|
||||||
: "加入首页选择器"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SparklesIcon className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 shrink-0"
|
|
||||||
onClick={() => setTagPickerOpen(true)}
|
|
||||||
title={t.adminTags.manageTags}
|
|
||||||
>
|
|
||||||
<TagsIcon className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isOwned && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 shrink-0"
|
|
||||||
onClick={() => void handleTogglePublished()}
|
|
||||||
disabled={updateAgent.isPending}
|
|
||||||
title={agent.published ? "取消发布" : "发布"}
|
|
||||||
>
|
|
||||||
{agent.published ? (
|
|
||||||
<LockIcon className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<GlobeIcon className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{(isOwned || isAdmin) && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-8 shrink-0",
|
|
||||||
agent.is_private &&
|
|
||||||
"text-purple-600 hover:text-purple-700",
|
|
||||||
)}
|
|
||||||
onClick={() => void handleTogglePrivate()}
|
|
||||||
disabled={updateAgent.isPending || setPrivate.isPending}
|
|
||||||
title={
|
|
||||||
agent.is_private ? "移出私密广场" : "放入私密广场"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ShieldIcon className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 shrink-0"
|
|
||||||
onClick={() => void handleDuplicate()}
|
|
||||||
disabled={createAgent.isPending}
|
|
||||||
title="复制为新智能体"
|
|
||||||
>
|
|
||||||
<CopyIcon className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
{canEdit && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 shrink-0"
|
|
||||||
onClick={() => setEditOpen(true)}
|
|
||||||
title="编辑智能体"
|
|
||||||
>
|
|
||||||
<SquarePenIcon className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canDelete && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
|
||||||
onClick={() => setDeleteOpen(true)}
|
|
||||||
title={t.agents.delete}
|
|
||||||
>
|
|
||||||
<Trash2Icon className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button size="sm" className="w-full" onClick={handleChat}>
|
|
||||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
{t.agents.chat}
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import { Pagination } from "@/components/ui/pagination";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||||
|
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||||
import type { Agent, AgentSort } from "@/core/agents";
|
import type { Agent, AgentSort } from "@/core/agents";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { usePrivateSquareStatus } from "@/core/system-settings/hooks";
|
import { usePrivateSquareStatus, useViewMode } from "@/core/system-settings/hooks";
|
||||||
|
|
||||||
import { AgentCard } from "./agent-card";
|
import { AgentCard } from "./agent-card";
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
|||||||
export function AgentGallery() {
|
export function AgentGallery() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [viewMode, setViewMode] = useViewMode("agent");
|
||||||
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
|
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
|
||||||
"mine",
|
"mine",
|
||||||
);
|
);
|
||||||
@@ -125,13 +127,20 @@ export function AgentGallery() {
|
|||||||
setPrivatePassword(null);
|
setPrivatePassword(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderGrid = (list: Agent[]) => (
|
const renderGrid = (list: Agent[]) =>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
viewMode === "list" ? (
|
||||||
{list.map((agent) => (
|
<div className="flex flex-col gap-2">
|
||||||
<AgentCard key={agent.id} agent={agent} />
|
{list.map((agent) => (
|
||||||
))}
|
<AgentCard key={agent.id} agent={agent} layout="list" />
|
||||||
</div>
|
))}
|
||||||
);
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{list.map((agent) => (
|
||||||
|
<AgentCard key={agent.id} agent={agent} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
/** Loading / empty / (grid + pagination) for one paginated tab. */
|
/** Loading / empty / (grid + pagination) for one paginated tab. */
|
||||||
const renderListBody = (opts: {
|
const renderListBody = (opts: {
|
||||||
@@ -308,6 +317,7 @@ export function AgentGallery() {
|
|||||||
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
||||||
共 {activeTotal} 个
|
共 {activeTotal} 个
|
||||||
</span>
|
</span>
|
||||||
|
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||||
{activeTab === "private" && privatePassword && (
|
{activeTab === "private" && privatePassword && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MoonIcon, SunIcon } from "lucide-react";
|
import { LayoutGridIcon, ListIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||||
import { useMemo, type ComponentType, type SVGProps } from "react";
|
import { useMemo, type ComponentType, type SVGProps } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -48,6 +48,8 @@ export function AppearanceSettingsPage() {
|
|||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
||||||
const globalDefault = appearance?.default_theme ?? "light";
|
const globalDefault = appearance?.default_theme ?? "light";
|
||||||
|
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||||
|
const globalAgentView = appearance?.default_agent_view ?? "card";
|
||||||
const cleanupWords =
|
const cleanupWords =
|
||||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||||
|
|
||||||
@@ -65,6 +67,27 @@ export function AppearanceSettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSetDefaultView = async (
|
||||||
|
kind: "skill" | "agent",
|
||||||
|
value: "card" | "list",
|
||||||
|
) => {
|
||||||
|
const current = kind === "skill" ? globalSkillView : globalAgentView;
|
||||||
|
if (value === current) return;
|
||||||
|
try {
|
||||||
|
await updateAppearance.mutateAsync(
|
||||||
|
kind === "skill"
|
||||||
|
? { default_skill_view: value }
|
||||||
|
: { default_agent_view: value },
|
||||||
|
);
|
||||||
|
const label = kind === "skill" ? "技能" : "智能体";
|
||||||
|
toast.success(
|
||||||
|
`已将${label}默认展示模式设置为${value === "card" ? "卡片" : "列表"}`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const themeOptions = useMemo(
|
const themeOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -146,6 +169,52 @@ export function AppearanceSettingsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<SettingsSection
|
||||||
|
title="默认展示模式(管理员)"
|
||||||
|
description="技能与智能体列表对所有还没有手动切换过的用户的默认展示模式;已经自己切换过的用户保留其个人选择不受影响。"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ kind: "skill", label: "技能", current: globalSkillView },
|
||||||
|
{ kind: "agent", label: "智能体", current: globalAgentView },
|
||||||
|
] as const
|
||||||
|
).map(({ kind, label, current }) => (
|
||||||
|
<div key={kind} className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="w-14 text-sm font-medium">{label}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={current === "card" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleSetDefaultView(kind, "card")}
|
||||||
|
disabled={updateAppearance.isPending}
|
||||||
|
>
|
||||||
|
<LayoutGridIcon className="mr-1.5 size-4" />
|
||||||
|
卡片
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={current === "list" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleSetDefaultView(kind, "list")}
|
||||||
|
disabled={updateAppearance.isPending}
|
||||||
|
>
|
||||||
|
<ListIcon className="mr-1.5 size-4" />
|
||||||
|
列表
|
||||||
|
</Button>
|
||||||
|
<p className="text-muted-foreground ml-2 text-xs">
|
||||||
|
当前系统默认:{current === "card" ? "卡片" : "列表"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-di
|
|||||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||||
|
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -74,6 +75,7 @@ import {
|
|||||||
useSkills,
|
useSkills,
|
||||||
useValidateSkillUpload,
|
useValidateSkillUpload,
|
||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
|
import { useViewMode } from "@/core/system-settings/hooks";
|
||||||
import type { Skill } from "@/core/skills/type";
|
import type { Skill } from "@/core/skills/type";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
@@ -150,6 +152,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||||
const [sort, setSort] = useState<SkillSort>("time_desc");
|
const [sort, setSort] = useState<SkillSort>("time_desc");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [viewMode, setViewMode] = useViewMode("skill");
|
||||||
const reconcileMutation = useReconcileCustomSkills();
|
const reconcileMutation = useReconcileCustomSkills();
|
||||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||||
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
|
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
|
||||||
@@ -335,25 +338,28 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
tagIds={tagIds}
|
tagIds={tagIds}
|
||||||
onTagIdsChange={setTagIds}
|
onTagIdsChange={setTagIds}
|
||||||
actions={
|
actions={
|
||||||
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
|
<div className="flex items-center gap-2">
|
||||||
<SelectTrigger
|
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||||
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
|
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
|
||||||
title="排序"
|
<SelectTrigger
|
||||||
>
|
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
|
||||||
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
|
title="排序"
|
||||||
<SelectValue />
|
>
|
||||||
</SelectTrigger>
|
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
|
||||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
<SelectValue />
|
||||||
<SelectItem value="time_desc">最新创建</SelectItem>
|
</SelectTrigger>
|
||||||
<SelectItem value="time_asc">最早创建</SelectItem>
|
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||||
<SelectItem value="activity_desc">最近使用</SelectItem>
|
<SelectItem value="time_desc">最新创建</SelectItem>
|
||||||
<SelectItem value="activity_asc">最早使用</SelectItem>
|
<SelectItem value="time_asc">最早创建</SelectItem>
|
||||||
<SelectItem value="usage_desc">使用最多</SelectItem>
|
<SelectItem value="activity_desc">最近使用</SelectItem>
|
||||||
<SelectItem value="usage_asc">使用最少</SelectItem>
|
<SelectItem value="activity_asc">最早使用</SelectItem>
|
||||||
<SelectItem value="name_asc">名称 A-Z</SelectItem>
|
<SelectItem value="usage_desc">使用最多</SelectItem>
|
||||||
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
<SelectItem value="usage_asc">使用最少</SelectItem>
|
||||||
</SelectContent>
|
<SelectItem value="name_asc">名称 A-Z</SelectItem>
|
||||||
</Select>
|
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
@@ -364,13 +370,20 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
<EmptySkill />
|
<EmptySkill />
|
||||||
)}
|
)}
|
||||||
{!isLoading && !error && filteredSkills.length > 0 && (
|
{!isLoading && !error && filteredSkills.length > 0 && (
|
||||||
<div className="flex flex-col gap-3">
|
<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) => (
|
{pagedSkills.map((skill) => (
|
||||||
<SkillRow
|
<SkillRow
|
||||||
key={skill.name}
|
key={skill.name}
|
||||||
skill={skill}
|
skill={skill}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
|
layout={viewMode === "card" ? "card" : "row"}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -427,11 +440,15 @@ export function SkillRow({
|
|||||||
skill,
|
skill,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
layout = "row",
|
||||||
}: {
|
}: {
|
||||||
skill: Skill;
|
skill: Skill;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
|
||||||
|
layout?: "row" | "card";
|
||||||
}) {
|
}) {
|
||||||
|
const isCard = layout === "card";
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const setPublished = useSetSkillPublished();
|
const setPublished = useSetSkillPublished();
|
||||||
const setPinned = useSetSkillPinned();
|
const setPinned = useSetSkillPinned();
|
||||||
@@ -529,207 +546,239 @@ export function SkillRow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const badgeGroup = (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={cn(isCard && "truncate font-medium")}>
|
||||||
|
{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 && 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>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const metaNodes = (
|
||||||
|
<>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{(skill.tags?.length ?? 0) > 0 && (
|
||||||
|
<TagChipList tags={skill.tags!} className="mt-2" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionButtons = (
|
||||||
|
<>
|
||||||
|
{isAdmin && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
{canManage && (
|
||||||
|
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
{isCard ? (
|
||||||
<ItemContent
|
<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">
|
||||||
className="cursor-pointer"
|
<div
|
||||||
onClick={() => setDetailOpen(true)}
|
className="min-w-0 flex-1 cursor-pointer"
|
||||||
>
|
onClick={() => setDetailOpen(true)}
|
||||||
<ItemTitle>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span>{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 && 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>
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ItemTitle>
|
|
||||||
<ItemDescription className="line-clamp-4">
|
|
||||||
{desensitize(skill.description ?? "")}
|
|
||||||
</ItemDescription>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
{(skill.tags?.length ?? 0) > 0 && (
|
|
||||||
<TagChipList tags={skill.tags!} className="mt-2" />
|
|
||||||
)}
|
|
||||||
</ItemContent>
|
|
||||||
<ItemActions>
|
|
||||||
{isAdmin && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{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" />
|
{badgeGroup}
|
||||||
</Button>
|
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
|
||||||
{canManage && (
|
{desensitize(skill.description ?? "")}
|
||||||
<Button
|
</p>
|
||||||
size="icon"
|
{metaNodes}
|
||||||
variant="ghost"
|
</div>
|
||||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
<div className="mt-2 flex flex-wrap items-center justify-end gap-1 border-t pt-2 dark:border-[#213D5E]">
|
||||||
onClick={() => setConfirmDeleteOpen(true)}
|
{actionButtons}
|
||||||
title={t.settings.skills.deleteAction}
|
</div>
|
||||||
>
|
</div>
|
||||||
<Trash2Icon className="size-3.5" />
|
) : (
|
||||||
</Button>
|
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
||||||
)}
|
<ItemContent
|
||||||
</ItemActions>
|
className="cursor-pointer"
|
||||||
</Item>
|
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}>
|
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LayoutGridIcon, ListIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { ViewMode } from "@/core/system-settings/api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact card/list switch shown in the header of the skills and agents lists.
|
||||||
|
* Purely presentational — the parent owns the `value` (resolved from the user's
|
||||||
|
* local override or the admin default via `useViewMode`) and persists changes.
|
||||||
|
*/
|
||||||
|
export function ViewModeToggle({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: ViewMode;
|
||||||
|
onChange: (mode: ViewMode) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/60 inline-flex items-center gap-0.5 rounded-md p-0.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
role="group"
|
||||||
|
aria-label="展示模式"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant={value === "card" ? "default" : "ghost"}
|
||||||
|
className="size-7"
|
||||||
|
onClick={() => onChange("card")}
|
||||||
|
title="卡片模式"
|
||||||
|
aria-pressed={value === "card"}
|
||||||
|
>
|
||||||
|
<LayoutGridIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant={value === "list" ? "default" : "ghost"}
|
||||||
|
className="size-7"
|
||||||
|
onClick={() => onChange("list")}
|
||||||
|
title="列表模式"
|
||||||
|
aria-pressed={value === "list"}
|
||||||
|
>
|
||||||
|
<ListIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -55,6 +55,13 @@ export interface LocalSettings {
|
|||||||
* app sizes text/spacing in rem, the whole UI scales proportionally.
|
* app sizes text/spacing in rem, the whole UI scales proportionally.
|
||||||
*/
|
*/
|
||||||
fontScale: number;
|
fontScale: number;
|
||||||
|
/**
|
||||||
|
* Per-user override for the skill / agent list display mode (card vs list).
|
||||||
|
* `undefined` = follow the admin-configured system default. Set when the
|
||||||
|
* user flips the card/list toggle on the skills or agents page.
|
||||||
|
*/
|
||||||
|
skillViewMode?: "card" | "list";
|
||||||
|
agentViewMode?: "card" | "list";
|
||||||
};
|
};
|
||||||
context: Omit<
|
context: Omit<
|
||||||
AgentThreadContext,
|
AgentThreadContext,
|
||||||
|
|||||||
@@ -90,13 +90,18 @@ export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ThemeMode = "light" | "dark";
|
export type ThemeMode = "light" | "dark";
|
||||||
|
export type ViewMode = "card" | "list";
|
||||||
|
|
||||||
export interface AppearanceSettings {
|
export interface AppearanceSettings {
|
||||||
default_theme: ThemeMode;
|
default_theme: ThemeMode;
|
||||||
|
default_skill_view: ViewMode;
|
||||||
|
default_agent_view: ViewMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppearanceUpdate {
|
export interface AppearanceUpdate {
|
||||||
default_theme?: ThemeMode;
|
default_theme?: ThemeMode;
|
||||||
|
default_skill_view?: ViewMode;
|
||||||
|
default_agent_view?: ViewMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
|
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { useLocalSettings } from "@/core/settings";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
loadAppearanceSettings,
|
loadAppearanceSettings,
|
||||||
loadCitationDisplaySettings,
|
loadCitationDisplaySettings,
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
type LeaderboardSettingsUpdate,
|
type LeaderboardSettingsUpdate,
|
||||||
type PrivateSquareUpdate,
|
type PrivateSquareUpdate,
|
||||||
type PromptPrefixUpdate,
|
type PromptPrefixUpdate,
|
||||||
|
type ViewMode,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
||||||
@@ -86,6 +89,31 @@ export function useUpdateAppearanceSettings() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective card/list display mode for the skill or agent list. A user's own
|
||||||
|
* choice (persisted in local settings) wins; otherwise the admin-configured
|
||||||
|
* system default applies; otherwise a built-in fallback (skills → list,
|
||||||
|
* agents → card, matching the historical layout of each page). `setMode`
|
||||||
|
* persists the user's override locally.
|
||||||
|
*/
|
||||||
|
export function useViewMode(
|
||||||
|
kind: "skill" | "agent",
|
||||||
|
): [ViewMode, (mode: ViewMode) => void] {
|
||||||
|
const [settings, setSettings] = useLocalSettings();
|
||||||
|
const { data: appearance } = useAppearanceSettings();
|
||||||
|
const overrideKey = kind === "skill" ? "skillViewMode" : "agentViewMode";
|
||||||
|
const userOverride = settings.display[overrideKey];
|
||||||
|
const adminDefault =
|
||||||
|
kind === "skill"
|
||||||
|
? appearance?.default_skill_view
|
||||||
|
: appearance?.default_agent_view;
|
||||||
|
const fallback: ViewMode = kind === "skill" ? "list" : "card";
|
||||||
|
const mode: ViewMode = userOverride ?? adminDefault ?? fallback;
|
||||||
|
const setMode = (next: ViewMode) =>
|
||||||
|
setSettings("display", { [overrideKey]: next });
|
||||||
|
return [mode, setMode];
|
||||||
|
}
|
||||||
|
|
||||||
export function useCitationDisplaySettings() {
|
export function useCitationDisplaySettings() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: CITATION_DISPLAY_KEY,
|
queryKey: CITATION_DISPLAY_KEY,
|
||||||
|
|||||||
@@ -751,7 +751,24 @@ export default function ScheduledTasksPage() {
|
|||||||
<Card className="flex min-h-0 min-w-0 flex-col">
|
<Card className="flex min-h-0 min-w-0 flex-col">
|
||||||
<CardHeader className="gap-2">
|
<CardHeader className="gap-2">
|
||||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||||
<CardTitle>执行记录</CardTitle>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<CardTitle>执行记录</CardTitle>
|
||||||
|
{selectedTask && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 gap-1 px-2 text-xs"
|
||||||
|
title="在新标签打开该任务的公开页"
|
||||||
|
onClick={() => {
|
||||||
|
const url = `${window.location.origin}${window.location.pathname}#/public/scheduled-tasks/${encodeURIComponent(selectedTask.task_id)}`;
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Globe className="size-3.5" />
|
||||||
|
公开页
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Select value={runsTimeFilter} onValueChange={(v) => setRunsTimeFilter(v as typeof runsTimeFilter)}>
|
<Select value={runsTimeFilter} onValueChange={(v) => setRunsTimeFilter(v as typeof runsTimeFilter)}>
|
||||||
<SelectTrigger className="h-7 w-28 text-xs">
|
<SelectTrigger className="h-7 w-28 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|||||||
@@ -944,7 +944,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
app.include_router(recommend.router) # Step 1→2:/api/recommend/stream(无 init)
|
app.include_router(recommend.router) # Step 1→2:/api/recommend/stream(无 init)
|
||||||
# Step 3 产物列表/下载复用下方 artifacts.router(/api/threads/{id}/artifacts)
|
# Step 3 产物列表/下载复用下方 artifacts.router(/api/threads/{id}/artifacts)
|
||||||
|
|
||||||
|
# 探活端点:无需鉴权(见 auth_middleware._PUBLIC_PATH_PREFIXES)。
|
||||||
|
# 同时暴露裸 ``/health`` 与带 API 前缀的 ``/api/health``,方便监控按
|
||||||
|
# 任意 base URL 直接探活,不会被认证中间件拦成 401。
|
||||||
@app.get("/health", tags=["health"])
|
@app.get("/health", tags=["health"])
|
||||||
|
@app.get("/api/health", tags=["health"])
|
||||||
async def health_check() -> dict:
|
async def health_check() -> dict:
|
||||||
"""Health check endpoint.
|
"""Health check endpoint.
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from deerflow.runtime.user_context import reset_current_user, set_current_user
|
|||||||
# safe read-only routes under it — everything there bypasses auth entirely.
|
# safe read-only routes under it — everything there bypasses auth entirely.
|
||||||
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||||
"/health",
|
"/health",
|
||||||
|
"/api/health",
|
||||||
"/docs",
|
"/docs",
|
||||||
"/redoc",
|
"/redoc",
|
||||||
"/openapi.json",
|
"/openapi.json",
|
||||||
|
|||||||
@@ -223,17 +223,28 @@ class AppearanceResponse(BaseModel):
|
|||||||
"""Effective system-wide appearance defaults."""
|
"""Effective system-wide appearance defaults."""
|
||||||
|
|
||||||
default_theme: Literal["light", "dark"] = Field(..., description="System default theme — light or dark")
|
default_theme: Literal["light", "dark"] = Field(..., description="System default theme — light or dark")
|
||||||
|
default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list")
|
||||||
|
default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list")
|
||||||
|
|
||||||
|
|
||||||
class AppearanceUpdateRequest(BaseModel):
|
class AppearanceUpdateRequest(BaseModel):
|
||||||
default_theme: Literal["light", "dark"] | None = Field(default=None)
|
default_theme: Literal["light", "dark"] | None = Field(default=None)
|
||||||
|
default_skill_view: Literal["card", "list"] | None = Field(default=None)
|
||||||
|
default_agent_view: Literal["card", "list"] | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _appearance_response(appearance) -> AppearanceResponse:
|
||||||
|
return AppearanceResponse(
|
||||||
|
default_theme=appearance.default_theme,
|
||||||
|
default_skill_view=appearance.default_skill_view,
|
||||||
|
default_agent_view=appearance.default_agent_view,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/appearance", response_model=AppearanceResponse)
|
@router.get("/appearance", response_model=AppearanceResponse)
|
||||||
async def get_appearance_settings() -> AppearanceResponse:
|
async def get_appearance_settings() -> AppearanceResponse:
|
||||||
"""Return the global default theme. Any logged-in user may read this."""
|
"""Return the global appearance defaults. Any logged-in user may read this."""
|
||||||
settings = load_system_settings().appearance
|
return _appearance_response(load_system_settings().appearance)
|
||||||
return AppearanceResponse(default_theme=settings.default_theme)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/appearance", response_model=AppearanceResponse)
|
@router.put("/appearance", response_model=AppearanceResponse)
|
||||||
@@ -246,8 +257,9 @@ async def update_appearance_settings(
|
|||||||
current = load_system_settings()
|
current = load_system_settings()
|
||||||
fields = body.model_dump(exclude_unset=True)
|
fields = body.model_dump(exclude_unset=True)
|
||||||
update: dict[str, object] = {}
|
update: dict[str, object] = {}
|
||||||
if "default_theme" in fields and fields["default_theme"] is not None:
|
for key in ("default_theme", "default_skill_view", "default_agent_view"):
|
||||||
update["default_theme"] = fields["default_theme"]
|
if key in fields and fields[key] is not None:
|
||||||
|
update[key] = fields[key]
|
||||||
if update:
|
if update:
|
||||||
updated_appearance = current.appearance.model_copy(update=update)
|
updated_appearance = current.appearance.model_copy(update=update)
|
||||||
saved = save_system_settings(
|
saved = save_system_settings(
|
||||||
@@ -255,7 +267,7 @@ async def update_appearance_settings(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
saved = current
|
saved = current
|
||||||
return AppearanceResponse(default_theme=saved.appearance.default_theme)
|
return _appearance_response(saved.appearance)
|
||||||
|
|
||||||
|
|
||||||
class LogSystemResponse(BaseModel):
|
class LogSystemResponse(BaseModel):
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ class AppearanceSettings(BaseModel):
|
|||||||
default="light",
|
default="light",
|
||||||
description="Theme applied for users who haven't picked their own (light / dark)",
|
description="Theme applied for users who haven't picked their own (light / dark)",
|
||||||
)
|
)
|
||||||
|
default_skill_view: Literal["card", "list"] = Field(
|
||||||
|
default="list",
|
||||||
|
description="Default skill list display mode for users who haven't picked their own (card / list)",
|
||||||
|
)
|
||||||
|
default_agent_view: Literal["card", "list"] = Field(
|
||||||
|
default="card",
|
||||||
|
description="Default agent list display mode for users who haven't picked their own (card / list)",
|
||||||
|
)
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user