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
+414 -90

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,30 +438,10 @@ 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">
<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>
</div>
<div className="flex shrink-0 items-center gap-1">
{isBuiltin && ( {isBuiltin && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
内置 内置
@@ -510,33 +494,14 @@ export function AgentCard({ agent }: AgentCardProps) {
首页推荐 首页推荐
</Badge> </Badge>
)} )}
</div> </>
</div> );
{agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm">
{desensitize(agent.description)}
</CardDescription>
)}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && ( const skillBadgeItems = [
<CardContent className="pt-0 pb-2">
<TagChipList tags={agent.tags!} />
</CardContent>
)}
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-3">
<ClampedBadgeRow
items={[
...(agent.tool_groups ?? []).map((group) => ({ ...(agent.tool_groups ?? []).map((group) => ({
key: `tg:${group}`, key: `tg:${group}`,
node: ( node: (
<Badge <Badge key={`tg:${group}`} variant="outline" className="text-xs">
key={`tg:${group}`}
variant="outline"
className="text-xs"
>
{desensitize(group)} {desensitize(group)}
</Badge> </Badge>
), ),
@@ -558,20 +523,23 @@ export function AgentCard({ agent }: AgentCardProps) {
</Badge> </Badge>
), ),
})), })),
]} ];
/> const hasSkillsOrTools =
</CardContent> (agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0;
)}
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3"> const showActionRow =
{(canEdit || canDelete || isOwned || isAdmin || canFavorite) && ( canEdit || canDelete || isOwned || isAdmin || canFavorite;
<div className="flex flex-wrap items-center justify-end gap-1">
// The icon action buttons, shared by both layouts.
const actionButtons = (
<>
{canFavorite && ( {canFavorite && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className={cn( className={cn(
"mr-auto h-8 w-8 shrink-0", "h-8 w-8 shrink-0",
!isList && "mr-auto",
isFavorite && "text-amber-500 hover:text-amber-600", isFavorite && "text-amber-500 hover:text-amber-600",
)} )}
onClick={() => void handleToggleFavorite()} onClick={() => void handleToggleFavorite()}
@@ -589,7 +557,7 @@ export function AgentCard({ agent }: AgentCardProps) {
className={cn( className={cn(
"h-8 w-8 shrink-0", "h-8 w-8 shrink-0",
isPinned && "text-emerald-600 hover:text-emerald-700", isPinned && "text-emerald-600 hover:text-emerald-700",
!canFavorite && "mr-auto", !isList && !canFavorite && "mr-auto",
)} )}
onClick={() => void handleTogglePin()} onClick={() => void handleTogglePin()}
disabled={togglePin.isPending} disabled={togglePin.isPending}
@@ -650,14 +618,11 @@ export function AgentCard({ agent }: AgentCardProps) {
variant="ghost" variant="ghost"
className={cn( className={cn(
"h-8 w-8 shrink-0", "h-8 w-8 shrink-0",
agent.is_private && agent.is_private && "text-purple-600 hover:text-purple-700",
"text-purple-600 hover:text-purple-700",
)} )}
onClick={() => void handleTogglePrivate()} onClick={() => void handleTogglePrivate()}
disabled={updateAgent.isPending || setPrivate.isPending} disabled={updateAgent.isPending || setPrivate.isPending}
title={ title={agent.is_private ? "移出私密广场" : "放入私密广场"}
agent.is_private ? "移出私密广场" : "放入私密广场"
}
> >
<ShieldIcon className="h-3.5 w-3.5" /> <ShieldIcon className="h-3.5 w-3.5" />
</Button> </Button>
@@ -694,14 +659,107 @@ export function AgentCard({ agent }: AgentCardProps) {
<Trash2Icon className="h-3.5 w-3.5" /> <Trash2Icon className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
</div> </>
)} );
<Button size="sm" className="w-full" onClick={handleChat}>
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" /> <MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat} {t.agents.chat}
</Button> </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="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>
<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.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> </CardFooter>
</Card> </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}> <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,7 +127,14 @@ export function AgentGallery() {
setPrivatePassword(null); setPrivatePassword(null);
}; };
const renderGrid = (list: Agent[]) => ( 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"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((agent) => ( {list.map((agent) => (
<AgentCard key={agent.id} agent={agent} /> <AgentCard key={agent.id} agent={agent} />
@@ -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,6 +338,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
tagIds={tagIds} tagIds={tagIds}
onTagIdsChange={setTagIds} onTagIdsChange={setTagIds}
actions={ actions={
<div className="flex items-center gap-2">
<ViewModeToggle value={viewMode} onChange={setViewMode} />
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}> <Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
<SelectTrigger <SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]" className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
@@ -354,6 +359,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<SelectItem value="name_desc">名称 Z-A</SelectItem> <SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent> </SelectContent>
</Select> </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,16 +546,11 @@ export function SkillRow({
} }
} }
return ( const badgeGroup = (
<>
<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"> <div className="flex flex-wrap items-center gap-2">
<span>{desensitize(skill.name)}</span> <span className={cn(isCard && "truncate font-medium")}>
{desensitize(skill.name)}
</span>
{isBuiltin && ( {isBuiltin && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{t.settings.skills.badgeBuiltin} {t.settings.skills.badgeBuiltin}
@@ -615,10 +627,10 @@ export function SkillRow({
</Badge> </Badge>
)} )}
</div> </div>
</ItemTitle> );
<ItemDescription className="line-clamp-4">
{desensitize(skill.description ?? "")} const metaNodes = (
</ItemDescription> <>
{skill.use_count !== undefined && ( {skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs"> <div className="text-muted-foreground mt-1 text-xs">
{skill.use_count > 0 {skill.use_count > 0
@@ -632,8 +644,11 @@ export function SkillRow({
{(skill.tags?.length ?? 0) > 0 && ( {(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="mt-2" /> <TagChipList tags={skill.tags!} className="mt-2" />
)} )}
</ItemContent> </>
<ItemActions> );
const actionButtons = (
<>
{isAdmin && ( {isAdmin && (
<Button <Button
size="icon" size="icon"
@@ -728,8 +743,42 @@ export function SkillRow({
<Trash2Icon className="size-3.5" /> <Trash2Icon className="size-3.5" />
</Button> </Button>
)} )}
</ItemActions> </>
);
return (
<>
{isCard ? (
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div
className="min-w-0 flex-1 cursor-pointer"
onClick={() => setDetailOpen(true)}
>
{badgeGroup}
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
{desensitize(skill.description ?? "")}
</p>
{metaNodes}
</div>
<div className="mt-2 flex flex-wrap items-center justify-end gap-1 border-t pt-2 dark:border-[#213D5E]">
{actionButtons}
</div>
</div>
) : (
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
<ItemContent
className="cursor-pointer"
onClick={() => setDetailOpen(true)}
>
<ItemTitle>{badgeGroup}</ItemTitle>
<ItemDescription className="line-clamp-4">
{desensitize(skill.description ?? "")}
</ItemDescription>
{metaNodes}
</ItemContent>
<ItemActions>{actionButtons}</ItemActions>
</Item> </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,
@@ -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">
<div className="flex min-w-0 items-center gap-2">
<CardTitle>执行记录</CardTitle> <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")