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
+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>
);
}
+7
View File
@@ -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,
+18 -1
View File
@@ -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")