Merge branch 'ji' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
This commit is contained in:
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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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: "日志系统"
|
||||
+35
-7
@@ -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")
|
||||
|
||||
|
||||
|
||||
+131
-33
@@ -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}")
|
||||
|
||||
|
||||
+276
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 一刷拉清。
|
||||
|
||||
Reference in new issue
Block a user