This commit is contained in:
董宏凯 committed 2026-06-05 19:56:51 +08:00
commit 7b6dd9ba6f
23 files changed
+1888 -572

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>
);
}
@@ -4,9 +4,10 @@ import {
BellIcon,
BotIcon,
BrainIcon,
CalendarClockIcon,
ChevronsUpDown,
GaugeIcon,
CalendarClockIcon,
ChevronsUpDown,
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
MessageSquareCodeIcon,
MessageSquarePlusIcon,
@@ -198,6 +199,16 @@ export function WorkspaceNavMenu() {
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/checkpoint-migration")
}
>
<DatabaseZapIcon />
Checkpoint 迁移
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin")}
+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,
@@ -0,0 +1,257 @@
"use client";
import { DatabaseZap, Loader2Icon, RefreshCwIcon } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
interface CheckpointMigrationSources {
source_paths: string[];
}
interface CheckpointMigrationTask {
task_id: string;
status: "queued" | "running" | "completed" | "failed";
start_at: string;
end_at: string;
source_paths: string[];
total_threads: number;
copied_threads: number;
skipped_threads: number;
failed_threads: number;
skipped_ranges: number;
message: string;
error?: string | null;
created_at: string;
updated_at: string;
}
async function loadSources(): Promise<CheckpointMigrationSources> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/checkpoint-migration/sources`,
);
if (!response.ok) throw new Error("加载 SQLite 来源失败");
return (await response.json()) as CheckpointMigrationSources;
}
async function startMigration(body: {
start_at: string;
end_at: string;
}): Promise<CheckpointMigrationTask> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/checkpoint-migration/tasks`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: "启动迁移失败" }));
throw new Error(detail.detail ?? "启动迁移失败");
}
return (await response.json()) as CheckpointMigrationTask;
}
async function loadTask(taskId: string): Promise<CheckpointMigrationTask> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/checkpoint-migration/tasks/${taskId}`,
);
if (!response.ok) throw new Error("加载迁移任务失败");
return (await response.json()) as CheckpointMigrationTask;
}
function toLocalInputValue(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function statusVariant(status?: string) {
if (status === "failed") return "destructive" as const;
if (status === "completed") return "default" as const;
if (status === "queued" || status === "running") return "secondary" as const;
return "outline" as const;
}
export default function CheckpointMigrationPage() {
const now = useMemo(() => new Date(), []);
const weekAgo = useMemo(
() => new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
[now],
);
const [startAt, setStartAt] = useState(toLocalInputValue(weekAgo));
const [endAt, setEndAt] = useState(toLocalInputValue(now));
const [taskId, setTaskId] = useState<string | null>(null);
const { data: sources, isLoading: sourcesLoading, refetch } = useQuery({
queryKey: ["checkpointMigration", "sources"],
queryFn: loadSources,
staleTime: 60_000,
refetchOnWindowFocus: false,
});
const startMutation = useMutation({ mutationFn: startMigration });
const { data: task } = useQuery({
queryKey: ["checkpointMigration", "task", taskId],
queryFn: () => loadTask(taskId as string),
enabled: !!taskId,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "queued" || status === "running" ? 2000 : false;
},
refetchOnWindowFocus: false,
});
const running =
startMutation.isPending ||
task?.status === "queued" ||
task?.status === "running";
async function handleStart() {
const start = new Date(startAt);
const end = new Date(endAt);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
toast.error("请选择有效的开始和结束时间");
return;
}
if (end <= start) {
toast.error("结束时间必须晚于开始时间");
return;
}
try {
const created = await startMutation.mutateAsync({
start_at: start.toISOString(),
end_at: end.toISOString(),
});
setTaskId(created.task_id);
toast.success("迁移任务已启动");
} catch (err) {
toast.error(err instanceof Error ? err.message : "启动迁移失败");
}
}
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-10">
<div>
<h1 className="text-xl font-semibold">Checkpoint 迁移</h1>
<p className="text-muted-foreground mt-1 text-sm">
按时间段把 SQLite 旧聊天 checkpoint 同步到 Postgres。同一来源、同一时间段完成后再次执行会跳过。
</p>
</div>
<section className="rounded-lg border bg-white p-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<DatabaseZap className="size-4" />
SQLite 来源
</div>
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={sourcesLoading}
title="刷新"
>
<RefreshCwIcon className="size-4" />
</Button>
</div>
{sourcesLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
加载中
</div>
) : (sources?.source_paths.length ?? 0) > 0 ? (
<div className="space-y-2">
{sources?.source_paths.map((path) => (
<div
key={path}
className="rounded border bg-muted/40 px-3 py-2 font-mono text-xs break-all"
>
{path}
</div>
))}
</div>
) : (
<div className="text-muted-foreground rounded border border-dashed px-3 py-6 text-center text-sm">
未配置 SQLite 来源路径
</div>
)}
</section>
<section className="rounded-lg border bg-white p-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">开始时间</span>
<Input
type="datetime-local"
value={startAt}
onChange={(e) => setStartAt(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">结束时间</span>
<Input
type="datetime-local"
value={endAt}
onChange={(e) => setEndAt(e.target.value)}
/>
</label>
</div>
<div className="mt-5 flex justify-end">
<Button
onClick={handleStart}
disabled={running || !(sources?.source_paths.length ?? 0)}
>
{running && <Loader2Icon className="size-4 animate-spin" />}
开始同步
</Button>
</div>
</section>
{task && (
<section className="rounded-lg border bg-white p-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-center justify-between">
<div className="text-sm font-medium">任务状态</div>
<Badge variant={statusVariant(task.status)}>{task.status}</Badge>
</div>
<div className="grid gap-3 text-sm sm:grid-cols-4">
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">总线程</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.total_threads}
</div>
</div>
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">已复制</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.copied_threads}
</div>
</div>
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">已跳过</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.skipped_threads}
</div>
</div>
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">跳过时间段</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.skipped_ranges}
</div>
</div>
</div>
{(task.message || task.error) && (
<div className="mt-4 rounded border bg-muted/40 px-3 py-2 text-sm">
{task.error || task.message}
</div>
)}
</section>
)}
</div>
);
}
+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 />
@@ -25,6 +25,7 @@ import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import LlmMetricsPage from './LlmMetricsPage';
import CheckpointMigrationPage from './CheckpointMigrationPage';
import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
@@ -476,6 +477,18 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="settings/checkpoint-migration"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<CheckpointMigrationPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</RouteBaseProvider>
);
@@ -30,8 +30,9 @@ from app.gateway.routers import (
artifacts,
assistants_compat,
auth,
channels,
feedback,
channels,
checkpoint_migration,
feedback,
html_page_favorites,
intent,
llm_metrics,
@@ -906,7 +907,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(tool_metrics.router)
# Channels API is mounted at /api/channels
app.include_router(channels.router)
app.include_router(channels.router)
# Admin checkpoint migration API: SQLite legacy checkpoints -> Postgres.
app.include_router(checkpoint_migration.router)
# Assistants compatibility API (LangGraph Platform stub)
app.include_router(assistants_compat.router)
@@ -946,7 +950,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",
@@ -0,0 +1,276 @@
"""Admin checkpoint migration endpoints.
These endpoints support the SQLite -> Postgres transition for LangGraph
checkpoints. The live checkpointer should be Postgres with SQLite dual-read
enabled; this router only starts manual, bounded migrations for selected time
ranges.
"""
from __future__ import annotations
import asyncio
import contextlib
import hashlib
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.auth.disabled_mode import is_auth_disabled
from app.gateway.deps import get_current_user_from_request
from deerflow.config.runtime_paths import runtime_home
from deerflow.runtime.checkpointer.checkpoint_migration import (
SQLiteCheckpointSource,
copy_thread_state,
sqlite_recent_threads,
target_has_thread,
)
from deerflow.runtime.store._sqlite_utils import resolve_sqlite_conn_str
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/checkpoint-migration", tags=["checkpoint-migration"])
_TASKS: dict[str, dict[str, Any]] = {}
class MigrationStartRequest(BaseModel):
start_at: str = Field(description="Inclusive start datetime, ISO string.")
end_at: str = Field(description="Inclusive end datetime, ISO string.")
source_paths: list[str] | None = Field(default=None, description="Optional SQLite sources. Defaults to configured dual-read paths.")
class MigrationTaskResponse(BaseModel):
task_id: str
status: str
start_at: str
end_at: str
source_paths: list[str]
total_threads: int = 0
copied_threads: int = 0
skipped_threads: int = 0
failed_threads: int = 0
skipped_ranges: int = 0
message: str = ""
error: str | None = None
created_at: str
updated_at: str
async def _require_admin(request: Request) -> None:
if is_auth_disabled():
return
user = await get_current_user_from_request(request)
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="Checkpoint migration requires admin role")
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_dt(value: str) -> datetime:
try:
normalized = value.replace("Z", "+00:00")
dt = datetime.fromisoformat(normalized)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Invalid datetime: {value}") from exc
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def _sqlite_datetime(dt: datetime) -> str:
# Existing SQLite rows store naive wall-clock strings like
# "2026-05-18 06:41:10.989427"; match that format for comparisons.
return dt.astimezone(timezone.utc).replace(tzinfo=None).isoformat(sep=" ")
def _configured_sources(request: Request) -> list[str]:
config = getattr(request.app.state, "config", None)
cp = getattr(config, "checkpointer", None)
paths = list(getattr(cp, "dual_read_sqlite_paths", []) or [])
raw_env = os.getenv("DEER_FLOW_CHECKPOINT_SQLITE_PATHS", "")
paths.extend(part.strip() for part in raw_env.split(",") if part.strip())
resolved: list[str] = []
seen: set[str] = set()
for path in paths:
conn_str = resolve_sqlite_conn_str(path)
if conn_str == ":memory:" or conn_str.startswith("file:"):
continue
if conn_str not in seen:
resolved.append(conn_str)
seen.add(conn_str)
return resolved
def _target_checkpointer(request: Request):
checkpointer = getattr(request.app.state, "checkpointer", None)
if checkpointer is None:
raise HTTPException(status_code=503, detail="Checkpointer is not available")
return getattr(checkpointer, "primary", checkpointer)
def _state_path() -> Path:
return runtime_home() / "checkpoint_migration_state.json"
def _load_completed_ranges() -> set[str]:
try:
payload = json.loads(_state_path().read_text(encoding="utf-8"))
except FileNotFoundError:
return set()
except Exception:
logger.warning("Ignoring unreadable checkpoint migration state file", exc_info=True)
return set()
return {str(item) for item in payload.get("completed_ranges", [])}
def _save_completed_ranges(items: set[str]) -> None:
path = _state_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps({"completed_ranges": sorted(items)}, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
def _range_key(source: str, start_at: str, end_at: str) -> str:
raw = json.dumps({"source": str(Path(source).resolve()), "start_at": start_at, "end_at": end_at}, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def _open_sqlite_source(path: str) -> SQLiteCheckpointSource:
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
ctx = AsyncSqliteSaver.from_conn_string(path)
saver = await ctx.__aenter__()
await saver.setup()
return SQLiteCheckpointSource(
path=path,
saver=saver,
context=ctx,
progress_path=Path(path).with_suffix(Path(path).suffix + ".pg-migration.json"),
)
def _task_response(task: dict[str, Any]) -> MigrationTaskResponse:
return MigrationTaskResponse(**task)
async def _run_task(task_id: str, target, start_at: datetime, end_at: datetime) -> None:
task = _TASKS[task_id]
completed = await asyncio.to_thread(_load_completed_ranges)
start_sql = _sqlite_datetime(start_at)
end_sql = _sqlite_datetime(end_at)
range_start = start_at.isoformat()
range_end = end_at.isoformat()
try:
task["status"] = "running"
task["updated_at"] = _now_iso()
for source_path in task["source_paths"]:
if not Path(source_path).exists():
task["message"] = f"SQLite source not found: {source_path}"
task["skipped_ranges"] += 1
continue
key = _range_key(source_path, range_start, range_end)
if key in completed:
task["skipped_ranges"] += 1
task["message"] = "Selected range was already migrated; skipped"
task["updated_at"] = _now_iso()
continue
thread_ids = await asyncio.to_thread(sqlite_recent_threads, source_path, since=start_sql, until=end_sql)
if not thread_ids:
task["message"] = f"No threads found in selected range for {Path(source_path).name}"
completed.add(key)
await asyncio.to_thread(_save_completed_ranges, completed)
task["updated_at"] = _now_iso()
continue
task["total_threads"] += len(thread_ids)
source = await _open_sqlite_source(source_path)
try:
for thread_id in thread_ids:
if await target_has_thread(target, thread_id):
task["skipped_threads"] += 1
else:
copied = await copy_thread_state(source.saver, target, thread_id)
if copied:
task["copied_threads"] += 1
else:
task["skipped_threads"] += 1
task["updated_at"] = _now_iso()
await asyncio.sleep(0)
finally:
with contextlib.suppress(Exception):
await source.close()
completed.add(key)
await asyncio.to_thread(_save_completed_ranges, completed)
task["status"] = "completed"
task["message"] = task["message"] or "Migration completed"
except Exception as exc:
logger.exception("Checkpoint migration task failed: %s", task_id)
task["status"] = "failed"
task["error"] = str(exc)
finally:
task["updated_at"] = _now_iso()
@router.post("/tasks", response_model=MigrationTaskResponse)
async def start_migration(body: MigrationStartRequest, request: Request):
await _require_admin(request)
start_at = _parse_dt(body.start_at)
end_at = _parse_dt(body.end_at)
if end_at <= start_at:
raise HTTPException(status_code=400, detail="end_at must be later than start_at")
sources = [resolve_sqlite_conn_str(p) for p in (body.source_paths or _configured_sources(request))]
sources = [p for p in dict.fromkeys(sources) if p != ":memory:" and not p.startswith("file:")]
if not sources:
raise HTTPException(status_code=400, detail="No SQLite source paths configured")
target = _target_checkpointer(request)
task_id = hashlib.sha256(f"{_now_iso()}:{sources}:{body.start_at}:{body.end_at}".encode("utf-8")).hexdigest()[:16]
now = _now_iso()
_TASKS[task_id] = {
"task_id": task_id,
"status": "queued",
"start_at": start_at.isoformat(),
"end_at": end_at.isoformat(),
"source_paths": sources,
"total_threads": 0,
"copied_threads": 0,
"skipped_threads": 0,
"failed_threads": 0,
"skipped_ranges": 0,
"message": "",
"error": None,
"created_at": now,
"updated_at": now,
}
asyncio.create_task(_run_task(task_id, target, start_at, end_at))
return _task_response(_TASKS[task_id])
@router.get("/tasks/{task_id}", response_model=MigrationTaskResponse)
async def get_migration_task(task_id: str, request: Request):
await _require_admin(request)
task = _TASKS.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Migration task not found")
return _task_response(task)
@router.get("/sources")
async def list_sources(request: Request):
await _require_admin(request)
return {"source_paths": _configured_sources(request)}
@@ -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):
+11 -3
View File
@@ -218,8 +218,16 @@ database:
echo_sql: false
checkpointer:
type: sqlite
connection_string: .deer-flow/data/deerflow.db
type: postgres
# true: 新 checkpoint 写入 Postgres;false: 保留 PG 配置但继续使用 SQLite。
# 也可临时用环境变量 DEER_FLOW_USE_PG_CHECKPOINTER=0 覆盖。
use_postgres: true
connection_string: postgresql://postgres:postgres123@47.88.25.99:8432/deerflow
sqlite_connection_string: .deer-flow/data/deerflow.db
dual_read_enabled: true
dual_read_sqlite_paths:
- .deer-flow/data/deerflow.db
- ../data/.deer-flow/data/checkpoints.db
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
# 三层覆盖优先级(高->低):
@@ -300,4 +308,4 @@ ai_writing:
log_system:
enabled: true
url: "https://your-log-system.example.com"
label: "日志系统"
label: "日志系统"
@@ -2,7 +2,7 @@
from typing import Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field
CheckpointerType = Literal["memory", "sqlite", "postgres"]
@@ -16,13 +16,41 @@ class CheckpointerConfig(BaseModel):
"'sqlite' persists to a local file (requires langgraph-checkpoint-sqlite). "
"'postgres' persists to PostgreSQL (requires langgraph-checkpoint-postgres)."
)
connection_string: str | None = Field(
default=None,
description="Connection string for sqlite (file path) or postgres (DSN). "
connection_string: str | None = Field(
default=None,
description="Connection string for sqlite (file path) or postgres (DSN). "
"Required for sqlite and postgres types. "
"For sqlite, use a file path like '.deer-flow/checkpoints.db' or ':memory:' for in-memory. "
"For postgres, use a DSN like 'postgresql://user:pass@localhost:5432/db'.",
)
"For sqlite, use a file path like '.deer-flow/checkpoints.db' or ':memory:' for in-memory. "
"For postgres, use a DSN like 'postgresql://user:pass@localhost:5432/db'.",
)
use_postgres: bool = Field(
default=True,
description=(
"When type='postgres', set false to keep the Postgres DSN in config "
"but run the checkpointer on SQLite instead."
),
)
sqlite_connection_string: str | None = Field(
default=None,
description=(
"SQLite file used when type='postgres' and use_postgres=false. "
"Defaults to the first dual_read_sqlite_paths entry or 'store.db'."
),
)
dual_read_enabled: bool = Field(
default=False,
description=(
"When type='postgres', read from Postgres first and fall back to legacy "
"SQLite checkpoint files when Postgres does not have the requested data."
),
)
dual_read_sqlite_paths: list[str] = Field(
default_factory=list,
description=(
"Legacy SQLite checkpoint database paths used for read fallback. "
"Plain paths are resolved the same way as SQLite connection_string."
),
)
# Global configuration instance — None means no checkpointer is configured.
@@ -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")
@@ -17,10 +17,12 @@ For sync usage see :mod:`deerflow.runtime.checkpointer.provider`.
from __future__ import annotations
import asyncio
import contextlib
import logging
from collections.abc import AsyncIterator
import asyncio
import contextlib
import logging
from collections.abc import AsyncIterator
import os
from pathlib import Path
from langgraph.types import Checkpointer
@@ -30,13 +32,70 @@ from deerflow.runtime.checkpointer.provider import (
POSTGRES_INSTALL,
SQLITE_INSTALL,
)
from deerflow.runtime.store._sqlite_utils import ensure_sqlite_parent_dir, resolve_sqlite_conn_str
from deerflow.runtime.store._sqlite_utils import ensure_sqlite_parent_dir, resolve_sqlite_conn_str
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Async factory
# ---------------------------------------------------------------------------
logger = logging.getLogger(__name__)
def _csv_env(name: str) -> list[str]:
raw = os.getenv(name, "")
return [part.strip() for part in raw.split(",") if part.strip()]
def _dual_read_enabled(config) -> bool:
if getattr(config, "dual_read_enabled", False):
return True
return os.getenv("DEER_FLOW_CHECKPOINT_DUAL_READ", "").strip().lower() in {"1", "true", "yes", "on"}
def _use_postgres_enabled(config) -> bool:
raw = os.getenv("DEER_FLOW_USE_PG_CHECKPOINTER")
if raw:
return raw.strip().lower() not in {"0", "false", "no", "off"}
return bool(getattr(config, "use_postgres", True))
def _dual_read_paths(config) -> list[str]:
paths = list(getattr(config, "dual_read_sqlite_paths", []) or [])
paths.extend(_csv_env("DEER_FLOW_CHECKPOINT_SQLITE_PATHS"))
resolved: list[str] = []
seen: set[str] = set()
for path in paths:
conn_str = resolve_sqlite_conn_str(path)
if conn_str == ":memory:" or conn_str.startswith("file:"):
logger.warning("Skipping unsupported checkpoint migration SQLite path: %s", path)
continue
if conn_str not in seen:
resolved.append(conn_str)
seen.add(conn_str)
return resolved
def _sqlite_conn_str_for_postgres_fallback(config) -> str:
configured = getattr(config, "sqlite_connection_string", None)
if configured:
return configured
paths = _dual_read_paths(config)
return paths[0] if paths else "store.db"
@contextlib.asynccontextmanager
async def _sqlite_checkpointer_from_conn_str(raw: str) -> AsyncIterator[Checkpointer]:
try:
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
except ImportError as exc:
raise ImportError(SQLITE_INSTALL) from exc
conn_str = resolve_sqlite_conn_str(raw)
await asyncio.to_thread(ensure_sqlite_parent_dir, conn_str)
async with AsyncSqliteSaver.from_conn_string(conn_str) as saver:
await saver.setup()
yield saver
# ---------------------------------------------------------------------------
# Async factory
# ---------------------------------------------------------------------------
@contextlib.asynccontextmanager
@@ -48,32 +107,71 @@ async def _async_checkpointer(config) -> AsyncIterator[Checkpointer]:
yield InMemorySaver()
return
if config.type == "sqlite":
try:
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
except ImportError as exc:
raise ImportError(SQLITE_INSTALL) from exc
conn_str = resolve_sqlite_conn_str(config.connection_string or "store.db")
await asyncio.to_thread(ensure_sqlite_parent_dir, conn_str)
async with AsyncSqliteSaver.from_conn_string(conn_str) as saver:
await saver.setup()
yield saver
return
if config.type == "postgres":
try:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
if config.type == "sqlite":
async with _sqlite_checkpointer_from_conn_str(config.connection_string or "store.db") as saver:
yield saver
return
if config.type == "postgres":
if not _use_postgres_enabled(config):
conn_str = _sqlite_conn_str_for_postgres_fallback(config)
logger.info("Checkpointer: use_postgres=false; using SQLite fallback (%s)", conn_str)
async with _sqlite_checkpointer_from_conn_str(conn_str) as saver:
yield saver
return
try:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
except ImportError as exc:
raise ImportError(POSTGRES_INSTALL) from exc
if not config.connection_string:
raise ValueError(POSTGRES_CONN_REQUIRED)
async with AsyncPostgresSaver.from_conn_string(config.connection_string) as saver:
await saver.setup()
yield saver
return
if not config.connection_string:
raise ValueError(POSTGRES_CONN_REQUIRED)
async with AsyncPostgresSaver.from_conn_string(config.connection_string) as saver:
await saver.setup()
if not _dual_read_enabled(config):
yield saver
return
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from deerflow.runtime.checkpointer.checkpoint_migration import (
DualReadCheckpointer,
SQLiteCheckpointSource,
)
sources: list[SQLiteCheckpointSource] = []
for sqlite_path in _dual_read_paths(config):
if not Path(sqlite_path).exists():
logger.info("Checkpoint dual-read source does not exist, skipping: %s", sqlite_path)
continue
ctx = AsyncSqliteSaver.from_conn_string(sqlite_path)
source_saver = await ctx.__aenter__()
await source_saver.setup()
sources.append(
SQLiteCheckpointSource(
path=sqlite_path,
saver=source_saver,
context=ctx,
progress_path=Path(sqlite_path).with_suffix(Path(sqlite_path).suffix + ".pg-migration.json"),
)
)
if not sources:
logger.info("Checkpoint dual-read enabled but no SQLite sources were opened; using Postgres only")
yield saver
return
proxy = DualReadCheckpointer(
saver,
sources,
)
try:
yield proxy
finally:
await proxy.aclose_sources()
return
raise ValueError(f"Unknown checkpointer type: {config.type!r}")
@@ -0,0 +1,276 @@
"""SQLite-to-Postgres checkpoint migration and read fallback helpers.
The live gateway can wrap a Postgres checkpointer with ``DualReadCheckpointer``.
New writes go to Postgres, reads prefer Postgres and fall back to legacy SQLite.
Manual migration scripts reuse the copy helpers in this module.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
@dataclass
class SQLiteCheckpointSource:
path: str
saver: Any
context: Any
progress_path: Path
closed: bool = False
async def close(self) -> None:
if self.closed:
return
self.closed = True
try:
await self.context.__aexit__(None, None, None)
except Exception:
logger.warning("Failed to close legacy SQLite checkpointer: %s", self.path, exc_info=True)
def _sqlite_db_exists(path: str) -> bool:
return Path(path).exists()
def _sqlite_threads(path: str) -> list[str]:
with sqlite3.connect(path) as conn:
rows = conn.execute("SELECT DISTINCT thread_id FROM checkpoints ORDER BY thread_id").fetchall()
return [str(row[0]) for row in rows if row and row[0]]
def _sqlite_has_table(path: str, table: str) -> bool:
with sqlite3.connect(path) as conn:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
).fetchone()
return row is not None
def sqlite_recent_threads(path: str, *, since: str | None = None, until: str | None = None) -> list[str]:
"""Return threads updated since an ISO timestamp when ``threads_meta`` exists."""
if not _sqlite_has_table(path, "threads_meta"):
return []
query = "SELECT thread_id FROM threads_meta"
where: list[str] = []
params: list[str] = []
if since:
where.append("updated_at >= ?")
params.append(since)
if until:
where.append("updated_at <= ?")
params.append(until)
if where:
query += " WHERE " + " AND ".join(where)
query += " ORDER BY updated_at DESC"
with sqlite3.connect(path) as conn:
rows = conn.execute(query, tuple(params)).fetchall()
return [str(row[0]) for row in rows if row and row[0]]
def _sqlite_thread_checkpoint_count(path: str, thread_id: str) -> int:
with sqlite3.connect(path) as conn:
row = conn.execute(
"SELECT COUNT(*) FROM checkpoints WHERE thread_id = ?",
(thread_id,),
).fetchone()
return int(row[0] if row else 0)
def _load_progress(path: Path) -> set[str]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return set()
except Exception:
logger.warning("Ignoring unreadable checkpoint migration progress file: %s", path, exc_info=True)
return set()
migrated = payload.get("migrated_threads", [])
return {str(x) for x in migrated if x}
def _save_progress(path: Path, migrated: set[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(
json.dumps({"migrated_threads": sorted(migrated)}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
tmp.replace(path)
async def _copy_thread_state(source, target, thread_id: str) -> bool:
"""Copy one thread through the public LangGraph checkpointer API."""
config = {"configurable": {"thread_id": thread_id}}
tuples = [t async for t in source.alist(config)]
if not tuples:
return False
for tup in reversed(tuples):
configurable = (tup.config or {}).get("configurable", {}) or {}
checkpoint_ns = configurable.get("checkpoint_ns", "") or ""
parent_id = ((tup.parent_config or {}).get("configurable", {}) or {}).get("checkpoint_id")
put_config: dict[str, Any] = {"configurable": {"thread_id": thread_id, "checkpoint_ns": checkpoint_ns}}
if parent_id:
put_config["configurable"]["checkpoint_id"] = parent_id
new_versions = (tup.checkpoint or {}).get("channel_versions", {}) or {}
await target.aput(put_config, tup.checkpoint, tup.metadata or {}, new_versions)
if tup.pending_writes:
by_task: dict[str, list] = {}
for task_id, channel, value in tup.pending_writes:
by_task.setdefault(task_id, []).append((channel, value))
checkpoint_id = (tup.checkpoint or {}).get("id")
for task_id, writes in by_task.items():
writes_config = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": checkpoint_ns,
"checkpoint_id": checkpoint_id,
}
}
await target.aput_writes(writes_config, writes, task_id)
return True
async def copy_thread_state(source, target, thread_id: str) -> bool:
return await _copy_thread_state(source, target, thread_id)
async def _target_thread_checkpoint_count(target, thread_id: str) -> int:
config = {"configurable": {"thread_id": thread_id}}
count = 0
async for _ in target.alist(config):
count += 1
return count
async def target_has_thread(target, thread_id: str) -> bool:
config = {"configurable": {"thread_id": thread_id}}
async for _ in target.alist(config, limit=1):
return True
return False
class DualReadCheckpointer:
"""Postgres-first checkpointer with SQLite read fallback."""
def __init__(self, primary, sources: list[SQLiteCheckpointSource], *, sleep_seconds: float = 0.05):
self.primary = primary
self.sources = sources
self.sleep_seconds = max(0.0, sleep_seconds)
self._closed = False
def __getattr__(self, name: str):
return getattr(self.primary, name)
async def aclose_sources(self) -> None:
self._closed = True
for source in self.sources:
await source.close()
async def aget_tuple(self, config):
found = await self.primary.aget_tuple(config)
if found is not None:
return found
for source in self.sources:
if source.closed:
continue
found = await source.saver.aget_tuple(config)
if found is not None:
return found
return None
async def aget(self, config):
found = await self.primary.aget(config)
if found is not None:
return found
for source in self.sources:
if source.closed:
continue
found = await source.saver.aget(config)
if found is not None:
return found
return None
async def alist(self, config, *, filter=None, before=None, limit=None):
seen: set[tuple[str, str, str]] = set()
yielded = 0
async for tup in self.primary.alist(config, filter=filter, before=before, limit=limit):
key = self._tuple_key(tup)
seen.add(key)
yield tup
yielded += 1
if limit is not None and yielded >= limit:
return
remaining = None if limit is None else max(0, limit - yielded)
for source in self.sources:
if source.closed:
continue
async for tup in source.saver.alist(config, filter=filter, before=before, limit=remaining):
key = self._tuple_key(tup)
if key in seen:
continue
seen.add(key)
yield tup
yielded += 1
if limit is not None:
remaining = max(0, limit - yielded)
if remaining <= 0:
return
async def aput(self, *args, **kwargs):
return await self.primary.aput(*args, **kwargs)
async def aput_writes(self, *args, **kwargs):
return await self.primary.aput_writes(*args, **kwargs)
async def adelete_thread(self, thread_id: str) -> None:
delete = getattr(self.primary, "adelete_thread", None)
if delete is not None:
await delete(thread_id)
for source in self.sources:
if source.closed:
continue
delete = getattr(source.saver, "adelete_thread", None)
if delete is not None:
await delete(thread_id)
@staticmethod
def _tuple_key(tup) -> tuple[str, str, str]:
configurable = (tup.config or {}).get("configurable", {}) or {}
return (
str(configurable.get("thread_id", "")),
str(configurable.get("checkpoint_ns", "") or ""),
str(configurable.get("checkpoint_id", "") or (tup.checkpoint or {}).get("id", "")),
)
async def _verify_source(self, source: SQLiteCheckpointSource, threads: list[str]) -> bool:
for thread_id in threads:
source_count = await asyncio.to_thread(_sqlite_thread_checkpoint_count, source.path, thread_id)
target_count = await _target_thread_checkpoint_count(self.primary, thread_id)
if target_count < source_count:
logger.warning(
"Checkpoint migration verification failed: source=%s thread_id=%s source_count=%d target_count=%d",
source.path,
thread_id,
source_count,
target_count,
)
return False
return True
@@ -31,9 +31,12 @@ dependencies = [
"ddgs>=9.10.0",
"duckdb>=1.4.4",
"langchain-google-genai>=4.2.1",
"langgraph-checkpoint-sqlite>=3.0.3",
"langgraph-sdk>=0.1.51",
"sqlalchemy[asyncio]>=2.0,<3.0",
"langgraph-checkpoint-sqlite>=3.0.3",
"langgraph-checkpoint-postgres>=3.0.5",
"langgraph-sdk>=0.1.51",
"psycopg[binary]>=3.3.3",
"psycopg-pool>=3.3.0",
"sqlalchemy[asyncio]>=2.0,<3.0",
"aiosqlite>=0.19",
"alembic>=1.13",
]
+20 -2
View File
@@ -67,6 +67,16 @@ models:
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: Qwen/Qwen3.6-27B
display_name: Qwen/Qwen3.6-27B
use: langchain_openai:ChatOpenAI
model: Qwen/Qwen3.6-27B
api_key: sk-qwwwvjompnzxqhndctlwmmomvqdgaqqqgxvibfxchsikvgod
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
# 识图(图片识别)配置。
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
@@ -187,8 +197,16 @@ checkpointer:
# Docker 部署:把容器内 .deer-flow 目录挂卷出来即可持久化(撑住容器重启)。
# 这里同时被「主 agent」和「AI 写作 graph」共用 —— LangGraph 用
# (thread_id, checkpoint_ns) 做隔离,session_id 是 uuid,不会撞。
type: sqlite
connection_string: .deer-flow/data/checkpoints.db
type: postgres
# true: 新 checkpoint 写入 Postgres;false: 保留 PG 配置但继续使用 SQLite。
# 也可临时用环境变量 DEER_FLOW_USE_PG_CHECKPOINTER=0 覆盖。
use_postgres: true
connection_string: postgresql://postgres:postgres123@47.88.25.99:8432/deerflow
sqlite_connection_string: backend/.deer-flow/data/deerflow.db
dual_read_enabled: true
dual_read_sqlite_paths:
- backend/.deer-flow/data/deerflow.db
- data/.deer-flow/data/checkpoints.db
# AI 写作子系统的会话清理配置。
# 业务表(ai_writing_sessions @ MySQL) + checkpointer(checkpoints.db @ SQLite) 联动清理。
# 留意:清理走 cron 表达式调度,默认每天凌晨 3 点跑一次,按 updated_at 一刷拉清。