Merge branch 'ji' into 'master'
Ji See merge request knowledge-base-bj/deerflow-server!95
This commit is contained in:
commit
99aa064e6c
25 files changed
+1997
-509
No files matched your search
@@ -70,6 +70,9 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
interface AgentCardProps {
|
interface AgentCardProps {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
|
/** "card" (default) renders the full gallery card; "list" renders a compact
|
||||||
|
* single-row layout for the list view. All dialogs/actions are shared. */
|
||||||
|
layout?: "card" | "list";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BadgeItem {
|
interface BadgeItem {
|
||||||
@@ -181,7 +184,8 @@ function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentCard({ agent }: AgentCardProps) {
|
export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||||
|
const isList = layout === "list";
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const deleteAgent = useDeleteAgent();
|
const deleteAgent = useDeleteAgent();
|
||||||
@@ -434,274 +438,328 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
|
|
||||||
const enabledSkills = skills.filter((skill) => skill.enabled);
|
const enabledSkills = skills.filter((skill) => skill.enabled);
|
||||||
|
|
||||||
return (
|
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
|
||||||
|
// both the card header and the compact list row.
|
||||||
|
const statusBadges = (
|
||||||
<>
|
<>
|
||||||
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
|
{isBuiltin && (
|
||||||
<CardHeader className="min-w-0 pb-3">
|
<Badge variant="secondary" className="text-xs">
|
||||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
内置
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
</Badge>
|
||||||
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
)}
|
||||||
<BotIcon className="h-5 w-5" />
|
{isOwned && (
|
||||||
</div>
|
<Badge
|
||||||
<div className="min-w-0 flex-1 overflow-hidden">
|
variant="default"
|
||||||
<CardTitle
|
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||||
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
|
>
|
||||||
title={desensitize(agent.name)}
|
我的
|
||||||
>
|
</Badge>
|
||||||
{desensitize(agent.name)}
|
)}
|
||||||
</CardTitle>
|
{isFromOthers && (
|
||||||
{agent.model && (
|
<Badge
|
||||||
<Badge variant="secondary" className="mt-0.5 text-xs">
|
variant="outline"
|
||||||
{desensitize(agent.model)}
|
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||||
</Badge>
|
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
|
||||||
)}
|
>
|
||||||
</div>
|
来自他人
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isOwned && agent.published && !agent.is_private && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||||
|
title="该智能体已对所有用户可见"
|
||||||
|
>
|
||||||
|
<GlobeIcon className="size-3" />
|
||||||
|
已发布
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{agent.is_private && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||||
|
title="该智能体位于私密广场,需要密码才能查看"
|
||||||
|
>
|
||||||
|
<ShieldIcon className="size-3" />
|
||||||
|
私密
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{agent.featured_order != null && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
|
||||||
|
title="已加入首页选择器"
|
||||||
|
>
|
||||||
|
<SparklesIcon className="size-3" />
|
||||||
|
首页推荐
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const skillBadgeItems = [
|
||||||
|
...(agent.tool_groups ?? []).map((group) => ({
|
||||||
|
key: `tg:${group}`,
|
||||||
|
node: (
|
||||||
|
<Badge key={`tg:${group}`} variant="outline" className="text-xs">
|
||||||
|
{desensitize(group)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
...(agent.skills ?? []).map((skill) => ({
|
||||||
|
key: `sk:${skill}`,
|
||||||
|
node: (
|
||||||
|
<Badge
|
||||||
|
key={`sk:${skill}`}
|
||||||
|
variant="secondary"
|
||||||
|
className="hover:bg-secondary/70 cursor-pointer text-xs"
|
||||||
|
title={t.settings.skills.viewDetailAction}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDetailSkillName(skill);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{desensitize(skill)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const hasSkillsOrTools =
|
||||||
|
(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
const showActionRow =
|
||||||
|
canEdit || canDelete || isOwned || isAdmin || canFavorite;
|
||||||
|
|
||||||
|
// The icon action buttons, shared by both layouts.
|
||||||
|
const actionButtons = (
|
||||||
|
<>
|
||||||
|
{canFavorite && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 shrink-0",
|
||||||
|
!isList && "mr-auto",
|
||||||
|
isFavorite && "text-amber-500 hover:text-amber-600",
|
||||||
|
)}
|
||||||
|
onClick={() => void handleToggleFavorite()}
|
||||||
|
disabled={toggleFavorite.isPending}
|
||||||
|
title={isFavorite ? "取消收藏" : "收藏到「我的」"}
|
||||||
|
>
|
||||||
|
<StarIcon
|
||||||
|
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 shrink-0",
|
||||||
|
isPinned && "text-emerald-600 hover:text-emerald-700",
|
||||||
|
!isList && !canFavorite && "mr-auto",
|
||||||
|
)}
|
||||||
|
onClick={() => void handleTogglePin()}
|
||||||
|
disabled={togglePin.isPending}
|
||||||
|
title={isPinned ? "取消置顶" : "置顶"}
|
||||||
|
>
|
||||||
|
<PinIcon className={cn("h-3.5 w-3.5", isPinned && "fill-current")} />
|
||||||
|
</Button>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 shrink-0",
|
||||||
|
agent.featured_order != null &&
|
||||||
|
"text-amber-600 hover:text-amber-700",
|
||||||
|
)}
|
||||||
|
onClick={() => void handleToggleFeatured()}
|
||||||
|
disabled={setFeatured.isPending}
|
||||||
|
title={
|
||||||
|
agent.featured_order != null
|
||||||
|
? "从首页选择器移除"
|
||||||
|
: "加入首页选择器"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SparklesIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={() => setTagPickerOpen(true)}
|
||||||
|
title={t.adminTags.manageTags}
|
||||||
|
>
|
||||||
|
<TagsIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isOwned && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={() => void handleTogglePublished()}
|
||||||
|
disabled={updateAgent.isPending}
|
||||||
|
title={agent.published ? "取消发布" : "发布"}
|
||||||
|
>
|
||||||
|
{agent.published ? (
|
||||||
|
<LockIcon className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<GlobeIcon className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(isOwned || isAdmin) && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 shrink-0",
|
||||||
|
agent.is_private && "text-purple-600 hover:text-purple-700",
|
||||||
|
)}
|
||||||
|
onClick={() => void handleTogglePrivate()}
|
||||||
|
disabled={updateAgent.isPending || setPrivate.isPending}
|
||||||
|
title={agent.is_private ? "移出私密广场" : "放入私密广场"}
|
||||||
|
>
|
||||||
|
<ShieldIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={() => void handleDuplicate()}
|
||||||
|
disabled={createAgent.isPending}
|
||||||
|
title="复制为新智能体"
|
||||||
|
>
|
||||||
|
<CopyIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
{canEdit && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
title="编辑智能体"
|
||||||
|
>
|
||||||
|
<SquarePenIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
title={t.agents.delete}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const chatButton = (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className={cn(isList ? "shrink-0" : "w-full")}
|
||||||
|
onClick={handleChat}
|
||||||
|
>
|
||||||
|
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{t.agents.chat}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardLayout = (
|
||||||
|
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
|
||||||
|
<CardHeader className="min-w-0 pb-2">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||||
|
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||||
|
<BotIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
{isBuiltin && (
|
<CardTitle
|
||||||
<Badge variant="secondary" className="text-xs">
|
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
|
||||||
内置
|
title={desensitize(agent.name)}
|
||||||
</Badge>
|
>
|
||||||
)}
|
{desensitize(agent.name)}
|
||||||
{isOwned && (
|
</CardTitle>
|
||||||
<Badge
|
{agent.model && (
|
||||||
variant="default"
|
<Badge variant="secondary" className="mt-0.5 text-xs">
|
||||||
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
{desensitize(agent.model)}
|
||||||
>
|
|
||||||
我的
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{isFromOthers && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
|
||||||
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
|
|
||||||
>
|
|
||||||
来自他人
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{isOwned && agent.published && !agent.is_private && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
|
||||||
title="该智能体已对所有用户可见"
|
|
||||||
>
|
|
||||||
<GlobeIcon className="size-3" />
|
|
||||||
已发布
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{agent.is_private && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
|
||||||
title="该智能体位于私密广场,需要密码才能查看"
|
|
||||||
>
|
|
||||||
<ShieldIcon className="size-3" />
|
|
||||||
私密
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{agent.featured_order != null && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
|
|
||||||
title="已加入首页选择器"
|
|
||||||
>
|
|
||||||
<SparklesIcon className="size-3" />
|
|
||||||
首页推荐
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{agent.description && (
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
|
||||||
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
{statusBadges}
|
||||||
{desensitize(agent.description)}
|
</div>
|
||||||
</CardDescription>
|
</div>
|
||||||
)}
|
{agent.description && (
|
||||||
</CardHeader>
|
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
||||||
|
{desensitize(agent.description)}
|
||||||
{(agent.tags?.length ?? 0) > 0 && (
|
</CardDescription>
|
||||||
<CardContent className="pt-0 pb-2">
|
|
||||||
<TagChipList tags={agent.tags!} />
|
|
||||||
</CardContent>
|
|
||||||
)}
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
|
{(agent.tags?.length ?? 0) > 0 && (
|
||||||
<CardContent className="pt-0 pb-3">
|
<CardContent className="pt-0 pb-2">
|
||||||
<ClampedBadgeRow
|
<TagChipList tags={agent.tags!} />
|
||||||
items={[
|
</CardContent>
|
||||||
...(agent.tool_groups ?? []).map((group) => ({
|
)}
|
||||||
key: `tg:${group}`,
|
|
||||||
node: (
|
{hasSkillsOrTools && (
|
||||||
<Badge
|
<CardContent className="pt-0 pb-2">
|
||||||
key={`tg:${group}`}
|
<ClampedBadgeRow items={skillBadgeItems} />
|
||||||
variant="outline"
|
</CardContent>
|
||||||
className="text-xs"
|
)}
|
||||||
>
|
|
||||||
{desensitize(group)}
|
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
|
||||||
</Badge>
|
{showActionRow && (
|
||||||
),
|
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||||
})),
|
{actionButtons}
|
||||||
...(agent.skills ?? []).map((skill) => ({
|
</div>
|
||||||
key: `sk:${skill}`,
|
|
||||||
node: (
|
|
||||||
<Badge
|
|
||||||
key={`sk:${skill}`}
|
|
||||||
variant="secondary"
|
|
||||||
className="hover:bg-secondary/70 cursor-pointer text-xs"
|
|
||||||
title={t.settings.skills.viewDetailAction}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setDetailSkillName(skill);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{desensitize(skill)}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
)}
|
||||||
|
{chatButton}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
|
const listLayout = (
|
||||||
{(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
|
<div className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||||
{canFavorite && (
|
<BotIcon className="h-5 w-5" />
|
||||||
<Button
|
</div>
|
||||||
size="icon"
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
variant="ghost"
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
className={cn(
|
<span
|
||||||
"mr-auto h-8 w-8 shrink-0",
|
className="min-w-0 truncate text-sm font-medium"
|
||||||
isFavorite && "text-amber-500 hover:text-amber-600",
|
title={desensitize(agent.name)}
|
||||||
)}
|
>
|
||||||
onClick={() => void handleToggleFavorite()}
|
{desensitize(agent.name)}
|
||||||
disabled={toggleFavorite.isPending}
|
</span>
|
||||||
title={isFavorite ? "取消收藏" : "收藏到「我的」"}
|
<div className="flex shrink-0 items-center gap-1">{statusBadges}</div>
|
||||||
>
|
</div>
|
||||||
<StarIcon
|
{agent.description && (
|
||||||
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
|
<p className="text-muted-foreground mt-0.5 truncate text-xs">
|
||||||
/>
|
{desensitize(agent.description)}
|
||||||
</Button>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button
|
</div>
|
||||||
size="icon"
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
variant="ghost"
|
{showActionRow && actionButtons}
|
||||||
className={cn(
|
{chatButton}
|
||||||
"h-8 w-8 shrink-0",
|
</div>
|
||||||
isPinned && "text-emerald-600 hover:text-emerald-700",
|
</div>
|
||||||
!canFavorite && "mr-auto",
|
);
|
||||||
)}
|
|
||||||
onClick={() => void handleTogglePin()}
|
return (
|
||||||
disabled={togglePin.isPending}
|
<>
|
||||||
title={isPinned ? "取消置顶" : "置顶"}
|
{isList ? listLayout : cardLayout}
|
||||||
>
|
|
||||||
<PinIcon className={cn("h-3.5 w-3.5", isPinned && "fill-current")} />
|
|
||||||
</Button>
|
|
||||||
{isAdmin && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-8 shrink-0",
|
|
||||||
agent.featured_order != null &&
|
|
||||||
"text-amber-600 hover:text-amber-700",
|
|
||||||
)}
|
|
||||||
onClick={() => void handleToggleFeatured()}
|
|
||||||
disabled={setFeatured.isPending}
|
|
||||||
title={
|
|
||||||
agent.featured_order != null
|
|
||||||
? "从首页选择器移除"
|
|
||||||
: "加入首页选择器"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SparklesIcon className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 shrink-0"
|
|
||||||
onClick={() => setTagPickerOpen(true)}
|
|
||||||
title={t.adminTags.manageTags}
|
|
||||||
>
|
|
||||||
<TagsIcon className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isOwned && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 shrink-0"
|
|
||||||
onClick={() => void handleTogglePublished()}
|
|
||||||
disabled={updateAgent.isPending}
|
|
||||||
title={agent.published ? "取消发布" : "发布"}
|
|
||||||
>
|
|
||||||
{agent.published ? (
|
|
||||||
<LockIcon className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<GlobeIcon className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{(isOwned || isAdmin) && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-8 shrink-0",
|
|
||||||
agent.is_private &&
|
|
||||||
"text-purple-600 hover:text-purple-700",
|
|
||||||
)}
|
|
||||||
onClick={() => void handleTogglePrivate()}
|
|
||||||
disabled={updateAgent.isPending || setPrivate.isPending}
|
|
||||||
title={
|
|
||||||
agent.is_private ? "移出私密广场" : "放入私密广场"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ShieldIcon className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 shrink-0"
|
|
||||||
onClick={() => void handleDuplicate()}
|
|
||||||
disabled={createAgent.isPending}
|
|
||||||
title="复制为新智能体"
|
|
||||||
>
|
|
||||||
<CopyIcon className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
{canEdit && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 shrink-0"
|
|
||||||
onClick={() => setEditOpen(true)}
|
|
||||||
title="编辑智能体"
|
|
||||||
>
|
|
||||||
<SquarePenIcon className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canDelete && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
|
||||||
onClick={() => setDeleteOpen(true)}
|
|
||||||
title={t.agents.delete}
|
|
||||||
>
|
|
||||||
<Trash2Icon className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button size="sm" className="w-full" onClick={handleChat}>
|
|
||||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
{t.agents.chat}
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import { Pagination } from "@/components/ui/pagination";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||||
|
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||||
import type { Agent, AgentSort } from "@/core/agents";
|
import type { Agent, AgentSort } from "@/core/agents";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { usePrivateSquareStatus } from "@/core/system-settings/hooks";
|
import { usePrivateSquareStatus, useViewMode } from "@/core/system-settings/hooks";
|
||||||
|
|
||||||
import { AgentCard } from "./agent-card";
|
import { AgentCard } from "./agent-card";
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
|||||||
export function AgentGallery() {
|
export function AgentGallery() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [viewMode, setViewMode] = useViewMode("agent");
|
||||||
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
|
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
|
||||||
"mine",
|
"mine",
|
||||||
);
|
);
|
||||||
@@ -125,13 +127,20 @@ export function AgentGallery() {
|
|||||||
setPrivatePassword(null);
|
setPrivatePassword(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderGrid = (list: Agent[]) => (
|
const renderGrid = (list: Agent[]) =>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
viewMode === "list" ? (
|
||||||
{list.map((agent) => (
|
<div className="flex flex-col gap-2">
|
||||||
<AgentCard key={agent.id} agent={agent} />
|
{list.map((agent) => (
|
||||||
))}
|
<AgentCard key={agent.id} agent={agent} layout="list" />
|
||||||
</div>
|
))}
|
||||||
);
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{list.map((agent) => (
|
||||||
|
<AgentCard key={agent.id} agent={agent} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
/** Loading / empty / (grid + pagination) for one paginated tab. */
|
/** Loading / empty / (grid + pagination) for one paginated tab. */
|
||||||
const renderListBody = (opts: {
|
const renderListBody = (opts: {
|
||||||
@@ -308,6 +317,7 @@ export function AgentGallery() {
|
|||||||
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
||||||
共 {activeTotal} 个
|
共 {activeTotal} 个
|
||||||
</span>
|
</span>
|
||||||
|
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||||
{activeTab === "private" && privatePassword && (
|
{activeTab === "private" && privatePassword && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MoonIcon, SunIcon } from "lucide-react";
|
import { LayoutGridIcon, ListIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||||
import { useMemo, type ComponentType, type SVGProps } from "react";
|
import { useMemo, type ComponentType, type SVGProps } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -48,6 +48,8 @@ export function AppearanceSettingsPage() {
|
|||||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||||
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
||||||
const globalDefault = appearance?.default_theme ?? "light";
|
const globalDefault = appearance?.default_theme ?? "light";
|
||||||
|
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||||
|
const globalAgentView = appearance?.default_agent_view ?? "card";
|
||||||
const cleanupWords =
|
const cleanupWords =
|
||||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||||
|
|
||||||
@@ -65,6 +67,27 @@ export function AppearanceSettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSetDefaultView = async (
|
||||||
|
kind: "skill" | "agent",
|
||||||
|
value: "card" | "list",
|
||||||
|
) => {
|
||||||
|
const current = kind === "skill" ? globalSkillView : globalAgentView;
|
||||||
|
if (value === current) return;
|
||||||
|
try {
|
||||||
|
await updateAppearance.mutateAsync(
|
||||||
|
kind === "skill"
|
||||||
|
? { default_skill_view: value }
|
||||||
|
: { default_agent_view: value },
|
||||||
|
);
|
||||||
|
const label = kind === "skill" ? "技能" : "智能体";
|
||||||
|
toast.success(
|
||||||
|
`已将${label}默认展示模式设置为${value === "card" ? "卡片" : "列表"}`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const themeOptions = useMemo(
|
const themeOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -146,6 +169,52 @@ export function AppearanceSettingsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<SettingsSection
|
||||||
|
title="默认展示模式(管理员)"
|
||||||
|
description="技能与智能体列表对所有还没有手动切换过的用户的默认展示模式;已经自己切换过的用户保留其个人选择不受影响。"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ kind: "skill", label: "技能", current: globalSkillView },
|
||||||
|
{ kind: "agent", label: "智能体", current: globalAgentView },
|
||||||
|
] as const
|
||||||
|
).map(({ kind, label, current }) => (
|
||||||
|
<div key={kind} className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="w-14 text-sm font-medium">{label}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={current === "card" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleSetDefaultView(kind, "card")}
|
||||||
|
disabled={updateAppearance.isPending}
|
||||||
|
>
|
||||||
|
<LayoutGridIcon className="mr-1.5 size-4" />
|
||||||
|
卡片
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={current === "list" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleSetDefaultView(kind, "list")}
|
||||||
|
disabled={updateAppearance.isPending}
|
||||||
|
>
|
||||||
|
<ListIcon className="mr-1.5 size-4" />
|
||||||
|
列表
|
||||||
|
</Button>
|
||||||
|
<p className="text-muted-foreground ml-2 text-xs">
|
||||||
|
当前系统默认:{current === "card" ? "卡片" : "列表"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-di
|
|||||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||||
|
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -74,6 +75,7 @@ import {
|
|||||||
useSkills,
|
useSkills,
|
||||||
useValidateSkillUpload,
|
useValidateSkillUpload,
|
||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
|
import { useViewMode } from "@/core/system-settings/hooks";
|
||||||
import type { Skill } from "@/core/skills/type";
|
import type { Skill } from "@/core/skills/type";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
@@ -150,6 +152,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||||
const [sort, setSort] = useState<SkillSort>("time_desc");
|
const [sort, setSort] = useState<SkillSort>("time_desc");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [viewMode, setViewMode] = useViewMode("skill");
|
||||||
const reconcileMutation = useReconcileCustomSkills();
|
const reconcileMutation = useReconcileCustomSkills();
|
||||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||||
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
|
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
|
||||||
@@ -335,25 +338,28 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
tagIds={tagIds}
|
tagIds={tagIds}
|
||||||
onTagIdsChange={setTagIds}
|
onTagIdsChange={setTagIds}
|
||||||
actions={
|
actions={
|
||||||
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
|
<div className="flex items-center gap-2">
|
||||||
<SelectTrigger
|
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||||
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
|
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
|
||||||
title="排序"
|
<SelectTrigger
|
||||||
>
|
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
|
||||||
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
|
title="排序"
|
||||||
<SelectValue />
|
>
|
||||||
</SelectTrigger>
|
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
|
||||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
<SelectValue />
|
||||||
<SelectItem value="time_desc">最新创建</SelectItem>
|
</SelectTrigger>
|
||||||
<SelectItem value="time_asc">最早创建</SelectItem>
|
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||||
<SelectItem value="activity_desc">最近使用</SelectItem>
|
<SelectItem value="time_desc">最新创建</SelectItem>
|
||||||
<SelectItem value="activity_asc">最早使用</SelectItem>
|
<SelectItem value="time_asc">最早创建</SelectItem>
|
||||||
<SelectItem value="usage_desc">使用最多</SelectItem>
|
<SelectItem value="activity_desc">最近使用</SelectItem>
|
||||||
<SelectItem value="usage_asc">使用最少</SelectItem>
|
<SelectItem value="activity_asc">最早使用</SelectItem>
|
||||||
<SelectItem value="name_asc">名称 A-Z</SelectItem>
|
<SelectItem value="usage_desc">使用最多</SelectItem>
|
||||||
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
<SelectItem value="usage_asc">使用最少</SelectItem>
|
||||||
</SelectContent>
|
<SelectItem value="name_asc">名称 A-Z</SelectItem>
|
||||||
</Select>
|
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
@@ -364,13 +370,20 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
<EmptySkill />
|
<EmptySkill />
|
||||||
)}
|
)}
|
||||||
{!isLoading && !error && filteredSkills.length > 0 && (
|
{!isLoading && !error && filteredSkills.length > 0 && (
|
||||||
<div className="flex flex-col gap-3">
|
<div
|
||||||
|
className={cn(
|
||||||
|
viewMode === "card"
|
||||||
|
? "grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3"
|
||||||
|
: "flex flex-col gap-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{pagedSkills.map((skill) => (
|
{pagedSkills.map((skill) => (
|
||||||
<SkillRow
|
<SkillRow
|
||||||
key={skill.name}
|
key={skill.name}
|
||||||
skill={skill}
|
skill={skill}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
|
layout={viewMode === "card" ? "card" : "row"}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -427,11 +440,15 @@ export function SkillRow({
|
|||||||
skill,
|
skill,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
layout = "row",
|
||||||
}: {
|
}: {
|
||||||
skill: Skill;
|
skill: Skill;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
|
||||||
|
layout?: "row" | "card";
|
||||||
}) {
|
}) {
|
||||||
|
const isCard = layout === "card";
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const setPublished = useSetSkillPublished();
|
const setPublished = useSetSkillPublished();
|
||||||
const setPinned = useSetSkillPinned();
|
const setPinned = useSetSkillPinned();
|
||||||
@@ -529,207 +546,239 @@ export function SkillRow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const badgeGroup = (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={cn(isCard && "truncate font-medium")}>
|
||||||
|
{desensitize(skill.name)}
|
||||||
|
</span>
|
||||||
|
{isBuiltin && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{t.settings.skills.badgeBuiltin}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isOwned && (
|
||||||
|
<Badge
|
||||||
|
variant="default"
|
||||||
|
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgeMine}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isFromOthers && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||||
|
title={
|
||||||
|
skill.owner_user_id
|
||||||
|
? t.settings.skills.badgeFromOthersTitle.replace(
|
||||||
|
"{user_id}",
|
||||||
|
skill.owner_user_id,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgeFromOthers}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isOwned && skill.published && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||||
|
title={t.settings.skills.badgePublishedTitle}
|
||||||
|
>
|
||||||
|
<GlobeIcon className="size-3" />
|
||||||
|
{t.settings.skills.badgePublished}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isCustom && !isBuiltin && skill.source === "agent" && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
|
||||||
|
title={t.settings.skills.badgeSourceAgentTitle}
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgeSourceAgent}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isCustom && !isBuiltin && skill.source === "upload" && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
|
||||||
|
title={t.settings.skills.badgeSourceUploadTitle}
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgeSourceUpload}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{skill.featured && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
|
||||||
|
title={t.settings.skills.badgeFeaturedTitle}
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgeFeatured}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{skill.pinned && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||||
|
title={t.settings.skills.badgePinnedTitle}
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgePinned}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const metaNodes = (
|
||||||
|
<>
|
||||||
|
{skill.use_count !== undefined && (
|
||||||
|
<div className="text-muted-foreground mt-1 text-xs">
|
||||||
|
{skill.use_count > 0
|
||||||
|
? t.settings.skills.useCountInline.replace(
|
||||||
|
"{count}",
|
||||||
|
String(skill.use_count),
|
||||||
|
)
|
||||||
|
: t.settings.skills.useCountInlineZero}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(skill.tags?.length ?? 0) > 0 && (
|
||||||
|
<TagChipList tags={skill.tags!} className="mt-2" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionButtons = (
|
||||||
|
<>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={() => setTagPickerOpen(true)}
|
||||||
|
title={t.adminTags.manageTags}
|
||||||
|
>
|
||||||
|
<TagsIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 shrink-0",
|
||||||
|
skill.featured && "text-amber-600 hover:text-amber-700",
|
||||||
|
)}
|
||||||
|
disabled={setFeatured.isPending}
|
||||||
|
onClick={() => void handleToggleFeatured()}
|
||||||
|
title={
|
||||||
|
skill.featured
|
||||||
|
? t.settings.skills.unfeatureAction
|
||||||
|
: t.settings.skills.featureAction
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PinIcon
|
||||||
|
className={cn("size-3.5", skill.featured && "fill-current")}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 shrink-0",
|
||||||
|
skill.pinned && "text-emerald-600 hover:text-emerald-700",
|
||||||
|
)}
|
||||||
|
disabled={setPinned.isPending}
|
||||||
|
onClick={() => void handleTogglePinned()}
|
||||||
|
title={
|
||||||
|
skill.pinned
|
||||||
|
? t.settings.skills.unpinAction
|
||||||
|
: t.settings.skills.pinAction
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BookmarkIcon
|
||||||
|
className={cn("size-3.5", skill.pinned && "fill-current")}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canTogglePublish && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
disabled={setPublished.isPending}
|
||||||
|
onClick={() => void handleTogglePublished(!skill.published)}
|
||||||
|
title={
|
||||||
|
skill.published
|
||||||
|
? t.settings.skills.unpublishAction
|
||||||
|
: t.settings.skills.publishAction
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{skill.published ? (
|
||||||
|
<LockIcon className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<GlobeIcon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
disabled={duplicateSkillMutation.isPending}
|
||||||
|
onClick={handleOpenDuplicate}
|
||||||
|
title="复制为新技能"
|
||||||
|
>
|
||||||
|
<CopyIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||||
|
onClick={() => setConfirmDeleteOpen(true)}
|
||||||
|
title={t.settings.skills.deleteAction}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
{isCard ? (
|
||||||
<ItemContent
|
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||||
className="cursor-pointer"
|
<div
|
||||||
onClick={() => setDetailOpen(true)}
|
className="min-w-0 flex-1 cursor-pointer"
|
||||||
>
|
onClick={() => setDetailOpen(true)}
|
||||||
<ItemTitle>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span>{desensitize(skill.name)}</span>
|
|
||||||
{isBuiltin && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{t.settings.skills.badgeBuiltin}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{isOwned && (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
|
||||||
>
|
|
||||||
{t.settings.skills.badgeMine}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{isFromOthers && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
|
||||||
title={
|
|
||||||
skill.owner_user_id
|
|
||||||
? t.settings.skills.badgeFromOthersTitle.replace(
|
|
||||||
"{user_id}",
|
|
||||||
skill.owner_user_id,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t.settings.skills.badgeFromOthers}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{isOwned && skill.published && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
|
||||||
title={t.settings.skills.badgePublishedTitle}
|
|
||||||
>
|
|
||||||
<GlobeIcon className="size-3" />
|
|
||||||
{t.settings.skills.badgePublished}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{isCustom && !isBuiltin && skill.source === "agent" && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
|
|
||||||
title={t.settings.skills.badgeSourceAgentTitle}
|
|
||||||
>
|
|
||||||
{t.settings.skills.badgeSourceAgent}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{isCustom && !isBuiltin && skill.source === "upload" && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
|
|
||||||
title={t.settings.skills.badgeSourceUploadTitle}
|
|
||||||
>
|
|
||||||
{t.settings.skills.badgeSourceUpload}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{skill.featured && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
|
|
||||||
title={t.settings.skills.badgeFeaturedTitle}
|
|
||||||
>
|
|
||||||
{t.settings.skills.badgeFeatured}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{skill.pinned && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
|
||||||
title={t.settings.skills.badgePinnedTitle}
|
|
||||||
>
|
|
||||||
{t.settings.skills.badgePinned}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ItemTitle>
|
|
||||||
<ItemDescription className="line-clamp-4">
|
|
||||||
{desensitize(skill.description ?? "")}
|
|
||||||
</ItemDescription>
|
|
||||||
{skill.use_count !== undefined && (
|
|
||||||
<div className="text-muted-foreground mt-1 text-xs">
|
|
||||||
{skill.use_count > 0
|
|
||||||
? t.settings.skills.useCountInline.replace(
|
|
||||||
"{count}",
|
|
||||||
String(skill.use_count),
|
|
||||||
)
|
|
||||||
: t.settings.skills.useCountInlineZero}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(skill.tags?.length ?? 0) > 0 && (
|
|
||||||
<TagChipList tags={skill.tags!} className="mt-2" />
|
|
||||||
)}
|
|
||||||
</ItemContent>
|
|
||||||
<ItemActions>
|
|
||||||
{isAdmin && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 shrink-0"
|
|
||||||
onClick={() => setTagPickerOpen(true)}
|
|
||||||
title={t.adminTags.manageTags}
|
|
||||||
>
|
|
||||||
<TagsIcon className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canManage && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-8 shrink-0",
|
|
||||||
skill.featured && "text-amber-600 hover:text-amber-700",
|
|
||||||
)}
|
|
||||||
disabled={setFeatured.isPending}
|
|
||||||
onClick={() => void handleToggleFeatured()}
|
|
||||||
title={
|
|
||||||
skill.featured
|
|
||||||
? t.settings.skills.unfeatureAction
|
|
||||||
: t.settings.skills.featureAction
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PinIcon
|
|
||||||
className={cn("size-3.5", skill.featured && "fill-current")}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canManage && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-8 shrink-0",
|
|
||||||
skill.pinned && "text-emerald-600 hover:text-emerald-700",
|
|
||||||
)}
|
|
||||||
disabled={setPinned.isPending}
|
|
||||||
onClick={() => void handleTogglePinned()}
|
|
||||||
title={
|
|
||||||
skill.pinned
|
|
||||||
? t.settings.skills.unpinAction
|
|
||||||
: t.settings.skills.pinAction
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<BookmarkIcon
|
|
||||||
className={cn("size-3.5", skill.pinned && "fill-current")}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canTogglePublish && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 shrink-0"
|
|
||||||
disabled={setPublished.isPending}
|
|
||||||
onClick={() => void handleTogglePublished(!skill.published)}
|
|
||||||
title={
|
|
||||||
skill.published
|
|
||||||
? t.settings.skills.unpublishAction
|
|
||||||
: t.settings.skills.publishAction
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{skill.published ? (
|
|
||||||
<LockIcon className="size-3.5" />
|
|
||||||
) : (
|
|
||||||
<GlobeIcon className="size-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 shrink-0"
|
|
||||||
disabled={duplicateSkillMutation.isPending}
|
|
||||||
onClick={handleOpenDuplicate}
|
|
||||||
title="复制为新技能"
|
|
||||||
>
|
>
|
||||||
<CopyIcon className="size-3.5" />
|
{badgeGroup}
|
||||||
</Button>
|
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
|
||||||
{canManage && (
|
{desensitize(skill.description ?? "")}
|
||||||
<Button
|
</p>
|
||||||
size="icon"
|
{metaNodes}
|
||||||
variant="ghost"
|
</div>
|
||||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
<div className="mt-2 flex flex-wrap items-center justify-end gap-1 border-t pt-2 dark:border-[#213D5E]">
|
||||||
onClick={() => setConfirmDeleteOpen(true)}
|
{actionButtons}
|
||||||
title={t.settings.skills.deleteAction}
|
</div>
|
||||||
>
|
</div>
|
||||||
<Trash2Icon className="size-3.5" />
|
) : (
|
||||||
</Button>
|
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
||||||
)}
|
<ItemContent
|
||||||
</ItemActions>
|
className="cursor-pointer"
|
||||||
</Item>
|
onClick={() => setDetailOpen(true)}
|
||||||
|
>
|
||||||
|
<ItemTitle>{badgeGroup}</ItemTitle>
|
||||||
|
<ItemDescription className="line-clamp-4">
|
||||||
|
{desensitize(skill.description ?? "")}
|
||||||
|
</ItemDescription>
|
||||||
|
{metaNodes}
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions>{actionButtons}</ItemActions>
|
||||||
|
</Item>
|
||||||
|
)}
|
||||||
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
|
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LayoutGridIcon, ListIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { ViewMode } from "@/core/system-settings/api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact card/list switch shown in the header of the skills and agents lists.
|
||||||
|
* Purely presentational — the parent owns the `value` (resolved from the user's
|
||||||
|
* local override or the admin default via `useViewMode`) and persists changes.
|
||||||
|
*/
|
||||||
|
export function ViewModeToggle({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: ViewMode;
|
||||||
|
onChange: (mode: ViewMode) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/60 inline-flex items-center gap-0.5 rounded-md p-0.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
role="group"
|
||||||
|
aria-label="展示模式"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant={value === "card" ? "default" : "ghost"}
|
||||||
|
className="size-7"
|
||||||
|
onClick={() => onChange("card")}
|
||||||
|
title="卡片模式"
|
||||||
|
aria-pressed={value === "card"}
|
||||||
|
>
|
||||||
|
<LayoutGridIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant={value === "list" ? "default" : "ghost"}
|
||||||
|
className="size-7"
|
||||||
|
onClick={() => onChange("list")}
|
||||||
|
title="列表模式"
|
||||||
|
aria-pressed={value === "list"}
|
||||||
|
>
|
||||||
|
<ListIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
BrainIcon,
|
BrainIcon,
|
||||||
CalendarClockIcon,
|
CalendarClockIcon,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
DatabaseZapIcon,
|
||||||
GaugeIcon,
|
GaugeIcon,
|
||||||
HistoryIcon,
|
HistoryIcon,
|
||||||
MessageSquareCodeIcon,
|
MessageSquareCodeIcon,
|
||||||
@@ -198,6 +199,16 @@ export function WorkspaceNavMenu() {
|
|||||||
用户排行榜
|
用户排行榜
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
navigate("/page/workspace/settings/checkpoint-migration")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DatabaseZapIcon />
|
||||||
|
Checkpoint 迁移
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/workspace/admin")}
|
onClick={() => navigate("/page/workspace/admin")}
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ export interface LocalSettings {
|
|||||||
* app sizes text/spacing in rem, the whole UI scales proportionally.
|
* app sizes text/spacing in rem, the whole UI scales proportionally.
|
||||||
*/
|
*/
|
||||||
fontScale: number;
|
fontScale: number;
|
||||||
|
/**
|
||||||
|
* Per-user override for the skill / agent list display mode (card vs list).
|
||||||
|
* `undefined` = follow the admin-configured system default. Set when the
|
||||||
|
* user flips the card/list toggle on the skills or agents page.
|
||||||
|
*/
|
||||||
|
skillViewMode?: "card" | "list";
|
||||||
|
agentViewMode?: "card" | "list";
|
||||||
};
|
};
|
||||||
context: Omit<
|
context: Omit<
|
||||||
AgentThreadContext,
|
AgentThreadContext,
|
||||||
|
|||||||
@@ -90,13 +90,18 @@ export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ThemeMode = "light" | "dark";
|
export type ThemeMode = "light" | "dark";
|
||||||
|
export type ViewMode = "card" | "list";
|
||||||
|
|
||||||
export interface AppearanceSettings {
|
export interface AppearanceSettings {
|
||||||
default_theme: ThemeMode;
|
default_theme: ThemeMode;
|
||||||
|
default_skill_view: ViewMode;
|
||||||
|
default_agent_view: ViewMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppearanceUpdate {
|
export interface AppearanceUpdate {
|
||||||
default_theme?: ThemeMode;
|
default_theme?: ThemeMode;
|
||||||
|
default_skill_view?: ViewMode;
|
||||||
|
default_agent_view?: ViewMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
|
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { useLocalSettings } from "@/core/settings";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
loadAppearanceSettings,
|
loadAppearanceSettings,
|
||||||
loadCitationDisplaySettings,
|
loadCitationDisplaySettings,
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
type LeaderboardSettingsUpdate,
|
type LeaderboardSettingsUpdate,
|
||||||
type PrivateSquareUpdate,
|
type PrivateSquareUpdate,
|
||||||
type PromptPrefixUpdate,
|
type PromptPrefixUpdate,
|
||||||
|
type ViewMode,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
||||||
@@ -86,6 +89,31 @@ export function useUpdateAppearanceSettings() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective card/list display mode for the skill or agent list. A user's own
|
||||||
|
* choice (persisted in local settings) wins; otherwise the admin-configured
|
||||||
|
* system default applies; otherwise a built-in fallback (skills → list,
|
||||||
|
* agents → card, matching the historical layout of each page). `setMode`
|
||||||
|
* persists the user's override locally.
|
||||||
|
*/
|
||||||
|
export function useViewMode(
|
||||||
|
kind: "skill" | "agent",
|
||||||
|
): [ViewMode, (mode: ViewMode) => void] {
|
||||||
|
const [settings, setSettings] = useLocalSettings();
|
||||||
|
const { data: appearance } = useAppearanceSettings();
|
||||||
|
const overrideKey = kind === "skill" ? "skillViewMode" : "agentViewMode";
|
||||||
|
const userOverride = settings.display[overrideKey];
|
||||||
|
const adminDefault =
|
||||||
|
kind === "skill"
|
||||||
|
? appearance?.default_skill_view
|
||||||
|
: appearance?.default_agent_view;
|
||||||
|
const fallback: ViewMode = kind === "skill" ? "list" : "card";
|
||||||
|
const mode: ViewMode = userOverride ?? adminDefault ?? fallback;
|
||||||
|
const setMode = (next: ViewMode) =>
|
||||||
|
setSettings("display", { [overrideKey]: next });
|
||||||
|
return [mode, setMode];
|
||||||
|
}
|
||||||
|
|
||||||
export function useCitationDisplaySettings() {
|
export function useCitationDisplaySettings() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: CITATION_DISPLAY_KEY,
|
queryKey: CITATION_DISPLAY_KEY,
|
||||||
|
|||||||
@@ -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">
|
<Card className="flex min-h-0 min-w-0 flex-col">
|
||||||
<CardHeader className="gap-2">
|
<CardHeader className="gap-2">
|
||||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||||
<CardTitle>执行记录</CardTitle>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<CardTitle>执行记录</CardTitle>
|
||||||
|
{selectedTask && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 gap-1 px-2 text-xs"
|
||||||
|
title="在新标签打开该任务的公开页"
|
||||||
|
onClick={() => {
|
||||||
|
const url = `${window.location.origin}${window.location.pathname}#/public/scheduled-tasks/${encodeURIComponent(selectedTask.task_id)}`;
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Globe className="size-3.5" />
|
||||||
|
公开页
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Select value={runsTimeFilter} onValueChange={(v) => setRunsTimeFilter(v as typeof runsTimeFilter)}>
|
<Select value={runsTimeFilter} onValueChange={(v) => setRunsTimeFilter(v as typeof runsTimeFilter)}>
|
||||||
<SelectTrigger className="h-7 w-28 text-xs">
|
<SelectTrigger className="h-7 w-28 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import AdminSkillsPage from './AdminSkillsPage';
|
|||||||
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
||||||
import ScheduledTasksPage from './ScheduledTasksPage';
|
import ScheduledTasksPage from './ScheduledTasksPage';
|
||||||
import LlmMetricsPage from './LlmMetricsPage';
|
import LlmMetricsPage from './LlmMetricsPage';
|
||||||
|
import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||||
import MemoryPage from './MemoryPage';
|
import MemoryPage from './MemoryPage';
|
||||||
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
||||||
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
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>
|
</Routes>
|
||||||
</RouteBaseProvider>
|
</RouteBaseProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from app.gateway.routers import (
|
|||||||
assistants_compat,
|
assistants_compat,
|
||||||
auth,
|
auth,
|
||||||
channels,
|
channels,
|
||||||
|
checkpoint_migration,
|
||||||
feedback,
|
feedback,
|
||||||
html_page_favorites,
|
html_page_favorites,
|
||||||
intent,
|
intent,
|
||||||
@@ -908,6 +909,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
# Channels API is mounted at /api/channels
|
# 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)
|
# Assistants compatibility API (LangGraph Platform stub)
|
||||||
app.include_router(assistants_compat.router)
|
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)
|
app.include_router(recommend.router) # Step 1→2:/api/recommend/stream(无 init)
|
||||||
# Step 3 产物列表/下载复用下方 artifacts.router(/api/threads/{id}/artifacts)
|
# Step 3 产物列表/下载复用下方 artifacts.router(/api/threads/{id}/artifacts)
|
||||||
|
|
||||||
|
# 探活端点:无需鉴权(见 auth_middleware._PUBLIC_PATH_PREFIXES)。
|
||||||
|
# 同时暴露裸 ``/health`` 与带 API 前缀的 ``/api/health``,方便监控按
|
||||||
|
# 任意 base URL 直接探活,不会被认证中间件拦成 401。
|
||||||
@app.get("/health", tags=["health"])
|
@app.get("/health", tags=["health"])
|
||||||
|
@app.get("/api/health", tags=["health"])
|
||||||
async def health_check() -> dict:
|
async def health_check() -> dict:
|
||||||
"""Health check endpoint.
|
"""Health check endpoint.
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from deerflow.runtime.user_context import reset_current_user, set_current_user
|
|||||||
# safe read-only routes under it — everything there bypasses auth entirely.
|
# safe read-only routes under it — everything there bypasses auth entirely.
|
||||||
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||||
"/health",
|
"/health",
|
||||||
|
"/api/health",
|
||||||
"/docs",
|
"/docs",
|
||||||
"/redoc",
|
"/redoc",
|
||||||
"/openapi.json",
|
"/openapi.json",
|
||||||
|
|||||||
@@ -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."""
|
"""Effective system-wide appearance defaults."""
|
||||||
|
|
||||||
default_theme: Literal["light", "dark"] = Field(..., description="System default theme — light or dark")
|
default_theme: Literal["light", "dark"] = Field(..., description="System default theme — light or dark")
|
||||||
|
default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list")
|
||||||
|
default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list")
|
||||||
|
|
||||||
|
|
||||||
class AppearanceUpdateRequest(BaseModel):
|
class AppearanceUpdateRequest(BaseModel):
|
||||||
default_theme: Literal["light", "dark"] | None = Field(default=None)
|
default_theme: Literal["light", "dark"] | None = Field(default=None)
|
||||||
|
default_skill_view: Literal["card", "list"] | None = Field(default=None)
|
||||||
|
default_agent_view: Literal["card", "list"] | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _appearance_response(appearance) -> AppearanceResponse:
|
||||||
|
return AppearanceResponse(
|
||||||
|
default_theme=appearance.default_theme,
|
||||||
|
default_skill_view=appearance.default_skill_view,
|
||||||
|
default_agent_view=appearance.default_agent_view,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/appearance", response_model=AppearanceResponse)
|
@router.get("/appearance", response_model=AppearanceResponse)
|
||||||
async def get_appearance_settings() -> AppearanceResponse:
|
async def get_appearance_settings() -> AppearanceResponse:
|
||||||
"""Return the global default theme. Any logged-in user may read this."""
|
"""Return the global appearance defaults. Any logged-in user may read this."""
|
||||||
settings = load_system_settings().appearance
|
return _appearance_response(load_system_settings().appearance)
|
||||||
return AppearanceResponse(default_theme=settings.default_theme)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/appearance", response_model=AppearanceResponse)
|
@router.put("/appearance", response_model=AppearanceResponse)
|
||||||
@@ -246,8 +257,9 @@ async def update_appearance_settings(
|
|||||||
current = load_system_settings()
|
current = load_system_settings()
|
||||||
fields = body.model_dump(exclude_unset=True)
|
fields = body.model_dump(exclude_unset=True)
|
||||||
update: dict[str, object] = {}
|
update: dict[str, object] = {}
|
||||||
if "default_theme" in fields and fields["default_theme"] is not None:
|
for key in ("default_theme", "default_skill_view", "default_agent_view"):
|
||||||
update["default_theme"] = fields["default_theme"]
|
if key in fields and fields[key] is not None:
|
||||||
|
update[key] = fields[key]
|
||||||
if update:
|
if update:
|
||||||
updated_appearance = current.appearance.model_copy(update=update)
|
updated_appearance = current.appearance.model_copy(update=update)
|
||||||
saved = save_system_settings(
|
saved = save_system_settings(
|
||||||
@@ -255,7 +267,7 @@ async def update_appearance_settings(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
saved = current
|
saved = current
|
||||||
return AppearanceResponse(default_theme=saved.appearance.default_theme)
|
return _appearance_response(saved.appearance)
|
||||||
|
|
||||||
|
|
||||||
class LogSystemResponse(BaseModel):
|
class LogSystemResponse(BaseModel):
|
||||||
|
|||||||
@@ -218,8 +218,16 @@ database:
|
|||||||
echo_sql: false
|
echo_sql: false
|
||||||
|
|
||||||
checkpointer:
|
checkpointer:
|
||||||
type: sqlite
|
type: postgres
|
||||||
connection_string: .deer-flow/data/deerflow.db
|
# 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 私有记忆。
|
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
|
||||||
# 三层覆盖优先级(高->低):
|
# 三层覆盖优先级(高->低):
|
||||||
|
|||||||
+28
@@ -23,6 +23,34 @@ class CheckpointerConfig(BaseModel):
|
|||||||
"For sqlite, use a file path like '.deer-flow/checkpoints.db' or ':memory:' for in-memory. "
|
"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 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.
|
# Global configuration instance — None means no checkpointer is configured.
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ class AppearanceSettings(BaseModel):
|
|||||||
default="light",
|
default="light",
|
||||||
description="Theme applied for users who haven't picked their own (light / dark)",
|
description="Theme applied for users who haven't picked their own (light / dark)",
|
||||||
)
|
)
|
||||||
|
default_skill_view: Literal["card", "list"] = Field(
|
||||||
|
default="list",
|
||||||
|
description="Default skill list display mode for users who haven't picked their own (card / list)",
|
||||||
|
)
|
||||||
|
default_agent_view: Literal["card", "list"] = Field(
|
||||||
|
default="card",
|
||||||
|
description="Default agent list display mode for users who haven't picked their own (card / list)",
|
||||||
|
)
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+108
-10
@@ -21,6 +21,8 @@ import asyncio
|
|||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from langgraph.types import Checkpointer
|
from langgraph.types import Checkpointer
|
||||||
|
|
||||||
@@ -34,6 +36,63 @@ from deerflow.runtime.store._sqlite_utils import ensure_sqlite_parent_dir, resol
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# Async factory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -49,19 +108,18 @@ async def _async_checkpointer(config) -> AsyncIterator[Checkpointer]:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if config.type == "sqlite":
|
if config.type == "sqlite":
|
||||||
try:
|
async with _sqlite_checkpointer_from_conn_str(config.connection_string or "store.db") as saver:
|
||||||
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
|
yield saver
|
||||||
return
|
return
|
||||||
|
|
||||||
if config.type == "postgres":
|
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:
|
try:
|
||||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
@@ -72,7 +130,47 @@ async def _async_checkpointer(config) -> AsyncIterator[Checkpointer]:
|
|||||||
|
|
||||||
async with AsyncPostgresSaver.from_conn_string(config.connection_string) as saver:
|
async with AsyncPostgresSaver.from_conn_string(config.connection_string) as saver:
|
||||||
await saver.setup()
|
await saver.setup()
|
||||||
yield saver
|
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
|
return
|
||||||
|
|
||||||
raise ValueError(f"Unknown checkpointer type: {config.type!r}")
|
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
|
||||||
@@ -32,7 +32,10 @@ dependencies = [
|
|||||||
"duckdb>=1.4.4",
|
"duckdb>=1.4.4",
|
||||||
"langchain-google-genai>=4.2.1",
|
"langchain-google-genai>=4.2.1",
|
||||||
"langgraph-checkpoint-sqlite>=3.0.3",
|
"langgraph-checkpoint-sqlite>=3.0.3",
|
||||||
|
"langgraph-checkpoint-postgres>=3.0.5",
|
||||||
"langgraph-sdk>=0.1.51",
|
"langgraph-sdk>=0.1.51",
|
||||||
|
"psycopg[binary]>=3.3.3",
|
||||||
|
"psycopg-pool>=3.3.0",
|
||||||
"sqlalchemy[asyncio]>=2.0,<3.0",
|
"sqlalchemy[asyncio]>=2.0,<3.0",
|
||||||
"aiosqlite>=0.19",
|
"aiosqlite>=0.19",
|
||||||
"alembic>=1.13",
|
"alembic>=1.13",
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ models:
|
|||||||
temperature: 1.0
|
temperature: 1.0
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_thinking: 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 工具会把
|
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
|
||||||
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
|
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
|
||||||
@@ -187,8 +197,16 @@ checkpointer:
|
|||||||
# Docker 部署:把容器内 .deer-flow 目录挂卷出来即可持久化(撑住容器重启)。
|
# Docker 部署:把容器内 .deer-flow 目录挂卷出来即可持久化(撑住容器重启)。
|
||||||
# 这里同时被「主 agent」和「AI 写作 graph」共用 —— LangGraph 用
|
# 这里同时被「主 agent」和「AI 写作 graph」共用 —— LangGraph 用
|
||||||
# (thread_id, checkpoint_ns) 做隔离,session_id 是 uuid,不会撞。
|
# (thread_id, checkpoint_ns) 做隔离,session_id 是 uuid,不会撞。
|
||||||
type: sqlite
|
type: postgres
|
||||||
connection_string: .deer-flow/data/checkpoints.db
|
# 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 写作子系统的会话清理配置。
|
||||||
# 业务表(ai_writing_sessions @ MySQL) + checkpointer(checkpoints.db @ SQLite) 联动清理。
|
# 业务表(ai_writing_sessions @ MySQL) + checkpointer(checkpoints.db @ SQLite) 联动清理。
|
||||||
# 留意:清理走 cron 表达式调度,默认每天凌晨 3 点跑一次,按 updated_at 一刷拉清。
|
# 留意:清理走 cron 表达式调度,默认每天凌晨 3 点跑一次,按 updated_at 一刷拉清。
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
|
|||||||
|
|
||||||
PID_FILE="${PID_DIR}/backend.pid"
|
PID_FILE="${PID_DIR}/backend.pid"
|
||||||
LOG_FILE="${LOG_DIR}/backend.log"
|
LOG_FILE="${LOG_DIR}/backend.log"
|
||||||
|
BACKEND_VENDOR_DIR="${BACKEND_VENDOR_DIR:-${BACKEND_DIR}/.deer-flow/python-vendor}"
|
||||||
|
BACKEND_WHEELHOUSE="${BACKEND_WHEELHOUSE:-${BACKEND_DIR}/wheelhouse}"
|
||||||
|
BACKEND_INSTALL_DEPS="${BACKEND_INSTALL_DEPS:-1}"
|
||||||
|
BACKEND_PIP_PACKAGES="${BACKEND_PIP_PACKAGES:-croniter==6.2.2 langgraph-checkpoint-postgres==3.0.5 psycopg==3.3.3 psycopg-binary==3.3.3 psycopg-pool==3.3.0 orjson==3.11.9 tzdata==2026.2}"
|
||||||
|
|
||||||
if is_running "${PID_FILE}"; then
|
if is_running "${PID_FILE}"; then
|
||||||
echo "Backend is already running (pid $(cat "${PID_FILE}"))."
|
echo "Backend is already running (pid $(cat "${PID_FILE}"))."
|
||||||
@@ -17,16 +21,41 @@ rm -f "${PID_FILE}"
|
|||||||
echo "Starting backend..."
|
echo "Starting backend..."
|
||||||
echo "Log: ${LOG_FILE}"
|
echo "Log: ${LOG_FILE}"
|
||||||
|
|
||||||
|
mkdir -p "${BACKEND_VENDOR_DIR}"
|
||||||
|
|
||||||
|
if [[ "${BACKEND_INSTALL_DEPS}" != "0" ]]; then
|
||||||
|
if ! (
|
||||||
|
cd "${BACKEND_DIR}" && \
|
||||||
|
PYTHONPATH=".:${BACKEND_VENDOR_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \
|
||||||
|
uv run --no-sync python -c "import croniter; import psycopg; import psycopg_pool; import langgraph.checkpoint.postgres; import langgraph.store.postgres"
|
||||||
|
) >/dev/null 2>&1; then
|
||||||
|
if [[ ! -d "${BACKEND_WHEELHOUSE}" ]]; then
|
||||||
|
echo "ERROR: wheelhouse not found: ${BACKEND_WHEELHOUSE}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Installing backend runtime deps into ${BACKEND_VENDOR_DIR}"
|
||||||
|
(
|
||||||
|
cd "${BACKEND_DIR}" && \
|
||||||
|
uv pip install \
|
||||||
|
--target "${BACKEND_VENDOR_DIR}" \
|
||||||
|
--no-deps \
|
||||||
|
--no-index \
|
||||||
|
--find-links "${BACKEND_WHEELHOUSE}" \
|
||||||
|
${BACKEND_PIP_PACKAGES}
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
setsid bash -c '
|
setsid bash -c '
|
||||||
cd "$0" || exit 1
|
cd "$0" || exit 1
|
||||||
exec env \
|
exec env \
|
||||||
GATEWAY_CORS_ORIGINS="$1" \
|
GATEWAY_CORS_ORIGINS="$1" \
|
||||||
PYTHONPATH=. \
|
PYTHONPATH=".:$4${PYTHONPATH:+:${PYTHONPATH}}" \
|
||||||
uv run uvicorn app.gateway.app:app \
|
uv run uvicorn app.gateway.app:app \
|
||||||
--host "$2" \
|
--host "$2" \
|
||||||
--port "$3" \
|
--port "$3" \
|
||||||
--reload
|
--reload
|
||||||
' "${BACKEND_DIR}" "${GATEWAY_CORS_ORIGINS}" "${BACKEND_HOST}" "${BACKEND_PORT}" \
|
' "${BACKEND_DIR}" "${GATEWAY_CORS_ORIGINS}" "${BACKEND_HOST}" "${BACKEND_PORT}" "${BACKEND_VENDOR_DIR}" \
|
||||||
>"${LOG_FILE}" 2>&1 &
|
>"${LOG_FILE}" 2>&1 &
|
||||||
|
|
||||||
echo "$!" >"${PID_FILE}"
|
echo "$!" >"${PID_FILE}"
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-deerflow-backend-offline:20260505}"
|
||||||
|
CONTAINER_NAME="${CONTAINER_NAME:-deerflow-backend}"
|
||||||
|
HOST_PORT="${HOST_PORT:-2026}"
|
||||||
|
IMAGE_TAR="${IMAGE_TAR:-${SCRIPT_DIR}/deerflow-backend-offline-image-20260505.tar}"
|
||||||
|
# Default timezone for the container. The offline image is built from a base
|
||||||
|
# layer that ships with UTC, which made scheduled tasks fire at the wrong wall
|
||||||
|
# clock and made log timestamps confusing for ops. ``Asia/Shanghai`` keeps the
|
||||||
|
# scheduler aligned with the operator's expectation; override with TZ=... when
|
||||||
|
# deploying to a different region.
|
||||||
|
CONTAINER_TZ="${CONTAINER_TZ:-${TZ:-Asia/Shanghai}}"
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "ERROR: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || die "command not found: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
[ -f "$1" ] || die "required file not found: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_dir() {
|
||||||
|
[ -d "$1" ] || die "required directory not found: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_env_key() {
|
||||||
|
local key="$1"
|
||||||
|
grep -Eq "^[[:space:]]*${key}=" "${SCRIPT_DIR}/.env" || die "required .env key not found: ${key}"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd docker
|
||||||
|
|
||||||
|
if [ ! -f "${SCRIPT_DIR}/.env" ]; then
|
||||||
|
if [ -f "${SCRIPT_DIR}/.env.example" ]; then
|
||||||
|
cp "${SCRIPT_DIR}/.env.example" "${SCRIPT_DIR}/.env"
|
||||||
|
echo "Generated .env from .env.example. Please edit .env and run this script again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
die "missing .env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "${SCRIPT_DIR}/config.yaml" ]; then
|
||||||
|
if [ -f "${SCRIPT_DIR}/config.yaml.example" ]; then
|
||||||
|
cp "${SCRIPT_DIR}/config.yaml.example" "${SCRIPT_DIR}/config.yaml"
|
||||||
|
echo "Generated config.yaml from config.yaml.example. Please edit model/API settings and run this script again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
die "missing config.yaml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_env_key "AUTH_JWT_SECRET"
|
||||||
|
require_env_key "SCHEDULED_TASKS_ENABLED"
|
||||||
|
require_env_key "ELEMENT_ENABLED"
|
||||||
|
require_file "${SCRIPT_DIR}/config.yaml"
|
||||||
|
require_file "${SCRIPT_DIR}/extensions_config.json"
|
||||||
|
require_file "${SCRIPT_DIR}/backend/pyproject.toml"
|
||||||
|
require_file "${SCRIPT_DIR}/backend/app/gateway/app.py"
|
||||||
|
require_file "${SCRIPT_DIR}/backend/packages/harness/deerflow/runtime/scheduler/element.py"
|
||||||
|
require_dir "${SCRIPT_DIR}/skills"
|
||||||
|
require_dir "${SCRIPT_DIR}/wheelhouse"
|
||||||
|
# Knowledge base directory, mounted into the container at /mnt/knowledge_base.
|
||||||
|
require_dir "${SCRIPT_DIR}/backend/knowledge/knowledge_base"
|
||||||
|
mkdir -p "${SCRIPT_DIR}/data"
|
||||||
|
|
||||||
|
if ! docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then
|
||||||
|
require_file "${IMAGE_TAR}"
|
||||||
|
echo "Loading Docker image from ${IMAGE_TAR}"
|
||||||
|
docker load -i "${IMAGE_TAR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if docker ps -a --format '{{.Names}}' | awk -v name="${CONTAINER_NAME}" '$0 == name { found=1 } END { exit(found ? 0 : 1) }'; then
|
||||||
|
echo "Removing existing container ${CONTAINER_NAME}"
|
||||||
|
docker rm -f "${CONTAINER_NAME}" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting backend container ${CONTAINER_NAME}"
|
||||||
|
docker run -d \
|
||||||
|
--name "${CONTAINER_NAME}" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p "${HOST_PORT}:8001" \
|
||||||
|
--env-file "${SCRIPT_DIR}/.env" \
|
||||||
|
-e TZ="${CONTAINER_TZ}" \
|
||||||
|
-e DEER_FLOW_PROJECT_ROOT=/app \
|
||||||
|
-e DEER_FLOW_HOME=/app/backend/.deer-flow \
|
||||||
|
-e DEER_FLOW_CONFIG_PATH=/app/config.yaml \
|
||||||
|
-e DEER_FLOW_EXTENSIONS_CONFIG_PATH=/app/extensions_config.json \
|
||||||
|
-e DEER_FLOW_SKILLS_PATH=/app/skills \
|
||||||
|
-e UV_LINK_MODE=copy \
|
||||||
|
-v "${SCRIPT_DIR}/backend/app:/app/backend/app" \
|
||||||
|
-v "${SCRIPT_DIR}/backend/packages:/app/backend/packages" \
|
||||||
|
-v "${SCRIPT_DIR}/backend/scripts:/app/backend/scripts" \
|
||||||
|
-v "${SCRIPT_DIR}/backend/pyproject.toml:/app/backend/pyproject.toml:ro" \
|
||||||
|
-v "${SCRIPT_DIR}/backend/uv.lock:/app/backend/uv.lock:ro" \
|
||||||
|
-v "${SCRIPT_DIR}/backend/langgraph.json:/app/backend/langgraph.json:ro" \
|
||||||
|
-v "${SCRIPT_DIR}/config.yaml:/app/config.yaml:ro" \
|
||||||
|
-v "${SCRIPT_DIR}/skill_evolution.json:/app/skill_evolution.json:rw" \
|
||||||
|
-v "${SCRIPT_DIR}/extensions_config.json:/app/extensions_config.json:rw" \
|
||||||
|
-v "${SCRIPT_DIR}/skills:/app/skills:rw" \
|
||||||
|
-v "${SCRIPT_DIR}/wheelhouse:/app/wheelhouse:ro" \
|
||||||
|
-v "${SCRIPT_DIR}/backend/knowledge/knowledge_base:/mnt/knowledge_base:ro" \
|
||||||
|
-v "${SCRIPT_DIR}/data:/app/backend/.deer-flow" \
|
||||||
|
"${IMAGE_TAG}" \
|
||||||
|
sh -c 'cd backend
|
||||||
|
# Apply timezone inside the container. ``TZ`` alone is enough for Python /
|
||||||
|
# uvicorn log timestamps, but linking /etc/localtime keeps C-level tools
|
||||||
|
# (e.g. anything that reads /etc/localtime directly) on the same clock.
|
||||||
|
if [ -n "${TZ:-}" ] && [ -f "/usr/share/zoneinfo/${TZ}" ]; then
|
||||||
|
ln -sf "/usr/share/zoneinfo/${TZ}" /etc/localtime 2>/dev/null || true
|
||||||
|
echo "${TZ}" > /etc/timezone 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [ "${SCHEDULER_INSTALL_DEPS:-1}" != "0" ]; then
|
||||||
|
uv run --no-sync python -c "import croniter" >/dev/null 2>&1 || {
|
||||||
|
PYTHON_BIN="$(uv run --no-sync python -c "import sys; print(sys.executable)")"
|
||||||
|
uv pip install --python "${PYTHON_BIN}" --no-index --find-links /app/wheelhouse ${SCHEDULER_PIP_PACKAGES:-croniter==6.2.2}
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
if [ "${HINDSIGHT_INSTALL_DEPS:-1}" != "0" ]; then
|
||||||
|
uv run --no-sync python -c "import hindsight_client" >/dev/null 2>&1 || {
|
||||||
|
PYTHON_BIN="$(uv run --no-sync python -c "import sys; print(sys.executable)")"
|
||||||
|
uv pip install --python "${PYTHON_BIN}" --no-index --find-links /app/wheelhouse ${HINDSIGHT_INSTALL_DEPS:-hindsight_client==0.6.2}
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
if [ "${PG_CHECKPOINTER_INSTALL_DEPS:-1}" != "0" ]; then
|
||||||
|
uv run --no-sync python -c "import psycopg; import psycopg_pool; import langgraph.checkpoint.postgres; import langgraph.store.postgres" >/dev/null 2>&1 || {
|
||||||
|
PYTHON_BIN="$(uv run --no-sync python -c "import sys; print(sys.executable)")"
|
||||||
|
uv pip install --python "${PYTHON_BIN}" --no-deps --no-index --find-links /app/wheelhouse ${PG_CHECKPOINTER_PIP_PACKAGES:-langgraph-checkpoint-postgres==3.0.5 psycopg==3.3.3 psycopg-binary==3.3.3 psycopg-pool==3.3.0 orjson==3.11.9 tzdata==2026.2}
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
PYTHONPATH=. uv run --no-sync python -m uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers ${GATEWAY_WORKERS:-8}'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Backend started."
|
||||||
|
echo " URL: http://<server-ip>:${HOST_PORT}"
|
||||||
|
echo " Logs: docker logs -f ${CONTAINER_NAME}"
|
||||||
|
echo " Stop: ./stop_backend.sh"
|
||||||
Reference in new issue
Block a user