feat: add workspace view mode settings

This commit is contained in:
吉鑫升 committed 2026-06-05 17:21:49 +08:00
1 parent 9471e72171
commit 690d09a3d1
13 files changed
+842 -518

No files matched your search

@@ -6,9 +6,9 @@ import {
GlobeIcon,
InfoIcon,
LockIcon,
MessageSquareIcon,
PinIcon,
ShieldIcon,
MessageSquareIcon,
PinIcon,
ShieldIcon,
SparklesIcon,
SquarePenIcon,
StarIcon,
@@ -54,11 +54,11 @@ import {
useAgent,
useCreateAgent,
useDeleteAgent,
useSetAgentFeatured,
useSetAgentPrivate,
useToggleAgentFavorite,
useToggleAgentPin,
useUpdateAgent,
useSetAgentFeatured,
useSetAgentPrivate,
useToggleAgentFavorite,
useToggleAgentPin,
useUpdateAgent,
} from "@/core/agents";
import { getAgent } from "@/core/agents/api";
import type { Agent } from "@/core/agents";
@@ -70,6 +70,9 @@ import { cn } from "@/lib/utils";
interface AgentCardProps {
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 {
@@ -181,16 +184,17 @@ 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 router = useRouter();
const deleteAgent = useDeleteAgent();
const updateAgent = useUpdateAgent();
const createAgent = useCreateAgent();
const setFeatured = useSetAgentFeatured();
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
const togglePin = useToggleAgentPin();
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
const togglePin = useToggleAgentPin();
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
@@ -228,8 +232,8 @@ export function AgentCard({ agent }: AgentCardProps) {
// Favoriting collects a square agent into "我的"; pointless for one you
// already own, so only offer it on built-in / others' agents.
const canFavorite = !isOwned;
const isFavorite = !!agent.is_favorite;
const isPinned = !!agent.is_pinned;
const isFavorite = !!agent.is_favorite;
const isPinned = !!agent.is_pinned;
useEffect(() => {
if (!editOpen) {
@@ -383,7 +387,7 @@ export function AgentCard({ agent }: AgentCardProps) {
}
}
async function handleToggleFavorite() {
async function handleToggleFavorite() {
const next = !isFavorite;
try {
await toggleFavorite.mutateAsync({ id: agent.id, favorite: next });
@@ -391,17 +395,17 @@ export function AgentCard({ agent }: AgentCardProps) {
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePin() {
const next = !isPinned;
try {
await togglePin.mutateAsync({ id: agent.id, pinned: next });
toast.success(next ? "已置顶" : "已取消置顶");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
}
async function handleTogglePin() {
const next = !isPinned;
try {
await togglePin.mutateAsync({ id: agent.id, pinned: next });
toast.success(next ? "已置顶" : "已取消置顶");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleToggleFeatured() {
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
@@ -434,274 +438,328 @@ export function AgentCard({ agent }: AgentCardProps) {
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">
<CardHeader className="min-w-0 pb-3">
<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 className="min-w-0 flex-1 overflow-hidden">
<CardTitle
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</CardTitle>
{agent.model && (
<Badge variant="secondary" className="mt-0.5 text-xs">
{desensitize(agent.model)}
</Badge>
)}
</div>
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
内置
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
我的
</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>
)}
</>
);
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 className="flex shrink-0 items-center gap-1">
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
内置
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
我的
</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" />
首页推荐
<div className="min-w-0 flex-1 overflow-hidden">
<CardTitle
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</CardTitle>
{agent.model && (
<Badge variant="secondary" className="mt-0.5 text-xs">
{desensitize(agent.model)}
</Badge>
)}
</div>
</div>
{agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm">
{desensitize(agent.description)}
</CardDescription>
)}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-2">
<TagChipList tags={agent.tags!} />
</CardContent>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
{statusBadges}
</div>
</div>
{agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm">
{desensitize(agent.description)}
</CardDescription>
)}
</CardHeader>
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-3">
<ClampedBadgeRow
items={[
...(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>
),
})),
]}
/>
</CardContent>
{(agent.tags?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-2">
<TagChipList tags={agent.tags!} />
</CardContent>
)}
{hasSkillsOrTools && (
<CardContent className="pt-0 pb-2">
<ClampedBadgeRow items={skillBadgeItems} />
</CardContent>
)}
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
{showActionRow && (
<div className="flex flex-wrap items-center justify-end gap-1">
{actionButtons}
</div>
)}
{chatButton}
</CardFooter>
</Card>
);
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
{(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
<div className="flex flex-wrap items-center justify-end gap-1">
{canFavorite && (
<Button
size="icon"
variant="ghost"
className={cn(
"mr-auto h-8 w-8 shrink-0",
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",
!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>
)}
</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>
const listLayout = (
<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="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex min-w-0 items-center gap-2">
<span
className="min-w-0 truncate text-sm font-medium"
title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</span>
<div className="flex shrink-0 items-center gap-1">{statusBadges}</div>
</div>
{agent.description && (
<p className="text-muted-foreground mt-0.5 truncate text-xs">
{desensitize(agent.description)}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{showActionRow && actionButtons}
{chatButton}
</div>
</div>
);
return (
<>
{isList ? listLayout : cardLayout}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents";
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";
@@ -28,6 +29,7 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
export function AgentGallery() {
const { t } = useI18n();
const router = useRouter();
const [viewMode, setViewMode] = useViewMode("agent");
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
"mine",
);
@@ -125,13 +127,20 @@ export function AgentGallery() {
setPrivatePassword(null);
};
const renderGrid = (list: Agent[]) => (
<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>
);
const renderGrid = (list: Agent[]) =>
viewMode === "list" ? (
<div className="flex flex-col gap-2">
{list.map((agent) => (
<AgentCard key={agent.id} agent={agent} layout="list" />
))}
</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. */
const renderListBody = (opts: {
@@ -308,6 +317,7 @@ export function AgentGallery() {
<span className="text-muted-foreground text-sm whitespace-nowrap">
共 {activeTotal} 个
</span>
<ViewModeToggle value={viewMode} onChange={setViewMode} />
{activeTab === "private" && privatePassword && (
<Button
variant="outline"
@@ -1,6 +1,6 @@
"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 { toast } from "sonner";
@@ -48,6 +48,8 @@ export function AppearanceSettingsPage() {
const { data: citationDisplay } = useCitationDisplaySettings();
const updateCitationDisplay = useUpdateCitationDisplaySettings();
const globalDefault = appearance?.default_theme ?? "light";
const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card";
const cleanupWords =
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(
() => [
{
@@ -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 />
<SettingsSection
@@ -59,6 +59,7 @@ import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-di
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";
@@ -74,6 +75,7 @@ import {
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
import { useViewMode } from "@/core/system-settings/hooks";
import type { Skill } from "@/core/skills/type";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
@@ -150,6 +152,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<SkillSort>("time_desc");
const [page, setPage] = useState(1);
const [viewMode, setViewMode] = useViewMode("skill");
const reconcileMutation = useReconcileCustomSkills();
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
@@ -335,25 +338,28 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<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 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 && (
@@ -364,13 +370,20 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<EmptySkill />
)}
{!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) => (
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
layout={viewMode === "card" ? "card" : "row"}
/>
))}
</div>
@@ -427,11 +440,15 @@ export function SkillRow({
skill,
currentUserId,
isAdmin,
layout = "row",
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
layout?: "row" | "card";
}) {
const isCard = layout === "card";
const { t } = useI18n();
const setPublished = useSetSkillPublished();
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 (
<>
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
<ItemContent
className="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="复制为新技能"
{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)}
>
<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>
)}
</ItemActions>
</Item>
{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>
@@ -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>
);
}
+7
View File
@@ -55,6 +55,13 @@ export interface LocalSettings {
* app sizes text/spacing in rem, the whole UI scales proportionally.
*/
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<
AgentThreadContext,
@@ -90,13 +90,18 @@ export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
}
export type ThemeMode = "light" | "dark";
export type ViewMode = "card" | "list";
export interface AppearanceSettings {
default_theme: ThemeMode;
default_skill_view: ViewMode;
default_agent_view: ViewMode;
}
export interface AppearanceUpdate {
default_theme?: ThemeMode;
default_skill_view?: ViewMode;
default_agent_view?: ViewMode;
}
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
@@ -1,5 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalSettings } from "@/core/settings";
import {
loadAppearanceSettings,
loadCitationDisplaySettings,
@@ -16,6 +18,7 @@ import {
type LeaderboardSettingsUpdate,
type PrivateSquareUpdate,
type PromptPrefixUpdate,
type ViewMode,
} from "./api";
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() {
return useQuery({
queryKey: CITATION_DISPLAY_KEY,
+18 -1
View File
@@ -751,7 +751,24 @@ export default function ScheduledTasksPage() {
<Card className="flex min-h-0 min-w-0 flex-col">
<CardHeader className="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)}>
<SelectTrigger className="h-7 w-28 text-xs">
<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)
# 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("/api/health", tags=["health"])
async def health_check() -> dict:
"""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.
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
"/health",
"/api/health",
"/docs",
"/redoc",
"/openapi.json",
@@ -223,17 +223,28 @@ class AppearanceResponse(BaseModel):
"""Effective system-wide appearance defaults."""
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):
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)
async def get_appearance_settings() -> AppearanceResponse:
"""Return the global default theme. Any logged-in user may read this."""
settings = load_system_settings().appearance
return AppearanceResponse(default_theme=settings.default_theme)
"""Return the global appearance defaults. Any logged-in user may read this."""
return _appearance_response(load_system_settings().appearance)
@router.put("/appearance", response_model=AppearanceResponse)
@@ -246,8 +257,9 @@ async def update_appearance_settings(
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
update: dict[str, object] = {}
if "default_theme" in fields and fields["default_theme"] is not None:
update["default_theme"] = fields["default_theme"]
for key in ("default_theme", "default_skill_view", "default_agent_view"):
if key in fields and fields[key] is not None:
update[key] = fields[key]
if update:
updated_appearance = current.appearance.model_copy(update=update)
saved = save_system_settings(
@@ -255,7 +267,7 @@ async def update_appearance_settings(
)
else:
saved = current
return AppearanceResponse(default_theme=saved.appearance.default_theme)
return _appearance_response(saved.appearance)
class LogSystemResponse(BaseModel):
@@ -51,6 +51,14 @@ class AppearanceSettings(BaseModel):
default="light",
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")