This commit is contained in:
吉鑫升 committed 2026-06-04 14:28:22 +08:00
commit 9704eff9f7
42 files changed
+1973 -10517

No files matched your search

-10140
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,96 @@
"use client";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
/** Compute the page-number window to render, inserting -1 sentinels for gaps.
* Always shows first/last and a window around the current page. */
function buildPageItems(current: number, totalPages: number): number[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const items = new Set<number>([1, totalPages, current]);
for (const d of [1, 2]) {
if (current - d >= 1) items.add(current - d);
if (current + d <= totalPages) items.add(current + d);
}
const sorted = [...items].sort((a, b) => a - b);
const out: number[] = [];
let prev = 0;
for (const p of sorted) {
if (prev && p - prev > 1) out.push(-1); // gap sentinel
out.push(p);
prev = p;
}
return out;
}
export interface PaginationProps {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
className?: string;
}
/** Controlled, compact pagination control. Renders nothing when a single page. */
export function Pagination({
page,
pageSize,
total,
onPageChange,
className,
}: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / Math.max(pageSize, 1)));
if (totalPages <= 1) return null;
const items = buildPageItems(page, totalPages);
return (
<nav
className={cn("flex items-center justify-center gap-1", className)}
aria-label="分页"
>
<Button
variant="outline"
size="icon-sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="上一页"
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
{items.map((p, idx) =>
p === -1 ? (
<span
key={`gap-${idx}`}
className="text-muted-foreground px-1 text-sm select-none"
>
…
</span>
) : (
<Button
key={p}
variant={p === page ? "default" : "outline"}
size="icon-sm"
onClick={() => onPageChange(p)}
aria-current={p === page ? "page" : undefined}
>
{p}
</Button>
),
)}
<Button
variant="outline"
size="icon-sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="下一页"
>
<ChevronRightIcon className="h-4 w-4" />
</Button>
</nav>
);
}
+30 -4
View File
@@ -22,10 +22,31 @@ function ResizablePanelGroup({
); );
} }
// react-resizable-panels v4 changed size semantics: a bare *number* is now
// interpreted as PIXELS, while a unit-less *string* is a percentage. Every call
// site in this app passes sizes as percentages (the v2/v3 convention), so a
// value like `maxSize={60}` would otherwise pin a panel to 60px and freeze the
// layout/drag. Normalize numbers to "<n>%" to preserve the intended percentage.
const asPercent = (v: number | string | undefined) =>
typeof v === "number" ? `${v}%` : v;
function ResizablePanel({ function ResizablePanel({
defaultSize,
minSize,
maxSize,
collapsedSize,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) { }: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />; return (
<ResizablePrimitive.Panel
data-slot="resizable-panel"
defaultSize={asPercent(defaultSize)}
minSize={asPercent(minSize)}
maxSize={asPercent(maxSize)}
collapsedSize={asPercent(collapsedSize)}
{...props}
/>
);
} }
function ResizableHandle({ function ResizableHandle({
@@ -39,14 +60,19 @@ function ResizableHandle({
<ResizablePrimitive.Separator <ResizablePrimitive.Separator
data-slot="resizable-handle" data-slot="resizable-handle"
className={cn( className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90", // Thin visible divider, but a generous invisible hit area on both sides
// (after:w-4 → 16px) so the handle is easy to grab and drag. Hovering /
// dragging tints the divider so it's obvious it's interactive.
"bg-border hover:bg-primary/60 active:bg-primary relative flex w-px items-center justify-center transition-colors",
"after:absolute after:inset-y-0 after:left-1/2 after:w-4 after:-translate-x-1/2 after:cursor-col-resize",
"focus-visible:ring-ring focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden",
className, className,
)} )}
{...props} {...props}
> >
{withHandle && ( {withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border"> <div className="bg-border z-10 flex h-8 w-4 items-center justify-center rounded-sm border shadow-sm">
<GripVerticalIcon className="size-2.5" /> <GripVerticalIcon className="size-3.5" />
</div> </div>
)} )}
</ResizablePrimitive.Separator> </ResizablePrimitive.Separator>
@@ -15,7 +15,14 @@ import {
Trash2Icon, Trash2Icon,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -63,6 +70,115 @@ interface AgentCardProps {
agent: Agent; agent: Agent;
} }
interface BadgeItem {
key: string;
node: ReactNode;
}
// Minimum number of skill/tool badges to always render before collapsing the
// remainder into the "+N" chip.
const MIN_VISIBLE_BADGES = 3;
/** Render skill/tool-group badges clamped to at most two rows. Overflow is
* collapsed into a clickable "+N" chip that opens a dialog listing the full
* badge set (the visible rows stay as-is — only the chip is interactive).
*
* Badge widths are variable, so the cutoff is measured rather than assumed: an
* off-screen mirror of the full badge set is laid out at the real container
* width, and we keep only the badges that land within the first two rows
* (reserving one slot for the "+N" chip). A ResizeObserver recomputes when the
* responsive grid changes the card width.
*/
function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
const measureRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(items.length);
const [dialogOpen, setDialogOpen] = useState(false);
const sig = items.map((i) => i.key).join("|");
useLayoutEffect(() => {
const el = measureRef.current;
if (!el) return;
const compute = () => {
const kids = Array.from(el.children) as HTMLElement[];
if (kids.length === 0) {
setVisible(0);
return;
}
const top0 = kids[0].offsetTop;
const rowH = kids[0].offsetHeight;
// +2px fudge so the second row's badges (which may sit a hair lower due
// to line-box rounding) are not falsely treated as a third row.
const maxBottom = top0 + rowH * 2 + 2;
let count = 0;
for (const k of kids) {
if (k.offsetTop + k.offsetHeight <= maxBottom) count += 1;
else break;
}
// Reserve one slot for the "+N" chip when anything overflows so the chip
// itself never spills onto a third row.
if (count < kids.length && count > 0) count -= 1;
// Always surface a few badges before collapsing into "+N", even when the
// card is narrow enough that two rows barely fit anything (otherwise a
// skinny column could show just "+N" with no skills at all). Keeps at
// least one badge hidden so the chip still makes sense.
if (count < kids.length) {
count = Math.max(count, Math.min(MIN_VISIBLE_BADGES, kids.length - 1));
}
setVisible(count);
};
compute();
const ro = new ResizeObserver(compute);
ro.observe(el);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sig]);
const hidden = items.length - visible;
const showChip = hidden > 0;
const shown = items.slice(0, visible);
return (
<div className="relative">
{/* Off-screen full-width mirror used only for measuring row breaks. */}
<div
ref={measureRef}
aria-hidden
className="pointer-events-none invisible absolute inset-x-0 top-0 flex flex-wrap gap-1"
>
{items.map((i) => i.node)}
</div>
<div className="flex flex-wrap gap-1">
{shown.map((i) => i.node)}
{showChip && (
<Badge
variant="secondary"
className="hover:bg-secondary/70 cursor-pointer text-xs"
title="查看全部"
onClick={(e) => {
e.stopPropagation();
setDialogOpen(true);
}}
>
+{hidden}
</Badge>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>全部技能与工具</DialogTitle>
<DialogDescription>共 {items.length} 项</DialogDescription>
</DialogHeader>
<div className="flex max-h-[60vh] flex-wrap gap-1.5 overflow-y-auto py-1">
{items.map((i) => i.node)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
export function AgentCard({ agent }: AgentCardProps) { export function AgentCard({ agent }: AgentCardProps) {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
@@ -397,31 +513,39 @@ export function AgentCard({ agent }: AgentCardProps) {
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && ( {(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-3"> <CardContent className="pt-0 pb-3">
<div className="flex flex-wrap gap-1"> <ClampedBadgeRow
{agent.tool_groups?.map((group) => ( items={[
<Badge ...(agent.tool_groups ?? []).map((group) => ({
key={`tg:${group}`} key: `tg:${group}`,
variant="outline" node: (
className="text-xs" <Badge
> key={`tg:${group}`}
{desensitize(group)} variant="outline"
</Badge> className="text-xs"
))} >
{agent.skills?.map((skill) => ( {desensitize(group)}
<Badge </Badge>
key={`sk:${skill}`} ),
variant="secondary" })),
className="hover:bg-secondary/70 cursor-pointer text-xs" ...(agent.skills ?? []).map((skill) => ({
title={t.settings.skills.viewDetailAction} key: `sk:${skill}`,
onClick={(e) => { node: (
e.stopPropagation(); <Badge
setDetailSkillName(skill); key={`sk:${skill}`}
}} variant="secondary"
> className="hover:bg-secondary/70 cursor-pointer text-xs"
{desensitize(skill)} title={t.settings.skills.viewDetailAction}
</Badge> onClick={(e) => {
))} e.stopPropagation();
</div> setDetailSkillName(skill);
}}
>
{desensitize(skill)}
</Badge>
),
})),
]}
/>
</CardContent> </CardContent>
)} )}
@@ -2,22 +2,23 @@
import { BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react"; import { BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
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 { useAgents, usePrivateAgents } from "@/core/agents"; import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent } from "@/core/agents"; import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { usePrivateSquareStatus } from "@/core/system-settings/hooks"; import { usePrivateSquareStatus } from "@/core/system-settings/hooks";
import { desensitize } from "@/lib/desensitize";
import { AgentCard } from "./agent-card"; import { AgentCard } from "./agent-card";
const PAGE_SIZE = 12;
// Within a single tab the verified password is kept in sessionStorage so a // Within a single tab the verified password is kept in sessionStorage so a
// refresh / route navigation does not re-prompt. Cleared on tab close, so the // refresh / route navigation does not re-prompt. Cleared on tab close, so the
// password never persists beyond the browsing session. // password never persists beyond the browsing session.
@@ -31,19 +32,19 @@ export function AgentGallery() {
); );
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]); const [tagIds, setTagIds] = useState<string[]>([]);
// Tag filtering stays server-side; name search must run on the *desensitized* // Per-tab 1-based page index, preserved while switching tabs.
// form because that's what the user sees on the card. If we sent the search const [pages, setPages] = useState<{ all: number; mine: number; private: number }>(
// string to the backend it would compare against the raw stored name and { all: 1, mine: 1, private: 1 },
// miss every desensitized hit (e.g. typing "TS分析" wouldn't match the );
// stored "态势分析").
const { agents, isLoading } = useAgents({ tagIds });
const needle = search.trim().toLowerCase(); // Search + tag filtering are both server-side now. The backend mirrors the
const matchesSearch = (a: Agent) => // frontend's desensitize map, so typing the displayed abbreviation (e.g.
!needle || desensitize(a.name).toLowerCase().includes(needle); // "TS") still matches the stored raw name (e.g. "态势分析"). Whenever the
// filters change, every tab returns to its first page.
const storedAuth = useMemo(() => getStoredAuth(), []); const tagKey = tagIds.join(",");
const currentUserId = storedAuth?.user_id ?? null; useEffect(() => {
setPages({ all: 1, mine: 1, private: 1 });
}, [search, tagKey]);
const { data: privateStatus } = usePrivateSquareStatus(); const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled; const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -56,11 +57,35 @@ export function AgentGallery() {
}); });
const [passwordInput, setPasswordInput] = useState(""); const [passwordInput, setPasswordInput] = useState("");
// Only the active tab fetches. "广场" maps to the server "square" scope
// (built-in/published, never private), "我的" to "mine" (owned + favorited).
const mainScope = activeTab === "mine" ? "mine" : "square";
const mainPage = activeTab === "mine" ? pages.mine : pages.all;
const {
agents: mainAgents,
total: mainTotal,
isLoading: mainLoading,
} = useAgentsPaginated({
scope: mainScope,
search,
tagIds,
page: mainPage,
pageSize: PAGE_SIZE,
enabled: activeTab !== "private",
});
const { const {
agents: privateAgents, agents: privateAgents,
total: privateTotal,
isLoading: isPrivateLoading, isLoading: isPrivateLoading,
error: privateError, error: privateError,
} = usePrivateAgents(privatePassword); } = usePrivateAgentsPaginated(privatePassword, {
search,
tagIds,
page: pages.private,
pageSize: PAGE_SIZE,
enabled: activeTab === "private",
});
// If the server reports the stored password no longer works (likely the // If the server reports the stored password no longer works (likely the
// admin rotated it), drop it from session storage and re-prompt. // admin rotated it), drop it from session storage and re-prompt.
@@ -72,49 +97,10 @@ export function AgentGallery() {
} }
}, [privateError, privatePassword]); }, [privateError, privatePassword]);
// The "广场" tab must never surface private-square agents, even to their const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
// own owner — moving an agent into the private square is supposed to hide
// it from the public square entirely. (The backend listing endpoint
// already filters them out for non-owners; this extra clause covers the
// owner's own private agents, which still come back in the unified list
// because owners always see their own.)
const allAgents = useMemo(
() =>
agents.filter(
(a) => (a.builtin || a.published) && !a.is_private && matchesSearch(a),
),
// matchesSearch closes over `needle`; depending on `search` is sufficient
// and avoids re-running on every render via the function reference.
// eslint-disable-next-line react-hooks/exhaustive-deps
[agents, search],
);
// "我的" shows the user's own agents plus any agent they've favorited (收藏) const setTabPage = (tab: "all" | "mine" | "private", page: number) =>
// from the square — favorited public agents have a different user_id but a setPages((prev) => ({ ...prev, [tab]: page }));
// truthy is_favorite flag.
const myAgents = useMemo(
() =>
agents.filter(
(a) =>
((a.user_id !== null && a.user_id === currentUserId) ||
a.is_favorite) &&
matchesSearch(a),
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[agents, currentUserId, search],
);
const filteredPrivateAgents = useMemo(() => {
const wanted = new Set(tagIds);
return privateAgents.filter((a) => {
if (!matchesSearch(a)) return false;
if (wanted.size > 0) {
if (!(a.tags ?? []).some((tg) => wanted.has(tg.id))) return false;
}
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [privateAgents, search, tagIds]);
const handleNewAgent = () => { const handleNewAgent = () => {
router.push("/page/workspace/agents/new"); router.push("/page/workspace/agents/new");
@@ -136,7 +122,7 @@ export function AgentGallery() {
setPrivatePassword(null); setPrivatePassword(null);
}; };
const renderGrid = (list: typeof agents) => ( const renderGrid = (list: Agent[]) => (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((agent) => ( {list.map((agent) => (
<AgentCard key={agent.id} agent={agent} /> <AgentCard key={agent.id} agent={agent} />
@@ -144,6 +130,42 @@ export function AgentGallery() {
</div> </div>
); );
/** Loading / empty / (grid + pagination) for one paginated tab. */
const renderListBody = (opts: {
list: Agent[];
total: number;
isLoading: boolean;
tab: "all" | "mine" | "private";
emptyDescription: string;
showNewButton?: boolean;
}) => {
const { list, total, isLoading, tab, emptyDescription, showNewButton } = opts;
// keepPreviousData keeps the old page visible while a new page loads, so
// only show the spinner on the very first load (no rows yet).
if (isLoading && list.length === 0) {
return (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
);
}
if (list.length === 0) {
return renderEmpty(emptyDescription, showNewButton);
}
return (
<div className="flex flex-col gap-4">
{renderGrid(list)}
<Pagination
page={pages[tab]}
pageSize={PAGE_SIZE}
total={total}
onPageChange={(p) => setTabPage(tab, p)}
className="pt-2"
/>
</div>
);
};
const renderEmpty = (description: string, showNewButton = false) => ( const renderEmpty = (description: string, showNewButton = false) => (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center"> <div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full dark:bg-[#022248]"> <div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full dark:bg-[#022248]">
@@ -225,19 +247,13 @@ export function AgentGallery() {
); );
} }
if (isPrivateLoading) { return renderListBody({
return ( list: privateAgents,
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm"> total: privateTotal,
{t.common.loading} isLoading: isPrivateLoading,
</div> tab: "private",
); emptyDescription: "私密广场暂时还没有智能体。",
} });
if (filteredPrivateAgents.length === 0) {
return renderEmpty("私密广场暂时还没有智能体。");
}
return renderGrid(filteredPrivateAgents);
}; };
return ( return (
@@ -276,17 +292,22 @@ export function AgentGallery() {
</TabsTrigger> </TabsTrigger>
)} )}
</TabsList> </TabsList>
{activeTab === "private" && privatePassword && ( <div className="flex items-center gap-3">
<Button <span className="text-muted-foreground text-sm whitespace-nowrap">
variant="outline" 共 {activeTotal} 个
size="sm" </span>
onClick={handleLockPrivate} {activeTab === "private" && privatePassword && (
title="锁定私密广场,下次需要重新输入密码" <Button
> variant="outline"
<LockIcon className="mr-1.5 h-3.5 w-3.5" /> size="sm"
锁定 onClick={handleLockPrivate}
</Button> title="锁定私密广场,下次需要重新输入密码"
)} >
<LockIcon className="mr-1.5 h-3.5 w-3.5" />
锁定
</Button>
)}
</div>
</div> </div>
<TagSearchBar <TagSearchBar
targetType="agent" targetType="agent"
@@ -297,27 +318,24 @@ export function AgentGallery() {
</div> </div>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0"> <TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
{isLoading ? ( {renderListBody({
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm"> list: mainAgents,
{t.common.loading} total: mainTotal,
</div> isLoading: mainLoading,
) : allAgents.length === 0 ? ( tab: "all",
renderEmpty(t.agents.emptyDescription) emptyDescription: t.agents.emptyDescription,
) : ( })}
renderGrid(allAgents)
)}
</TabsContent> </TabsContent>
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0"> <TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
{isLoading ? ( {renderListBody({
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm"> list: mainAgents,
{t.common.loading} total: mainTotal,
</div> isLoading: mainLoading,
) : myAgents.length === 0 ? ( tab: "mine",
renderEmpty(t.agents.emptyDescription, true) emptyDescription: t.agents.emptyDescription,
) : ( showNewButton: true,
renderGrid(myAgents) })}
)}
</TabsContent> </TabsContent>
{privateFeatureAvailable && ( {privateFeatureAvailable && (
@@ -19,15 +19,32 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useAgents } from "@/core/agents/hooks";
import { import {
useCreateCustomPrompt, useCreateCustomPrompt,
useCustomPrompts, useCustomPrompts,
useDeleteCustomPrompt, useDeleteCustomPrompt,
useUpdateCustomPrompt, useUpdateCustomPrompt,
} from "@/core/custom-prompts"; } from "@/core/custom-prompts";
import {
useUpdateUserPreferences,
useUserPreferences,
} from "@/core/user-preferences";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** Sentinel select value for "no agent association" (global prompt). */
const NO_AGENT = "__global__";
/** /**
* Bar above the chat input showing the user's saved prompts as centered, * Bar above the chat input showing the user's saved prompts as centered,
* clickable chips. When there are too many to fit, they become a horizontal * clickable chips. When there are too many to fit, they become a horizontal
@@ -38,11 +55,21 @@ import { cn } from "@/lib/utils";
export function CustomPromptBar({ export function CustomPromptBar({
onInsert, onInsert,
className, className,
currentAgentId,
}: { }: {
onInsert: (content: string) => void; onInsert: (content: string) => void;
className?: string; className?: string;
/**
* The agent currently selected for this chat (or null on the general
* new-chat page). Agent-associated prompts only show when their ``agent_id``
* matches; un-associated (global) prompts always show.
*/
currentAgentId?: string | null;
}) { }) {
const prompts = useCustomPrompts(); const allPrompts = useCustomPrompts();
const prompts = allPrompts.filter(
(p) => !p.agent_id || p.agent_id === currentAgentId,
);
const [manageOpen, setManageOpen] = useState(false); const [manageOpen, setManageOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@@ -153,16 +180,24 @@ function ManagePromptsDialog({
const createPrompt = useCreateCustomPrompt(); const createPrompt = useCreateCustomPrompt();
const updatePrompt = useUpdateCustomPrompt(); const updatePrompt = useUpdateCustomPrompt();
const deletePrompt = useDeleteCustomPrompt(); const deletePrompt = useDeleteCustomPrompt();
const { agents } = useAgents();
const preferences = useUserPreferences();
const updatePreferences = useUpdateUserPreferences();
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [agentId, setAgentId] = useState<string>(NO_AGENT);
const saving = createPrompt.isPending || updatePrompt.isPending; const saving = createPrompt.isPending || updatePrompt.isPending;
const agentNameById = (id?: string | null) =>
id ? agents.find((a) => a.id === id)?.name ?? null : null;
const resetForm = () => { const resetForm = () => {
setEditingId(null); setEditingId(null);
setTitle(""); setTitle("");
setContent(""); setContent("");
setAgentId(NO_AGENT);
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -171,15 +206,20 @@ function ManagePromptsDialog({
toast.error("提示词内容不能为空"); toast.error("提示词内容不能为空");
return; return;
} }
const boundAgentId = agentId === NO_AGENT ? null : agentId;
try { try {
if (editingId) { if (editingId) {
await updatePrompt.mutateAsync({ await updatePrompt.mutateAsync({
id: editingId, id: editingId,
input: { title: trimmedTitle, content }, input: { title: trimmedTitle, content, agent_id: boundAgentId },
}); });
toast.success("提示词已更新"); toast.success("提示词已更新");
} else { } else {
await createPrompt.mutateAsync({ title: trimmedTitle, content }); await createPrompt.mutateAsync({
title: trimmedTitle,
content,
agent_id: boundAgentId,
});
toast.success("提示词已添加"); toast.success("提示词已添加");
} }
resetForm(); resetForm();
@@ -194,6 +234,15 @@ function ManagePromptsDialog({
setEditingId(id); setEditingId(id);
setTitle(target.title); setTitle(target.title);
setContent(target.content); setContent(target.content);
setAgentId(target.agent_id || NO_AGENT);
};
const handleToggleHideAdmin = async (next: boolean) => {
try {
await updatePreferences.mutateAsync({ hide_admin_prompt_prefix: next });
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
@@ -221,6 +270,20 @@ function ManagePromptsDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex items-start justify-between gap-3 rounded-md border p-3">
<div className="min-w-0">
<p className="text-sm font-medium">只用我自己的提示词</p>
<p className="text-muted-foreground mt-0.5 text-xs">
开启后,输入框不再显示管理员配置的提示词(提示词前缀)开关,本账号仅使用自己保存的提示词。
</p>
</div>
<Switch
checked={preferences.hide_admin_prompt_prefix}
disabled={updatePreferences.isPending}
onCheckedChange={(v) => void handleToggleHideAdmin(v)}
/>
</div>
<div className="space-y-3 rounded-md border p-3"> <div className="space-y-3 rounded-md border p-3">
<Input <Input
value={title} value={title}
@@ -233,6 +296,28 @@ function ManagePromptsDialog({
placeholder="提示词内容" placeholder="提示词内容"
className="min-h-28" className="min-h-28"
/> />
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">关联智能体(可选)</p>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectValue placeholder="不关联(所有对话可见)" />
</SelectTrigger>
<SelectContent
position="popper"
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
>
<SelectItem value={NO_AGENT}>不关联(所有对话可见)</SelectItem>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<span className="block truncate">{desensitize(agent.name)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
关联后,仅在选中该智能体的对话里显示此提示词。
</p>
</div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{editingId && ( {editingId && (
<Button variant="outline" size="sm" onClick={resetForm} disabled={saving}> <Button variant="outline" size="sm" onClick={resetForm} disabled={saving}>
@@ -260,6 +345,11 @@ function ManagePromptsDialog({
<p className="truncate text-sm font-medium"> <p className="truncate text-sm font-medium">
{prompt.title || "未命名提示词"} {prompt.title || "未命名提示词"}
</p> </p>
{prompt.agent_id && (
<p className="mt-0.5 truncate text-[11px] text-sky-600 dark:text-sky-400">
仅 {desensitize(agentNameById(prompt.agent_id) || "未知智能体")} 可见
</p>
)}
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs whitespace-pre-wrap"> <p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs whitespace-pre-wrap">
{prompt.content} {prompt.content}
</p> </p>
@@ -131,6 +131,7 @@ export function InputBox({
onStop, onStop,
promptPrefix, promptPrefix,
agentSelector, agentSelector,
assistantId,
...props ...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & { }: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null; assistantId?: string | null;
@@ -564,6 +565,7 @@ export function InputBox({
{isNewThread && ( {isNewThread && (
<CustomPromptBar <CustomPromptBar
className="px-1" className="px-1"
currentAgentId={agentSelector?.selectedAgentId ?? assistantId ?? null}
onInsert={(content) => { onInsert={(content) => {
const current = textInput.value?.trim() ?? ""; const current = textInput.value?.trim() ?? "";
textInput.setInput(current ? `${current}\n${content}` : content); textInput.setInput(current ? `${current}\n${content}` : content);
@@ -473,6 +473,16 @@ const CATEGORY_COLORS: Record<string, string> = {
correction: "bg-red-100 text-red-700", correction: "bg-red-100 text-red-700",
}; };
/** 事实分类的中文标签。 */
const CATEGORY_LABELS: Record<string, string> = {
preference: "偏好",
knowledge: "知识",
context: "背景",
behavior: "行为",
goal: "目标",
correction: "纠正",
};
/** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */ /** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */
function MemoryV1View({ function MemoryV1View({
memory, memory,
@@ -494,8 +504,9 @@ function MemoryV1View({
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。 V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
</div> </div>
<Tabs defaultValue="facts"> <Tabs defaultValue="profile">
<TabsList> <TabsList>
<TabsTrigger value="profile">用户画像 &amp; 历史</TabsTrigger>
<TabsTrigger value="facts"> <TabsTrigger value="facts">
事实记录 事实记录
{memory.facts.length > 0 && ( {memory.facts.length > 0 && (
@@ -504,55 +515,8 @@ function MemoryV1View({
</span> </span>
)} )}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="profile">用户画像 &amp; 历史</TabsTrigger>
</TabsList> </TabsList>
{/* 事实记录 Tab */}
<TabsContent value="facts" className="mt-3">
<div className="rounded-lg border p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]">
<div className="flex items-center justify-between">
<h3 className="text-base font-medium">事实记录</h3>
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
</div>
{memory.facts.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground ">
暂无事实记录,对话后系统会自动提取。
</div>
) : (
<div className="space-y-2">
{memory.facts.map((fact) => (
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
<div className="flex items-center gap-2">
<span
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
}`}
>
{fact.category}
</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
/>
</div>
<span className="text-xs text-muted-foreground shrink-0">
{Math.round(fact.confidence * 100)}%
</span>
</div>
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(fact.content)}</p>
{fact.sourceError && (
<p className="rounded bg-red-50 px-2 py-1 text-xs text-red-600 dark:bg-[#311629] dark:text-[#EE212C]">
避免:{desensitize(fact.sourceError)}
</p>
)}
</div>
))}
</div>
)}
</div>
</TabsContent>
{/* 用户画像 & 历史摘要 Tab */} {/* 用户画像 & 历史摘要 Tab */}
<TabsContent value="profile" className="mt-3 space-y-4"> <TabsContent value="profile" className="mt-3 space-y-4">
{/* 用户画像 (userContext) */} {/* 用户画像 (userContext) */}
@@ -593,6 +557,49 @@ function MemoryV1View({
)} )}
</div> </div>
</TabsContent> </TabsContent>
{/* 事实记录 Tab */}
<TabsContent value="facts" className="mt-3">
<div className="rounded-lg border p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]">
<div className="flex items-center justify-between">
<h3 className="text-base font-medium">事实记录</h3>
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
</div>
{memory.facts.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground ">
暂无事实记录,对话后系统会自动提取。
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{memory.facts.map((fact) => (
<div
key={fact.id}
className="flex flex-col gap-2 rounded-lg border p-3 shadow-sm dark:border-[#213D5E]"
>
<div className="flex items-center justify-between gap-2">
<span
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
}`}
>
{CATEGORY_LABELS[fact.category] ?? fact.category}
</span>
<span className="shrink-0 text-xs font-medium text-muted-foreground">
置信度 {Math.round(fact.confidence * 100)}%
</span>
</div>
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(fact.content)}</p>
{fact.sourceError && (
<p className="rounded bg-red-50 px-2 py-1 text-xs text-red-600 dark:bg-[#311629] dark:text-[#EE212C]">
避免:{desensitize(fact.sourceError)}
</p>
)}
</div>
))}
</div>
)}
</div>
</TabsContent>
</Tabs> </Tabs>
</div> </div>
); );
+84 -2
View File
@@ -5,6 +5,7 @@ import type {
Agent, Agent,
AgentSelectorResponse, AgentSelectorResponse,
CreateAgentRequest, CreateAgentRequest,
PaginatedAgents,
UpdateAgentRequest, UpdateAgentRequest,
} from "./types"; } from "./types";
@@ -23,13 +24,25 @@ export class AgentNameCheckError extends Error {
export interface ListAgentsParams { export interface ListAgentsParams {
search?: string; search?: string;
tagIds?: string[]; tagIds?: string[];
/** Pagination scope. Omit for the legacy combined-visibility list. */
scope?: "mine" | "square";
/** 1-based page index. Providing this (or scope) enables server-side pagination. */
page?: number;
pageSize?: number;
} }
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> { function buildAgentsQuery(params: ListAgentsParams): string {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search); if (params.search) qs.set("search", params.search);
for (const id of params.tagIds ?? []) qs.append("tag_ids", id); for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
const query = qs.toString(); if (params.scope) qs.set("scope", params.scope);
if (params.page != null) qs.set("page", String(params.page));
if (params.pageSize != null) qs.set("page_size", String(params.pageSize));
return qs.toString();
}
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
const query = buildAgentsQuery(params);
const res = await apiFetch( const res = await apiFetch(
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`, `${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
); );
@@ -38,6 +51,29 @@ export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]
return data.agents; return data.agents;
} }
/** Like {@link listAgents} but returns the pagination envelope (total/page). */
export async function listAgentsPaginated(
params: ListAgentsParams = {},
): Promise<PaginatedAgents> {
const query = buildAgentsQuery(params);
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
);
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
const data = (await res.json()) as {
agents: Agent[];
total?: number;
page?: number;
page_size?: number;
};
return {
agents: data.agents,
total: data.total ?? data.agents.length,
page: data.page ?? params.page ?? 1,
pageSize: data.page_size ?? params.pageSize ?? data.agents.length,
};
}
export async function listAllAgentsAdmin(): Promise<Agent[]> { export async function listAllAgentsAdmin(): Promise<Agent[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents?admin=true`); const res = await apiFetch(`${getBackendBaseURL()}/api/agents?admin=true`);
if (!res.ok) { if (!res.ok) {
@@ -152,6 +188,52 @@ export async function listPrivateAgents(password: string): Promise<Agent[]> {
return data.agents; return data.agents;
} }
export interface ListPrivateAgentsParams {
password: string;
search?: string;
tagIds?: string[];
page?: number;
pageSize?: number;
}
/** Paginated private-square listing (password-gated). */
export async function listPrivateAgentsPaginated(
params: ListPrivateAgentsParams,
): Promise<PaginatedAgents> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents/private-square/list`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
password: params.password,
search: params.search || undefined,
tag_ids: params.tagIds && params.tagIds.length ? params.tagIds : undefined,
page: params.page,
page_size: params.pageSize,
}),
},
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(
err.detail ?? `Failed to load private agents: ${res.statusText}`,
);
}
const data = (await res.json()) as {
agents: Agent[];
total?: number;
page?: number;
page_size?: number;
};
return {
agents: data.agents,
total: data.total ?? data.agents.length,
page: data.page ?? params.page ?? 1,
pageSize: data.page_size ?? params.pageSize ?? data.agents.length,
};
}
export async function setAgentPrivate( export async function setAgentPrivate(
id: string, id: string,
isPrivate: boolean, isPrivate: boolean,
+115 -16
View File
@@ -13,15 +13,42 @@ import {
getAgent, getAgent,
getAgentSelector, getAgentSelector,
listAgents, listAgents,
listAgentsPaginated,
listAllAgentsAdmin, listAllAgentsAdmin,
listPrivateAgents, listPrivateAgents,
listPrivateAgentsPaginated,
reorderFeaturedAgents, reorderFeaturedAgents,
setAgentFeatured, setAgentFeatured,
setAgentPrivate, setAgentPrivate,
unfavoriteAgent, unfavoriteAgent,
updateAgent, updateAgent,
} from "./api"; } from "./api";
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types"; import type {
Agent,
CreateAgentRequest,
PaginatedAgents,
UpdateAgentRequest,
} from "./types";
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
* or the paginated ``{ agents, total, ... }`` envelope. Mutators patch both. */
type CachedAgents = Agent[] | PaginatedAgents | undefined;
function isPaginated(value: CachedAgents): value is PaginatedAgents {
return !!value && !Array.isArray(value) && Array.isArray(value.agents);
}
/** Apply ``map`` to every agent inside whatever listing shape is cached. */
function mapCachedAgents(
queryClient: ReturnType<typeof useQueryClient>,
map: (a: Agent) => Agent,
) {
queryClient.setQueriesData<CachedAgents>({ queryKey: ["agents"] }, (old) => {
if (Array.isArray(old)) return old.map(map);
if (isPaginated(old)) return { ...old, agents: old.agents.map(map) };
return old;
});
}
/** Patch an updated agent into every cached agent listing immediately so /** Patch an updated agent into every cached agent listing immediately so
* cards re-render before the background refetch finishes. Without this the * cards re-render before the background refetch finishes. Without this the
@@ -32,13 +59,7 @@ function applyAgentToCache(
queryClient: ReturnType<typeof useQueryClient>, queryClient: ReturnType<typeof useQueryClient>,
next: Agent, next: Agent,
) { ) {
queryClient.setQueriesData<Agent[] | undefined>( mapCachedAgents(queryClient, (a) => (a.id === next.id ? { ...a, ...next } : a));
{ queryKey: ["agents"] },
(old) => {
if (!Array.isArray(old)) return old;
return old.map((a) => (a.id === next.id ? { ...a, ...next } : a));
},
);
} }
export function useAgents(params: { search?: string; tagIds?: string[] } = {}) { export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
@@ -52,6 +73,42 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
return { agents: data ?? [], isLoading, error }; return { agents: data ?? [], isLoading, error };
} }
/** Server-side paginated agent listing for one gallery tab (scope). Returns
* the page of agents plus the total count for the "共 N 个" header. */
export function useAgentsPaginated(params: {
scope: "mine" | "square";
search?: string;
tagIds?: string[];
page: number;
pageSize: number;
enabled?: boolean;
}) {
const { scope, page, pageSize, enabled = true } = params;
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const { data, isLoading, error } = useQuery({
queryKey: [
"agents",
"paginated",
scope,
search,
[...tagIds].sort(),
page,
pageSize,
],
queryFn: () =>
listAgentsPaginated({ scope, search, tagIds, page, pageSize }),
enabled,
placeholderData: keepPreviousData,
});
return {
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
error,
};
}
/** /**
* Admin-only listing — returns every agent including unpublished agents owned * Admin-only listing — returns every agent including unpublished agents owned
* by other users and private-square entries. Caller must be ``system_role * by other users and private-square entries. Caller must be ``system_role
@@ -123,14 +180,8 @@ export function useToggleAgentFavorite() {
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) => mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
favorite ? favoriteAgent(id) : unfavoriteAgent(id), favorite ? favoriteAgent(id) : unfavoriteAgent(id),
onMutate: ({ id, favorite }) => { onMutate: ({ id, favorite }) => {
queryClient.setQueriesData<Agent[] | undefined>( mapCachedAgents(queryClient, (a) =>
{ queryKey: ["agents"] }, a.id === id ? { ...a, is_favorite: favorite } : a,
(old) =>
Array.isArray(old)
? old.map((a) =>
a.id === id ? { ...a, is_favorite: favorite } : a,
)
: old,
); );
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) => queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
old ? { ...old, is_favorite: favorite } : old, old ? { ...old, is_favorite: favorite } : old,
@@ -196,6 +247,54 @@ export function usePrivateAgents(password: string | null) {
return { agents: data ?? [], isLoading, error, refetch }; return { agents: data ?? [], isLoading, error, refetch };
} }
/** Paginated variant of {@link usePrivateAgents} — one page of the
* password-gated private square plus the total count. */
export function usePrivateAgentsPaginated(
password: string | null,
params: {
search?: string;
tagIds?: string[];
page: number;
pageSize: number;
enabled?: boolean;
},
) {
const { page, pageSize, enabled = true } = params;
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const { data, isLoading, error, refetch } = useQuery({
queryKey: [
"agents",
"private-square",
password ?? "",
"paginated",
search,
[...tagIds].sort(),
page,
pageSize,
],
queryFn: () =>
listPrivateAgentsPaginated({
password: password!,
search,
tagIds,
page,
pageSize,
}),
enabled: !!password && enabled,
staleTime: 30_000,
retry: false,
placeholderData: keepPreviousData,
});
return {
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
error,
refetch,
};
}
export function useSetAgentPrivate() { export function useSetAgentPrivate() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
+8
View File
@@ -24,6 +24,14 @@ export interface AgentSelectorResponse {
agents: Agent[]; agents: Agent[];
} }
/** Pagination envelope returned by the server-side paginated agent listings. */
export interface PaginatedAgents {
agents: Agent[];
total: number;
page: number;
pageSize: number;
}
export interface CreateAgentRequest { export interface CreateAgentRequest {
name: string; name: string;
description?: string; description?: string;
@@ -9,6 +9,12 @@ export interface CustomPrompt {
title: string; title: string;
content: string; content: string;
sort_order?: number; sort_order?: number;
/**
* Optional association to a single agent. ``null``/absent = global (shown for
* every agent); otherwise the prompt only surfaces when that agent is
* selected.
*/
agent_id?: string | null;
created_at?: string | null; created_at?: string | null;
updated_at?: string | null; updated_at?: string | null;
} }
@@ -27,6 +33,7 @@ async function listUserPrompts(): Promise<CustomPrompt[]> {
async function createUserPrompt(input: { async function createUserPrompt(input: {
title: string; title: string;
content: string; content: string;
agent_id?: string | null;
}): Promise<CustomPrompt> { }): Promise<CustomPrompt> {
const res = await apiFetch(baseUrl(), { const res = await apiFetch(baseUrl(), {
method: "POST", method: "POST",
@@ -42,7 +49,7 @@ async function createUserPrompt(input: {
async function updateUserPrompt( async function updateUserPrompt(
id: string, id: string,
input: { title?: string; content?: string }, input: { title?: string; content?: string; agent_id?: string | null },
): Promise<CustomPrompt> { ): Promise<CustomPrompt> {
const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, { const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, {
method: "PUT", method: "PUT",
@@ -93,7 +100,7 @@ export function useUpdateCustomPrompt() {
input, input,
}: { }: {
id: string; id: string;
input: { title?: string; content?: string }; input: { title?: string; content?: string; agent_id?: string | null };
}) => updateUserPrompt(id, input), }) => updateUserPrompt(id, input),
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: QUERY_KEY }); void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
@@ -26,6 +26,14 @@ export type HtmlPageConfig = {
export type ScheduledTaskExecutionContext = { export type ScheduledTaskExecutionContext = {
require_markdown?: boolean; require_markdown?: boolean;
/**
* The unified "必须生成 HTML" switch. The task form no longer splits "普通任务 /
* HTML 页面" into separate kinds — instead ``require_html`` and
* ``require_markdown`` are two independent toggles that may both be on. Legacy
* tasks created under the old two-tab UI still carry ``task_kind`` and are
* treated as HTML tasks by the backend for backward compatibility.
*/
require_html?: boolean;
task_kind?: ScheduledTaskKind; task_kind?: ScheduledTaskKind;
html_config?: HtmlPageConfig; html_config?: HtmlPageConfig;
[key: string]: unknown; [key: string]: unknown;
+8 -2
View File
@@ -27,6 +27,12 @@ import type { Skill, SkillState } from "./type";
import { loadAllSkillsAdmin, loadSkills } from "."; import { loadAllSkillsAdmin, loadSkills } from ".";
// Stable empty fallback. Returning `data ?? []` inline would hand out a fresh
// array reference on every render while the query is loading/errored, which
// makes downstream `useMemo([skills])` / `useEffect([skills])` consumers churn
// forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage).
const EMPTY_SKILLS: Skill[] = [];
export function useSkills(params: { search?: string; tagIds?: string[] } = {}) { export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
const search = params.search ?? ""; const search = params.search ?? "";
const tagIds = params.tagIds ?? []; const tagIds = params.tagIds ?? [];
@@ -35,7 +41,7 @@ export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
queryFn: () => loadSkills({ search, tagIds }), queryFn: () => loadSkills({ search, tagIds }),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
return { skills: data ?? [], isLoading, error }; return { skills: data ?? EMPTY_SKILLS, isLoading, error };
} }
/** /**
@@ -50,7 +56,7 @@ export function useAllSkillsAdmin(enabled: boolean) {
enabled, enabled,
staleTime: 30_000, staleTime: 30_000,
}); });
return { skills: data ?? [], isLoading, error }; return { skills: data ?? EMPTY_SKILLS, isLoading, error };
} }
export function useSkillContent(skillName: string | null) { export function useSkillContent(skillName: string | null) {
@@ -0,0 +1,59 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
/** Per-user UI preferences, bound to the account (stored server-side). */
export interface UserPreferences {
/**
* When true, the chat input hides the admin-configured prompt-prefix switch
* and never applies the prefix — the user uses only their own prompts.
*/
hide_admin_prompt_prefix: boolean;
}
const QUERY_KEY = ["user-preferences"] as const;
const DEFAULT_PREFS: UserPreferences = { hide_admin_prompt_prefix: false };
const baseUrl = () => `${getBackendBaseURL()}/api/user-preferences`;
async function getUserPreferences(): Promise<UserPreferences> {
const res = await apiFetch(baseUrl());
if (!res.ok) throw new Error(`Failed to load preferences: ${res.statusText}`);
const data = (await res.json()) as Partial<UserPreferences>;
return { hide_admin_prompt_prefix: !!data.hide_admin_prompt_prefix };
}
async function updateUserPreferences(
input: Partial<UserPreferences>,
): Promise<UserPreferences> {
const res = await apiFetch(baseUrl(), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to update preferences: ${res.statusText}`);
}
const data = (await res.json()) as Partial<UserPreferences>;
return { hide_admin_prompt_prefix: !!data.hide_admin_prompt_prefix };
}
/** Reactive current-user preferences (server-backed, follows the account). */
export function useUserPreferences(): UserPreferences {
const { data } = useQuery({
queryKey: QUERY_KEY,
queryFn: getUserPreferences,
});
return data ?? DEFAULT_PREFS;
}
export function useUpdateUserPreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateUserPreferences,
onSuccess: (data) => {
queryClient.setQueryData(QUERY_KEY, data);
},
});
}
+1
View File
@@ -250,6 +250,7 @@ export default function AgentChatPage() {
className="w-full" className="w-full"
isNewThread={isLandingState} isNewThread={isLandingState}
threadId={threadId} threadId={threadId}
assistantId={agent_id}
autoFocus={isLandingState} autoFocus={isLandingState}
status={ status={
thread.error thread.error
+15 -1
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { usePromptPrefixSettings } from "@/core/system-settings/hooks"; import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
import { useUserPreferences } from "@/core/user-preferences";
import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts"; import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts";
import { import {
ChatBox, ChatBox,
@@ -50,6 +51,9 @@ export default function ChatPage() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels(); const { tokenUsageEnabled } = useModels();
const { data: promptPrefixSettings } = usePromptPrefixSettings(); const { data: promptPrefixSettings } = usePromptPrefixSettings();
// Personal opt-out: when set, hide the admin prompt-prefix switch entirely and
// never apply the prefix — the user uses only their own prompts.
const { hide_admin_prompt_prefix: hideAdminPrompt } = useUserPreferences();
const [promptPrefixOn, setPromptPrefixOn] = useState(false); const [promptPrefixOn, setPromptPrefixOn] = useState(false);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null); const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const { agents: selectorAgents, isLoading: selectorLoading } = const { agents: selectorAgents, isLoading: selectorLoading } =
@@ -159,6 +163,7 @@ export default function ChatPage() {
isNewThread && isNewThread &&
!hasStartedConversation && !hasStartedConversation &&
promptPrefixOn && promptPrefixOn &&
!hideAdminPrompt &&
promptPrefixSettings?.enabled === true; promptPrefixSettings?.enabled === true;
setHasStartedConversation(true); setHasStartedConversation(true);
window.dispatchEvent( window.dispatchEvent(
@@ -189,6 +194,7 @@ export default function ChatPage() {
hasStartedConversation, hasStartedConversation,
promptPrefixOn, promptPrefixOn,
promptPrefixSettings, promptPrefixSettings,
hideAdminPrompt,
selectedAgentId, selectedAgentId,
router, router,
settings.context.writing_mode, settings.context.writing_mode,
@@ -217,6 +223,8 @@ export default function ChatPage() {
const promptPrefixProp = useMemo(() => { const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined; if (!promptPrefixSettings?.enabled) return undefined;
// Personal opt-out hides the admin prompt-prefix switch entirely.
if (hideAdminPrompt) return undefined;
// The prefix toggle is offered only on the opening turn of a new thread. // The prefix toggle is offered only on the opening turn of a new thread.
if (!isNewThread || hasStartedConversation) return undefined; if (!isNewThread || hasStartedConversation) return undefined;
return { return {
@@ -224,7 +232,13 @@ export default function ChatPage() {
on: promptPrefixOn, on: promptPrefixOn,
onToggle: () => setPromptPrefixOn((v) => !v), onToggle: () => setPromptPrefixOn((v) => !v),
}; };
}, [promptPrefixSettings, isNewThread, hasStartedConversation, promptPrefixOn]); }, [
promptPrefixSettings,
hideAdminPrompt,
isNewThread,
hasStartedConversation,
promptPrefixOn,
]);
const messageListPaddingBottom = showFollowups const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM + ? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
+31 -4
View File
@@ -19,6 +19,11 @@ import {
} from "@/components/ai-elements/prompt-input"; } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -361,8 +366,19 @@ export default function NewAgentPage() {
) : null} ) : null}
</header> </header>
<main className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[minmax(0,1fr)_420px]"> <ResizablePanelGroup
<section className="flex min-h-0 flex-col border-r"> id="new-agent-config-panels"
orientation="horizontal"
defaultLayout={{ chat: 62, config: 38 }}
className="min-h-0 flex-1"
>
<ResizablePanel
id="chat"
defaultSize={62}
minSize={40}
className="min-h-0"
>
<section className="flex size-full min-h-0 flex-col">
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
<MessageList <MessageList
className="size-full" className="size-full"
@@ -428,8 +444,18 @@ export default function NewAgentPage() {
</PromptInput> </PromptInput>
</div> </div>
</section> </section>
</ResizablePanel>
<aside className="bg-background min-h-0 overflow-y-auto"> <ResizableHandle withHandle />
<ResizablePanel
id="config"
defaultSize={38}
minSize={24}
maxSize={60}
className="min-h-0"
>
<aside className="bg-background size-full min-h-0 overflow-y-auto">
<div className="space-y-5 p-4"> <div className="space-y-5 p-4">
<div> <div>
<h2 className="text-sm font-semibold">智能体配置</h2> <h2 className="text-sm font-semibold">智能体配置</h2>
@@ -606,7 +632,8 @@ export default function NewAgentPage() {
</Button> </Button>
</div> </div>
</aside> </aside>
</main> </ResizablePanel>
</ResizablePanelGroup>
</div> </div>
</ArtifactsProvider> </ArtifactsProvider>
</ThreadContext.Provider> </ThreadContext.Provider>
+105 -109
View File
@@ -114,13 +114,15 @@ const emptyForm = {
timezone: "Asia/Shanghai", timezone: "Asia/Shanghai",
prompt: "", prompt: "",
execution_agent_name: "", execution_agent_name: "",
// Two independent output switches (may both be on): require a Markdown file
// and/or render an HTML page. The form no longer splits into "普通任务 / HTML
// 页面" tabs.
require_markdown: false, require_markdown: false,
// HTML page task fields. ``task_kind`` "agent" keeps the original behavior. require_html: false,
// Data gathering is delegated to the agent's own search skills — no explicit // HTML-only extra fields, shown when ``require_html`` is on. The main
// search query / data source. ``html_model_name`` empty means "use the first // ``prompt`` above doubles as the page requirement, so there is no separate
// available model" — never a hardcoded GLM-5. // page-prompt field. ``html_model_name`` empty means "use the first available
task_kind: "agent" as "agent" | "html_page", // model" — never a hardcoded GLM-5.
html_page_prompt: "",
html_negative_prompt: "", html_negative_prompt: "",
html_reference_favorite_id: "", html_reference_favorite_id: "",
html_model_name: "", html_model_name: "",
@@ -244,11 +246,15 @@ export default function ScheduledTasksPage() {
description: task.description ?? "", description: task.description ?? "",
schedule: task.schedule_text, schedule: task.schedule_text,
timezone: task.timezone, timezone: task.timezone,
prompt: task.prompt, // The main prompt doubles as the HTML page requirement; legacy HTML tasks
// stored that under ``html_config.page_prompt``, so prefer it on edit.
prompt: html.page_prompt || task.prompt,
execution_agent_name: task.execution_agent_name ?? "", execution_agent_name: task.execution_agent_name ?? "",
require_markdown: !!task.execution_context?.require_markdown, require_markdown: !!task.execution_context?.require_markdown,
task_kind: task.execution_context?.task_kind === "html_page" ? "html_page" : "agent", // Backward compatible: legacy HTML tasks only carry ``task_kind``.
html_page_prompt: html.page_prompt ?? "", require_html:
!!task.execution_context?.require_html ||
task.execution_context?.task_kind === "html_page",
html_negative_prompt: html.negative_prompt ?? "", html_negative_prompt: html.negative_prompt ?? "",
html_reference_favorite_id: html.reference_favorite_id ?? "", html_reference_favorite_id: html.reference_favorite_id ?? "",
html_model_name: html.model_name ?? "", html_model_name: html.model_name ?? "",
@@ -270,20 +276,26 @@ export default function ScheduledTasksPage() {
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG); setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
}; };
const isHtmlTask = form.task_kind === "html_page"; const isHtmlTask = form.require_html;
// The model actually shown/used: the user's choice, else the first configured // The model actually shown/used: the user's choice, else the first configured
// model. Never a hardcoded GLM-5 — it follows config order and the selection. // model. Never a hardcoded GLM-5 — it follows config order and the selection.
const effectiveHtmlModel = form.html_model_name || availableModels[0]?.name || ""; const effectiveHtmlModel = form.html_model_name || availableModels[0]?.name || "";
// Build the execution_context for the current form. HTML tasks persist their // Build the execution_context for the current form. ``require_html`` and
// config under ``html_config``; regular tasks send an explicit ``agent`` kind // ``require_markdown`` are independent toggles (both may be on). HTML tasks
// so switching a task back from HTML clears the old config. // persist their extra config under ``html_config`` and reuse the main prompt
// as the page requirement; ``task_kind`` is kept in sync only for backward
// compatibility with older readers.
const buildExecutionContext = (): ScheduledTaskExecutionContext => { const buildExecutionContext = (): ScheduledTaskExecutionContext => {
if (!isHtmlTask) return { task_kind: "agent", html_config: undefined }; if (!isHtmlTask) {
return { require_html: false, task_kind: "agent", html_config: undefined };
}
return { return {
require_html: true,
task_kind: "html_page", task_kind: "html_page",
html_config: { html_config: {
page_prompt: form.html_page_prompt.trim() || null, // The main prompt doubles as the page requirement.
page_prompt: form.prompt.trim() || null,
negative_prompt: form.html_negative_prompt.trim() || null, negative_prompt: form.html_negative_prompt.trim() || null,
reference_favorite_id: form.html_reference_favorite_id || null, reference_favorite_id: form.html_reference_favorite_id || null,
// Send the user's selection; fall back to the first configured model, // Send the user's selection; fall back to the first configured model,
@@ -297,11 +309,10 @@ export default function ScheduledTasksPage() {
try { try {
const cronSchedule = buildCron(scheduleConfig); const cronSchedule = buildCron(scheduleConfig);
const executionContext = buildExecutionContext(); const executionContext = buildExecutionContext();
// HTML tasks may leave the agent prompt empty — fall back to the page // The (non-null) prompt column always needs something useful; HTML tasks
// prompt so the (non-null) prompt column always has something useful. // may leave it sparse, so fall back to a generic instruction.
const effectivePrompt = isHtmlTask const effectivePrompt =
? form.prompt.trim() || form.html_page_prompt.trim() || "生成 HTML 页面" form.prompt.trim() || (isHtmlTask ? "生成 HTML 页面" : form.prompt);
: form.prompt;
if (creating) { if (creating) {
const task = await actions.create.mutateAsync({ const task = await actions.create.mutateAsync({
name: form.name || "定时任务", name: form.name || "定时任务",
@@ -309,8 +320,8 @@ export default function ScheduledTasksPage() {
schedule: cronSchedule, schedule: cronSchedule,
timezone: form.timezone || "Asia/Shanghai", timezone: form.timezone || "Asia/Shanghai",
prompt: effectivePrompt, prompt: effectivePrompt,
execution_agent_name: isHtmlTask ? null : form.execution_agent_name || null, execution_agent_name: form.execution_agent_name || null,
require_markdown: isHtmlTask ? false : form.require_markdown, require_markdown: form.require_markdown,
execution_context: executionContext, execution_context: executionContext,
}); });
setSelectedTaskId(task.task_id); setSelectedTaskId(task.task_id);
@@ -324,8 +335,8 @@ export default function ScheduledTasksPage() {
schedule: cronSchedule, schedule: cronSchedule,
timezone: form.timezone, timezone: form.timezone,
prompt: effectivePrompt, prompt: effectivePrompt,
execution_agent_name: isHtmlTask ? null : form.execution_agent_name || null, execution_agent_name: form.execution_agent_name || null,
require_markdown: isHtmlTask ? false : form.require_markdown, require_markdown: form.require_markdown,
execution_context: executionContext, execution_context: executionContext,
}, },
}); });
@@ -502,7 +513,8 @@ export default function ScheduledTasksPage() {
<Globe className="size-3" />已发布 <Globe className="size-3" />已发布
</Badge> </Badge>
)} )}
{task.execution_context?.task_kind === "html_page" && ( {(task.execution_context?.require_html ||
task.execution_context?.task_kind === "html_page") && (
<Badge <Badge
variant="outline" variant="outline"
className="shrink-0 border-sky-500/30 bg-sky-500/10 text-xs text-sky-600 dark:text-sky-400" className="shrink-0 border-sky-500/30 bg-sky-500/10 text-xs text-sky-600 dark:text-sky-400"
@@ -953,20 +965,6 @@ export default function ScheduledTasksPage() {
} }
placeholder="任务描述(可选,将显示在公开页标题下方)" placeholder="任务描述(可选,将显示在公开页标题下方)"
/> />
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">任务类型</p>
<Tabs
value={form.task_kind}
onValueChange={(v) =>
setForm((value) => ({ ...value, task_kind: v as "agent" | "html_page" }))
}
>
<TabsList className="w-full">
<TabsTrigger value="agent" className="flex-1">普通任务</TabsTrigger>
<TabsTrigger value="html_page" className="flex-1">HTML 页面</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">执行频率</p> <p className="text-muted-foreground text-xs">执行频率</p>
<ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} /> <ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} />
@@ -979,77 +977,83 @@ export default function ScheduledTasksPage() {
placeholder="时区,例如:Asia/Shanghai" placeholder="时区,例如:Asia/Shanghai"
/> />
{!isHtmlTask && ( <Select
<> value={form.execution_agent_name || "__default__"}
<Select onValueChange={(v) =>
value={form.execution_agent_name || "__default__"} setForm((value) => ({
onValueChange={(v) => ...value,
setForm((value) => ({ execution_agent_name: v === "__default__" ? "" : v,
...value, }))
execution_agent_name: v === "__default__" ? "" : v, }
})) >
} <SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
> <SelectValue placeholder="选择执行智能体" />
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate"> </SelectTrigger>
<SelectValue placeholder="选择执行智能体" /> <SelectContent
</SelectTrigger> position="popper"
<SelectContent className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
position="popper" >
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]" <SelectItem value="__default__">默认助手(不指定)</SelectItem>
> {agents.map((agent) => (
<SelectItem value="__default__">默认助手(不指定)</SelectItem> <SelectItem key={agent.id} value={agent.id}>
{agents.map((agent) => ( <span className="block truncate">
<SelectItem key={agent.id} value={agent.id}> {desensitize(agent.name)}
<span className="block truncate"> {agent.description ? ` — ${desensitize(agent.description)}` : ""}
{desensitize(agent.name)} </span>
{agent.description ? ` — ${desensitize(agent.description)}` : ""} </SelectItem>
</span> ))}
</SelectItem> </SelectContent>
))} </Select>
</SelectContent> <Textarea
</Select> className="min-h-40"
<Textarea value={form.prompt}
className="min-h-40" onChange={(event) =>
value={form.prompt} setForm((value) => ({ ...value, prompt: event.target.value }))
onChange={(event) => }
setForm((value) => ({ ...value, prompt: event.target.value })) placeholder={
} isHtmlTask
placeholder="任务 Prompt" ? "任务 / 页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
/> : "任务 Prompt"
<div className="flex items-center justify-between rounded-md border p-3"> }
<div className="min-w-0 pr-3"> />
<p className="text-sm font-medium">必须生成 Markdown 文件</p> <div className="flex items-center justify-between rounded-md border p-3">
<p className="text-muted-foreground text-xs"> <div className="min-w-0 pr-3">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。 <p className="text-sm font-medium">必须生成 Markdown 文件</p>
</p> <p className="text-muted-foreground text-xs">
</div> 开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
<Switch </p>
checked={form.require_markdown} </div>
onCheckedChange={(v) => <Switch
setForm((value) => ({ ...value, require_markdown: v })) checked={form.require_markdown}
} onCheckedChange={(v) =>
/> setForm((value) => ({ ...value, require_markdown: v }))
</div> }
</> />
)} </div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 HTML 页面</p>
<p className="text-muted-foreground text-xs">
开启后会先用智能体检索整理真实数据,再渲染为完整 HTML 页面(可在执行记录中预览)。可与“必须生成 Markdown”同时开启。
</p>
</div>
<Switch
checked={form.require_html}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_html: v }))
}
/>
</div>
{isHtmlTask && ( {isHtmlTask && (
<> <>
<Textarea
className="min-h-24"
value={form.html_page_prompt}
onChange={(event) =>
setForm((value) => ({ ...value, html_page_prompt: event.target.value }))
}
placeholder="页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
/>
<Textarea <Textarea
className="min-h-16" className="min-h-16"
value={form.html_negative_prompt} value={form.html_negative_prompt}
onChange={(event) => onChange={(event) =>
setForm((value) => ({ ...value, html_negative_prompt: event.target.value })) setForm((value) => ({ ...value, html_negative_prompt: event.target.value }))
} }
placeholder="限制条件(可选),例如:不要使用深色背景,不要出现外部链接脚本" placeholder="HTML 限制条件(可选),例如:不要使用深色背景,不要出现外部链接脚本"
/> />
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">参考收藏页面(可选)</p> <p className="text-muted-foreground text-xs">参考收藏页面(可选)</p>
@@ -1127,14 +1131,6 @@ export default function ScheduledTasksPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Textarea
className="min-h-16"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder="补充说明(可选):进一步约束智能体如何检索数据、页面包含哪些内容"
/>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
执行流程:智能体运用自身检索技能整理真实数据 → 选定模型渲染为完整 HTML → 自动安全清洗与结构校验 → 可在执行记录中预览。 执行流程:智能体运用自身检索技能整理真实数据 → 选定模型渲染为完整 HTML → 自动安全清洗与结构校验 → 可在执行记录中预览。
</p> </p>
+3 -1
View File
@@ -21,7 +21,9 @@
"paths": { "paths": {
"@/env": ["./src/env.ts"], "@/env": ["./src/env.ts"],
"@/*": ["./src/*"], "@/*": ["./src/*"],
"@vite/*": ["./src/*"] "@vite/*": ["./src/*"],
"next/navigation": ["./src/shims/next-navigation.ts"],
"next/link": ["./src/shims/next-link.tsx"]
}, },
"types": ["vite/client"] "types": ["vite/client"]
}, },
@@ -261,12 +261,22 @@ Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` →
**HTML page tasks (`task_kind == "html_page"`)**: a second task kind that renders a full HTML document each run instead of a Markdown report. The kind + its config (`html_config`: `page_prompt`, `negative_prompt`, `reference_favorite_id`, `model_name`) live inside `execution_context` — no schema change to `scheduled_tasks`. Data gathering is delegated to the agent's own search skills (driven by `page_prompt`), so there is no explicit search-query/data-source field; the render model is user-selectable (`model_name`, default `zai-org/GLM-5-FP8`). `_run_task_attempt()` dispatches by kind; `_run_html_page_attempt()` runs the pipeline: gather real data via the lead agent (its search tools) → optionally load a reference favorite's reusable style/layout → render with the chosen model (thinking off) → fault-tolerant JSON parse → sanitize (BeautifulSoup, strips `<script>`/`<iframe>`/`on*`/`javascript:`) → structural validate → one GLM5 repair pass if invalid → **completeness gate**: if the HTML is still not a full valid document (missing doctype/html/head/body, empty body, or residual scripts) after the repair pass, raise `HtmlGenerationError` so the run is marked **failed** (a page only counts as successful when it is a complete, previewable HTML document) → best-effort Playwright screenshot (no-op when Playwright absent) → archive `index.html` → persist a `scheduled_html_pages` row → return `result` with `format: "html"` and the full HTML inline. Pure helpers live in `deerflow/runtime/scheduler/html_page.py`. The frontend previews the HTML in a sandboxed `iframe` (no `allow-scripts`) on the run-detail and public pages; a user can bookmark a page (style summary extracted via GLM5) and later pick it as a style reference. New tables `scheduled_html_pages` + `html_page_favorites` (`deerflow/persistence/html_pages/`, wired as `app.state.html_page_store`) are created by `Base.metadata.create_all` with no migration. Tests: `tests/test_html_page_tasks.py`. **HTML page tasks (`task_kind == "html_page"`)**: a second task kind that renders a full HTML document each run instead of a Markdown report. The kind + its config (`html_config`: `page_prompt`, `negative_prompt`, `reference_favorite_id`, `model_name`) live inside `execution_context` — no schema change to `scheduled_tasks`. Data gathering is delegated to the agent's own search skills (driven by `page_prompt`), so there is no explicit search-query/data-source field; the render model is user-selectable (`model_name`, default `zai-org/GLM-5-FP8`). `_run_task_attempt()` dispatches by kind; `_run_html_page_attempt()` runs the pipeline: gather real data via the lead agent (its search tools) → optionally load a reference favorite's reusable style/layout → render with the chosen model (thinking off) → fault-tolerant JSON parse → sanitize (BeautifulSoup, strips `<script>`/`<iframe>`/`on*`/`javascript:`) → structural validate → one GLM5 repair pass if invalid → **completeness gate**: if the HTML is still not a full valid document (missing doctype/html/head/body, empty body, or residual scripts) after the repair pass, raise `HtmlGenerationError` so the run is marked **failed** (a page only counts as successful when it is a complete, previewable HTML document) → best-effort Playwright screenshot (no-op when Playwright absent) → archive `index.html` → persist a `scheduled_html_pages` row → return `result` with `format: "html"` and the full HTML inline. Pure helpers live in `deerflow/runtime/scheduler/html_page.py`. The frontend previews the HTML in a sandboxed `iframe` (no `allow-scripts`) on the run-detail and public pages; a user can bookmark a page (style summary extracted via GLM5) and later pick it as a style reference. New tables `scheduled_html_pages` + `html_page_favorites` (`deerflow/persistence/html_pages/`, wired as `app.state.html_page_store`) are created by `Base.metadata.create_all` with no migration. Tests: `tests/test_html_page_tasks.py`.
**Unified output switches (`require_html` + `require_markdown`).** The task form no longer splits "普通任务 / HTML 页面" into separate kinds. `execution_context` now carries two **independent** booleans that may both be on: `require_html` (必须生成 HTML) and `require_markdown` (必须生成 Markdown). `_task_requires_html()` returns true for `require_html` **or** legacy `task_kind == "html_page"` (so old tasks need no data migration), and `_run_task_attempt()` dispatches to `_run_html_page_attempt` when it is true, else the regular agent attempt. When **both** switches are on, `_gather_html_data()` returns `(text, files)` — the gather agent run is additionally told to save a `.md` file — and `_run_html_page_attempt` validates it (`MarkdownRequirementError` on failure) and surfaces it alongside the generated page, so the run delivers both artifacts. The single main `prompt` doubles as the HTML page requirement (`html_config.page_prompt`); there is no separate page-prompt field. Tests: `tests/test_scheduled_task_require_html.py` + the markdown-coexistence cases in `tests/test_html_page_tasks.py`.
### Per-user Prompts & UI Preferences
**Custom prompts can be scoped to one agent.** `user_custom_prompts` gains a nullable `agent_id` column (migration `20260603_02`): `NULL` = global (the chip shows in every chat), otherwise the prompt only surfaces when that agent is selected. The visibility filter is applied in the frontend `CustomPromptBar` (it receives the current agent id from `ChatPage`'s selector or `AgentChatPage`'s `assistantId`); the backend (`/api/user-prompts` CRUD) just stores/returns/clears `agent_id` (update accepts an explicit `null` to clear). Tests: `tests/test_user_prompts_agent_id.py`.
**Per-user UI preferences** (`deerflow/persistence/user_preferences/`, wired as `app.state.user_preferences_store`; migration `20260603_03`, also auto-created by `create_all`): one JSON-blob row per user so new toggles need no schema change. Router `/api/user-preferences` (`GET`/`PUT`, account-scoped). Current key: `hide_admin_prompt_prefix` — when true the chat input hides the admin-configured prompt-prefix switch and never applies the prefix (the user uses only their own prompts; enforced in `ChatPage` for both `shouldApplyPrefix` and `promptPrefixProp`). Toggled from the "管理常用提示词" dialog. Tests: `tests/test_user_preferences.py`.
**HTML skills (tools the render step uses).** Two offline skills under `skills/public/` shape the generated HTML and are toggled in the skills settings page (enabled by default): `html-page-builder` (a self-contained inline-CSS design system — colors/cards/tables/KPI/responsive) and `html-charts` (inline-SVG charts that render in the no-`allow-scripts` preview iframe; optional local ECharts documented in its `assets/README.md`). A skill opts into HTML injection via `metadata.kind: html_page` in its SKILL.md frontmatter; `_collect_html_skill_instructions()` reads the bodies of **enabled** such skills and `build_generation_prompt(skill_instructions=…)` injects them, so the (raw-model) render step honors the user's skill selection. The data-gathering step and the render/repair/style passes all use the task's selected `model_name` (no silent fallback to the default model — and there is **no hardcoded GLM-5**; an unset selection falls back to config `models[0]`). To stop auxiliary LLM calls from leaking onto the default model, **`TitleMiddleware` is skipped when `is_scheduled_run` is set** (`_build_middlewares` in `lead_agent/agent.py`) — scheduled/HTML runs use throwaway threads whose titles are never shown, and `config.title.model_name` is usually unset (→ would call `models[0]`, not the chosen model). The completeness gate (`validate_html`) also requires a non-empty `<title>` and forbids external stylesheets, on top of the doctype/html/head/body + non-empty-body + no-script checks. **HTML skills (tools the render step uses).** Two offline skills under `skills/public/` shape the generated HTML and are toggled in the skills settings page (enabled by default): `html-page-builder` (a self-contained inline-CSS design system — colors/cards/tables/KPI/responsive) and `html-charts` (inline-SVG charts that render in the no-`allow-scripts` preview iframe; optional local ECharts documented in its `assets/README.md`). A skill opts into HTML injection via `metadata.kind: html_page` in its SKILL.md frontmatter; `_collect_html_skill_instructions()` reads the bodies of **enabled** such skills and `build_generation_prompt(skill_instructions=…)` injects them, so the (raw-model) render step honors the user's skill selection. The data-gathering step and the render/repair/style passes all use the task's selected `model_name` (no silent fallback to the default model — and there is **no hardcoded GLM-5**; an unset selection falls back to config `models[0]`). To stop auxiliary LLM calls from leaking onto the default model, **`TitleMiddleware` is skipped when `is_scheduled_run` is set** (`_build_middlewares` in `lead_agent/agent.py`) — scheduled/HTML runs use throwaway threads whose titles are never shown, and `config.title.model_name` is usually unset (→ would call `models[0]`, not the chosen model). The completeness gate (`validate_html`) also requires a non-empty `<title>` and forbids external stylesheets, on top of the doctype/html/head/body + non-empty-body + no-script checks.
### Custom Agent Persistence ### Custom Agent Persistence
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks. Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
**Listing cache + pagination** — to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term→abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter. `POST /api/agents/private-square/list` takes the same pagination params. Repository entry point: `AgentStore.list_paginated(...)`.
**Homepage selector (agent picker on the new-chat page)** — single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL). **Homepage selector (agent picker on the new-chat page)** — single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL).
### Portable Column Types (`deerflow/persistence/types.py`) ### Portable Column Types (`deerflow/persistence/types.py`)
@@ -57,6 +57,7 @@ from app.gateway.routers import (
thread_shares, thread_shares,
threads, threads,
uploads, uploads,
user_preferences,
user_prompts, user_prompts,
writing, writing,
) )
@@ -857,6 +858,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(user_prompts.router) app.include_router(user_prompts.router)
# Per-user UI preferences at /api/user-preferences
app.include_router(user_preferences.router)
# Roundtable-planning drafts + recommendation history at /api/roundtable-drafts # Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
app.include_router(roundtable_drafts.router) app.include_router(roundtable_drafts.router)
@@ -160,6 +160,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.user_prompt_store = make_user_prompt_store(sf) app.state.user_prompt_store = make_user_prompt_store(sf)
from deerflow.persistence.user_preferences import make_user_preferences_store
app.state.user_preferences_store = make_user_preferences_store(sf)
from deerflow.persistence.roundtable_drafts import make_roundtable_draft_store from deerflow.persistence.roundtable_drafts import make_roundtable_draft_store
app.state.roundtable_draft_store = make_roundtable_draft_store(sf) app.state.roundtable_draft_store = make_roundtable_draft_store(sf)
@@ -66,9 +66,18 @@ class AgentResponse(BaseModel):
class AgentsListResponse(BaseModel): class AgentsListResponse(BaseModel):
"""Response model for listing all visible agents.""" """Response model for listing agents.
``total`` / ``page`` / ``page_size`` describe server-side pagination. When
a caller does not opt into pagination they default to "everything on one
page" (``total == len(agents)``, ``page == 1``), so existing unpaginated
callers keep working unchanged.
"""
agents: list[AgentResponse] agents: list[AgentResponse]
total: int = Field(default=0, description="Total agents matching the query across all pages")
page: int = Field(default=1, description="1-based page index of this response")
page_size: int = Field(default=0, description="Page size; 0 means the result was not paginated")
class AgentCreateRequest(BaseModel): class AgentCreateRequest(BaseModel):
@@ -215,6 +224,7 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
handlers as a safety net for environments that bypassed the lifespan hook. handlers as a safety net for environments that bypassed the lifespan hook.
""" """
try: try:
pairs: list[tuple[str, Any]] = []
for agent_cfg in list_custom_agents(): for agent_cfg in list_custom_agents():
agent_id = agent_cfg.id or agent_cfg.name agent_id = agent_cfg.id or agent_cfg.name
if not agent_id: if not agent_id:
@@ -227,10 +237,41 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
"published": True, "published": True,
} }
) )
pairs.append((agent_id, agent_cfg))
await _backfill_listing_cache(store, pairs)
except Exception: except Exception:
logger.exception("Failed to sync legacy folder-backed agents") logger.exception("Failed to sync legacy folder-backed agents")
async def _backfill_listing_cache(store: AgentStore, pairs: list[tuple[str, Any]]) -> None:
"""Mirror each agent's config.yaml skills/tool_groups/model into the DB cache.
The list endpoint reads skills/tool_groups from these cache rows instead of
stat+parsing every ``config.yaml`` per request. We fetch the current cache
in one batch and only re-write rows whose values actually changed, so a
steady-state sync (every ``_LEGACY_SYNC_TTL_SECONDS``) is cheap and does not
churn DELETE/INSERTs. For the cache, ``None`` and ``[]`` skills are
equivalent ("no skills") — the tri-state only matters to the agent runtime,
which reads config.yaml directly.
"""
if not pairs:
return
cached = await store.get_extras_for([aid for aid, _ in pairs])
for agent_id, cfg in pairs:
want_skills = [s for s in (cfg.skills or []) if isinstance(s, str)]
want_tool_groups = cfg.tool_groups
want_model = cfg.model
existing = cached.get(agent_id)
if existing is None or existing.get("skills") != want_skills:
await store.replace_skills(agent_id, want_skills)
if (
existing is None
or existing.get("tool_groups") != want_tool_groups
or existing.get("model") != want_model
):
await store.set_extras(agent_id, tool_groups=want_tool_groups, model=want_model)
# Throttle the per-request legacy-sync fallback. The real sync runs once at # Throttle the per-request legacy-sync fallback. The real sync runs once at
# startup (lifespan); request handlers used to call ``_sync_legacy_agents`` on # startup (lifespan); request handlers used to call ``_sync_legacy_agents`` on
# every hit, which scanned the filesystem + did N DB upserts per agent each # every hit, which scanned the filesystem + did N DB upserts per agent each
@@ -403,6 +444,38 @@ async def _get_editable_or_raise(
raise HTTPException(status_code=403, detail="You can only modify agents that you created") raise HTTPException(status_code=403, detail="You can only modify agents that you created")
async def _build_list_responses(
store: AgentStore,
tag_store: TagStore,
user_id: str,
records: list[dict[str, Any]],
) -> list[AgentResponse]:
"""Build gallery responses for a set of agent rows WITHOUT touching disk.
skills / tool_groups / model come from the denormalized DB cache
(``get_extras_for`` — one batched query) instead of stat+parsing every
agent's ``config.yaml`` per request, which was the list-endpoint hot spot.
SOUL.md is skipped (only the edit dialog reads it). An agent absent from
the cache (never mirrored) keeps its ``None`` extras until the next sync.
"""
responses = [
_record_to_response(a, include_soul=False, include_extras=False) for a in records
]
ids = [r.id for r in responses]
extras = await store.get_extras_for(ids)
assignments = await tag_store.list_assignments("agent", ids)
favorite_ids = await store.list_favorite_ids(user_id)
for resp in responses:
cached = extras.get(resp.id)
if cached is not None:
resp.skills = cached.get("skills")
resp.tool_groups = cached.get("tool_groups")
resp.model = cached.get("model")
resp.tags = assignments.get(resp.id, [])
resp.is_favorite = resp.id in favorite_ids
return responses
@router.get( @router.get(
"/agents", "/agents",
response_model=AgentsListResponse, response_model=AgentsListResponse,
@@ -411,8 +484,15 @@ async def _get_editable_or_raise(
) )
async def list_agents( async def list_agents(
request: Request, request: Request,
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name"), search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name (raw or desensitized display form)"),
tag_ids: list[str] | None = Query(default=None, description="Keep agents carrying any of these tags (OR)"), tag_ids: list[str] | None = Query(default=None, description="Keep agents carrying any of these tags (OR)"),
scope: str | None = Query(
default=None,
pattern="^(mine|square)$",
description="Pagination scope. 'mine' = owned + favorited; 'square' = built-in/published public. Omit for the default combined visibility list.",
),
page: int | None = Query(default=None, ge=1, description="1-based page index. Providing this (or 'scope') enables server-side pagination."),
page_size: int = Query(default=20, ge=1, le=200, description="Items per page when paginating."),
admin: bool = Query( admin: bool = Query(
default=False, default=False,
description=( description=(
@@ -429,38 +509,36 @@ async def list_agents(
tag_store: TagStore = get_tag_store(request) tag_store: TagStore = get_tag_store(request)
await _maybe_sync_legacy_agents(store) await _maybe_sync_legacy_agents(store)
user_id = _current_user_id(request) user_id = _current_user_id(request)
if admin: if admin and not _is_admin_user(request):
if not _is_admin_user(request): raise HTTPException(status_code=403, detail="Only system admins can list all agents")
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
agents = await store.list_all()
else:
agents = await store.list_visible(user_id)
# Load lightweight extras (skills / model / tool_groups from config.yaml)
# so the gallery cards can render skill badges. SOUL.md is still skipped
# (only the edit dialog reads it). The per-agent config.yaml reads run in
# worker threads concurrently, so the event loop stays responsive even
# with many agents.
responses = list(
await asyncio.gather(
*(
_record_to_response_async(a, include_soul=False, include_extras=True)
for a in agents
)
)
)
assignments = await tag_store.list_assignments("agent", [r.id for r in responses]) paginate = scope is not None or page is not None
favorite_ids = await store.list_favorite_ids(user_id) if paginate:
for resp in responses: eff_page = page or 1
resp.tags = assignments.get(resp.id, []) rows, total = await store.list_paginated(
resp.is_favorite = resp.id in favorite_ids user_id=user_id,
scope=scope,
search=search,
tag_ids=tag_ids,
page=eff_page,
page_size=page_size,
admin=admin,
)
responses = await _build_list_responses(store, tag_store, user_id, rows)
return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size)
# Legacy unpaginated path — returns everything (admin console, callers
# that have not migrated to pagination). Extras now come from the DB
# cache too, so this path no longer reads config.yaml per agent.
agents = await store.list_all() if admin else await store.list_visible(user_id)
responses = await _build_list_responses(store, tag_store, user_id, agents)
if search: if search:
needle = search.strip().lower() needle = search.strip().lower()
responses = [r for r in responses if needle in r.name.lower()] responses = [r for r in responses if needle in r.name.lower()]
if tag_ids: if tag_ids:
wanted = set(tag_ids) wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)] responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
return AgentsListResponse(agents=responses) return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -587,6 +665,10 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
"is_private": request.is_private, "is_private": request.is_private,
} }
) )
# Mirror skills/tool_groups/model into the listing cache so the gallery
# can render this agent's badges without reading its config.yaml.
await store.replace_skills(agent_id, request.skills)
await store.set_extras(agent_id, tool_groups=request.tool_groups, model=request.model)
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir) logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
return await _record_to_response_async(record, include_soul=True, include_extras=True) return await _record_to_response_async(record, include_soul=True, include_extras=True)
@@ -653,6 +735,9 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
model=next_model, model=next_model,
tool_groups=next_tool_groups, tool_groups=next_tool_groups,
) )
# Keep the listing cache in lock-step with config.yaml on every write.
await store.replace_skills(agent_id, next_skills)
await store.set_extras(agent_id, tool_groups=next_tool_groups, model=next_model)
if request.soul is not None: if request.soul is not None:
_write_agent_soul(agent_id, request.soul) _write_agent_soul(agent_id, request.soul)
@@ -766,6 +851,10 @@ async def delete_agent(agent_id: str, request: Request) -> None:
await store.delete_favorites_for_agent(agent_id) await store.delete_favorites_for_agent(agent_id)
except Exception: # noqa: BLE001 — favorite cleanup must not block deletion except Exception: # noqa: BLE001 — favorite cleanup must not block deletion
pass pass
try:
await store.delete_extras_for_agent(agent_id)
except Exception: # noqa: BLE001 — cache cleanup must not block deletion
pass
logger.info("Deleted agent '%s' from %s", agent_id, agent_dir) logger.info("Deleted agent '%s' from %s", agent_id, agent_dir)
except HTTPException: except HTTPException:
raise raise
@@ -868,6 +957,10 @@ class PrivateSquareListRequest(BaseModel):
"""Request body for listing all private-square agents.""" """Request body for listing all private-square agents."""
password: str = Field(..., description="Plain-text private-square password. Verified against the admin-configured hash before the listing is returned.") password: str = Field(..., description="Plain-text private-square password. Verified against the admin-configured hash before the listing is returned.")
search: str | None = Field(default=None, description="Case-insensitive fuzzy match on agent name (raw or desensitized).")
tag_ids: list[str] | None = Field(default=None, description="Keep agents carrying any of these tags (OR).")
page: int | None = Field(default=None, ge=1, description="1-based page index. Providing this enables server-side pagination.")
page_size: int = Field(default=20, ge=1, le=200, description="Items per page when paginating.")
class AgentPrivateUpdateRequest(BaseModel): class AgentPrivateUpdateRequest(BaseModel):
@@ -899,14 +992,26 @@ async def list_private_square(body: PrivateSquareListRequest, request: Request)
try: try:
store = get_agent_store(request) store = get_agent_store(request)
tag_store: TagStore = get_tag_store(request) tag_store: TagStore = get_tag_store(request)
await _sync_legacy_agents(store) await _maybe_sync_legacy_agents(store)
records = await store.list_private() # user_id is unused by the "private" scope filter (the password is the
responses = [_record_to_response(r, include_soul=True) for r in records] # gate), but list_paginated requires it for its signature.
user_id = _current_user_id(request)
assignments = await tag_store.list_assignments("agent", [r.id for r in responses]) if body.page is not None:
for resp in responses: rows, total = await store.list_paginated(
resp.tags = assignments.get(resp.id, []) user_id=user_id,
return AgentsListResponse(agents=responses) scope="private",
search=body.search,
tag_ids=body.tag_ids,
page=body.page,
page_size=body.page_size,
)
responses = await _build_list_responses(store, tag_store, user_id, rows)
return AgentsListResponse(agents=responses, total=total, page=body.page, page_size=body.page_size)
records = await store.list_private()
responses = await _build_list_responses(store, tag_store, user_id, records)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -0,0 +1,66 @@
"""Per-user UI preferences (account-bound, follow the user across devices).
Currently exposes a single toggle: ``hide_admin_prompt_prefix`` — when true the
chat input hides the admin-configured prompt-prefix switch so the user only uses
their own prompts. Kept as a small key-value (JSON) store so new toggles do not
each need a schema change.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/user-preferences", tags=["user-preferences"])
class UserPreferencesResponse(BaseModel):
hide_admin_prompt_prefix: bool = False
class UserPreferencesUpdateRequest(BaseModel):
hide_admin_prompt_prefix: bool | None = Field(default=None)
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "user_preferences_store", None)
if store is None:
raise HTTPException(status_code=503, detail="User preferences store not available")
return store
def _to_response(prefs: dict) -> UserPreferencesResponse:
return UserPreferencesResponse(
hide_admin_prompt_prefix=bool(prefs.get("hide_admin_prompt_prefix", False)),
)
@router.get("", response_model=UserPreferencesResponse)
async def get_user_preferences(request: Request) -> UserPreferencesResponse:
store = _get_store(request)
user_id = _current_user_id(request)
prefs = await store.get_for_user(user_id)
return _to_response(prefs)
@router.put("", response_model=UserPreferencesResponse)
async def update_user_preferences(request: Request, body: UserPreferencesUpdateRequest) -> UserPreferencesResponse:
store = _get_store(request)
user_id = _current_user_id(request)
patch = body.model_dump(exclude_unset=True, exclude_none=True)
if not patch:
return _to_response(await store.get_for_user(user_id))
prefs = await store.merge_for_user(user_id, patch)
return _to_response(prefs)
@@ -24,6 +24,8 @@ class UserPromptResponse(BaseModel):
title: str = "" title: str = ""
content: str content: str
sort_order: int = 0 sort_order: int = 0
# Optional single-agent association. NULL = global (shown for every agent).
agent_id: str | None = None
created_at: datetime | str | None = None created_at: datetime | str | None = None
updated_at: datetime | str | None = None updated_at: datetime | str | None = None
@@ -36,12 +38,16 @@ class UserPromptCreateRequest(BaseModel):
title: str = Field(default="", max_length=255) title: str = Field(default="", max_length=255)
content: str = Field(..., min_length=1) content: str = Field(..., min_length=1)
sort_order: int = Field(default=0) sort_order: int = Field(default=0)
agent_id: str | None = Field(default=None, max_length=64)
class UserPromptUpdateRequest(BaseModel): class UserPromptUpdateRequest(BaseModel):
title: str | None = Field(default=None, max_length=255) title: str | None = Field(default=None, max_length=255)
content: str | None = Field(default=None, min_length=1) content: str | None = Field(default=None, min_length=1)
sort_order: int | None = None sort_order: int | None = None
# Sent on every save; an empty string / null clears the association. Use a
# sentinel so "field omitted" (don't touch) differs from "set to null".
agent_id: str | None = Field(default=None, max_length=64)
def _current_user_id(request: Request) -> str: def _current_user_id(request: Request) -> str:
@@ -77,6 +83,7 @@ async def create_user_prompt(request: Request, body: UserPromptCreateRequest) ->
"title": (body.title or "").strip(), "title": (body.title or "").strip(),
"content": body.content, "content": body.content,
"sort_order": body.sort_order, "sort_order": body.sort_order,
"agent_id": (body.agent_id or "").strip() or None,
}, },
) )
return UserPromptResponse(**row) return UserPromptResponse(**row)
@@ -93,6 +100,11 @@ async def update_user_prompt(request: Request, prompt_id: str, body: UserPromptU
data["content"] = body.content data["content"] = body.content
if body.sort_order is not None: if body.sort_order is not None:
data["sort_order"] = body.sort_order data["sort_order"] = body.sort_order
# ``agent_id`` is nullable: only act when the caller actually sent the field
# (``model_fields_set``), so an explicit null clears the association while an
# omitted field leaves it untouched.
if "agent_id" in body.model_fields_set:
data["agent_id"] = (body.agent_id or "").strip() or None
row = await store.update(prompt_id, user_id, data) row = await store.update(prompt_id, user_id, data)
if row is None: if row is None:
raise HTTPException(status_code=404, detail="Prompt not found") raise HTTPException(status_code=404, detail="Prompt not found")
@@ -0,0 +1,92 @@
"""Server-side mirror of the frontend agent-name desensitization map.
The web UI displays agent names through a static term→abbreviation map
(``frontend-web/src/lib/desensitize.ts``) — e.g. the stored name "态势分析" is
shown as "TS分析". The gallery search box therefore matches against the
*desensitized* display form, not the raw stored name.
When search moves server-side (paginated listing), the backend must match the
same way, so this module mirrors that static map verbatim. The frontend map is
never extended at runtime (``addDesensitizeEntries`` is defined but never
called), so a static copy stays in sync.
Used by the SQL listing query to match "raw name OR desensitized name".
"""
from __future__ import annotations
# Mirror of DESENSITIZE_MAP in frontend-web/src/lib/desensitize.ts.
# Keep entries identical to the frontend so server-side and client-side search
# behave the same. Order does not affect membership tests.
DESENSITIZE_MAP: dict[str, str] = {
"态势": "TS",
"你好": "nh",
"滨海作战体系": "第一张网",
"滨海作战部队": "第一张网",
"Taiwan Air Force Flight Units": "第二张网",
"台海空军飞行部队": "第二张网",
"空军": "KJ",
"海军": "HJ",
"陆军": "LJ",
"陆战队": "LZD",
"海巡": "HX",
"憲兵": "XB",
"資通電軍": "ZTDJ",
"情报": "QB",
"指挥": "ZH",
"信息准备": "XX准备",
"行为体": "XWT",
"驱动因素": "QDYS",
"叙事": "XS",
"前三定": "QSD",
"受众目标": "SZMB",
"作战": "ZZ",
"目的": "MD",
"关键传播节点": "GJCBJD",
"战法": "ZF",
"行动": "XD",
"打击": "DJ",
"目标": "MB",
"战力": "ZL",
"筹划": "COH",
"策划": "CEH",
"模拟": "MN",
"推演": "TY",
"认知": "RZ",
"触及": "CJ",
"军": "J",
"美军": "MJ",
"台军": "TJ",
}
def desensitize_name(name: str) -> str:
"""Return ``name`` with every mapped term replaced by its abbreviation.
Mirrors the frontend ``desensitize()`` — a sequence of literal replacements.
"""
out = name
for term, abbr in DESENSITIZE_MAP.items():
if term in out:
out = out.replace(term, abbr)
return out
def name_matches(name: str, needle: str) -> bool:
"""True when ``needle`` (case-insensitive) is in the raw OR desensitized name."""
n = needle.strip().lower()
if not n:
return True
return n in name.lower() or n in desensitize_name(name).lower()
def source_terms_for_needle(needle: str) -> list[str]:
"""Return the raw source terms whose abbreviation contains ``needle``.
Lets the SQL search add ``name LIKE %term%`` clauses so a user typing an
abbreviation (e.g. "TS") still matches the stored raw term (e.g. "态势").
"""
n = needle.strip().lower()
if not n:
return []
return [term for term, abbr in DESENSITIZE_MAP.items() if n in abbr.lower()]
@@ -4,13 +4,20 @@ from __future__ import annotations
from deerflow.persistence.agents.base import AgentStore from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.agents.memory import MemoryAgentStore from deerflow.persistence.agents.memory import MemoryAgentStore
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow from deerflow.persistence.agents.model import (
AgentExtrasRow,
AgentFavoriteRow,
AgentRow,
AgentSkillRow,
)
from deerflow.persistence.agents.sql import AgentRepository from deerflow.persistence.agents.sql import AgentRepository
__all__ = [ __all__ = [
"AgentExtrasRow",
"AgentFavoriteRow", "AgentFavoriteRow",
"AgentRepository", "AgentRepository",
"AgentRow", "AgentRow",
"AgentSkillRow",
"AgentStore", "AgentStore",
"MemoryAgentStore", "MemoryAgentStore",
"make_agent_store", "make_agent_store",
@@ -118,6 +118,68 @@ class AgentStore(ABC):
""" """
raise NotImplementedError raise NotImplementedError
@abstractmethod
async def list_paginated(
self,
*,
user_id: str,
scope: str | None,
search: str | None,
tag_ids: list[str] | None,
page: int,
page_size: int,
admin: bool = False,
) -> tuple[list[dict[str, Any]], int]:
"""Return one page of agents plus the total matching count.
``scope``:
- ``"mine"`` — owned by ``user_id`` OR favorited by ``user_id``.
- ``"square"`` — built-in or published, and not private-square.
- ``"private"``— flagged ``is_private=True`` (caller gates access).
- ``None`` — the default listing visibility (``list_visible``).
``admin=True`` bypasses the visibility/scope filter entirely (every
agent), but still honors ``search`` / ``tag_ids``. Caller authorizes.
``search`` matches the raw name OR its desensitized display form.
``tag_ids`` keeps agents carrying ANY of the tags (OR). Both filters,
the COUNT, and LIMIT/OFFSET all execute in SQL.
Returns ``(rows, total)`` where ``rows`` is at most ``page_size`` items
for the 1-based ``page``.
"""
raise NotImplementedError
# --- Listing cache (skills / tool_groups / model) ---
@abstractmethod
async def replace_skills(self, agent_id: str, skills: list[str] | None) -> None:
"""Replace the cached skill list for ``agent_id``.
``None`` and ``[]`` both clear the cache to "no skills" (mirrors the
tri-state semantics of ``AgentConfig.skills``).
"""
raise NotImplementedError
@abstractmethod
async def set_extras(self, agent_id: str, *, tool_groups: list[str] | None, model: str | None) -> None:
"""Upsert the cached ``tool_groups`` / ``model`` for ``agent_id``."""
raise NotImplementedError
@abstractmethod
async def get_extras_for(self, agent_ids: list[str]) -> dict[str, dict[str, Any]]:
"""Batch-fetch cached listing extras for ``agent_ids``.
Returns ``{agent_id: {"skills": list[str] | None, "tool_groups":
list[str] | None, "model": str | None}}``. An agent that was never
mirrored is absent from the mapping (caller treats it as unknown).
"""
raise NotImplementedError
@abstractmethod
async def delete_extras_for_agent(self, agent_id: str) -> None:
"""Drop the cached skills + extras rows for ``agent_id`` (on delete)."""
raise NotImplementedError
# --- Favorites ("收藏") --- # --- Favorites ("收藏") ---
@abstractmethod @abstractmethod
@@ -5,6 +5,7 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from deerflow.config.agent_name_desensitize import name_matches
from deerflow.persistence.agents.base import AgentStore from deerflow.persistence.agents.base import AgentStore
@@ -22,6 +23,9 @@ class MemoryAgentStore(AgentStore):
def __init__(self) -> None: def __init__(self) -> None:
self._agents: dict[str, dict[str, Any]] = {} self._agents: dict[str, dict[str, Any]] = {}
self._favorites: set[tuple[str, str]] = set() self._favorites: set[tuple[str, str]] = set()
# Listing cache mirrors (see AgentExtrasRow / AgentSkillRow):
self._skills: dict[str, list[str]] = {}
self._extras: dict[str, dict[str, Any]] = {}
@staticmethod @staticmethod
def _is_visible(agent: dict[str, Any], user_id: str) -> bool: def _is_visible(agent: dict[str, Any], user_id: str) -> bool:
@@ -166,6 +170,73 @@ class MemoryAgentStore(AgentStore):
self._agents.pop(agent_id, None) self._agents.pop(agent_id, None)
return True return True
# --- Paginated listing ---
def _matches_scope(self, agent: dict[str, Any], *, user_id: str, scope: str | None, admin: bool) -> bool:
if admin:
return True
if scope == "square":
return (agent.get("user_id") is None or bool(agent.get("published"))) and not bool(agent.get("is_private"))
if scope == "mine":
owned = agent.get("user_id") is not None and agent.get("user_id") == user_id
favorited = (user_id, agent["id"]) in self._favorites
return owned or favorited
if scope == "private":
return bool(agent.get("is_private"))
return self._is_visible(agent, user_id)
async def list_paginated(
self,
*,
user_id: str,
scope: str | None,
search: str | None,
tag_ids: list[str] | None,
page: int,
page_size: int,
admin: bool = False,
) -> tuple[list[dict[str, Any]], int]:
# NOTE: the in-memory store has no tag-assignment data, so ``tag_ids``
# is ignored here. Tag-filter behavior is covered by the SQL repo tests.
matched = [
a
for a in self._agents.values()
if self._matches_scope(a, user_id=user_id, scope=scope, admin=admin)
and (not search or not search.strip() or name_matches(a.get("name") or "", search))
]
matched.sort(key=lambda a: (a.get("created_at") or "", a.get("id") or ""))
total = len(matched)
start = max(page - 1, 0) * page_size
page_rows = matched[start : start + page_size]
return [_copy(a) for a in page_rows], total
# --- Listing cache (skills / tool_groups / model) ---
async def replace_skills(self, agent_id: str, skills: list[str] | None) -> None:
self._skills[agent_id] = [s for s in (skills or []) if isinstance(s, str)]
async def set_extras(self, agent_id: str, *, tool_groups: list[str] | None, model: str | None) -> None:
self._extras[agent_id] = {"tool_groups": tool_groups, "model": model}
async def get_extras_for(self, agent_ids: list[str]) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
for agent_id in agent_ids:
has_extras = agent_id in self._extras
has_skills = agent_id in self._skills
if not has_extras and not has_skills:
continue
extras = self._extras.get(agent_id, {})
out[agent_id] = {
"skills": list(self._skills.get(agent_id, [])),
"tool_groups": extras.get("tool_groups"),
"model": extras.get("model"),
}
return out
async def delete_extras_for_agent(self, agent_id: str) -> None:
self._skills.pop(agent_id, None)
self._extras.pop(agent_id, None)
# --- Favorites ("收藏") --- # --- Favorites ("收藏") ---
async def add_favorite(self, user_id: str, agent_id: str) -> None: async def add_favorite(self, user_id: str, agent_id: str) -> None:
@@ -4,11 +4,11 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import BigInteger, Boolean, String, UniqueConstraint from sqlalchemy import BigInteger, Boolean, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime from deerflow.persistence.types import BeijingDateTime, PortableJSON
class AgentRow(Base): class AgentRow(Base):
@@ -52,3 +52,47 @@ class AgentFavoriteRow(Base):
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)) created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
class AgentSkillRow(Base):
"""Denormalized cache of a custom agent's selected skills.
``config.yaml`` under ``.deer-flow/agents/{id}/`` stays the source of truth
for the agent runtime (the lead-agent prompt reads it directly). This table
is a read cache so the gallery list endpoint can render skill badges with a
single batched ``IN`` query instead of stat+YAML-parsing every agent's
``config.yaml`` on each request. Written through on create/update and
backfilled from disk by ``_sync_legacy_agents``.
One row per (agent, skill); ``position`` preserves the configured order.
"""
__tablename__ = "agent_skills"
__table_args__ = (UniqueConstraint("agent_id", "skill_name", name="uq_agent_skills_agent_skill"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
skill_name: Mapped[str] = mapped_column(String(191), nullable=False, index=True)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
class AgentExtrasRow(Base):
"""Denormalized cache of a custom agent's ``tool_groups`` / ``model``.
Companion to :class:`AgentSkillRow` — same rationale (avoid per-agent
``config.yaml`` reads in the list path). One row per agent. The presence of
a row also marks "this agent has been mirrored": the list path treats a
missing row as "unknown / not yet backfilled" (``skills=None``) so it can
be told apart from an explicitly empty skill list.
``skills_synced_at`` records when the cache was last written from disk so
the backfill can skip agents whose ``config.yaml`` has not changed.
"""
__tablename__ = "agent_extras"
agent_id: Mapped[str] = mapped_column(String(64), primary_key=True)
# list[str] | None — the agent's tool_groups whitelist from config.yaml.
tool_groups: Mapped[list | None] = mapped_column(PortableJSON(), nullable=True, default=None)
model: Mapped[str | None] = mapped_column(String(191), nullable=True, default=None)
skills_synced_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -10,8 +10,15 @@ from sqlalchemy import and_, delete, func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.config.agent_name_desensitize import source_terms_for_needle
from deerflow.persistence.agents.base import AgentStore from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow from deerflow.persistence.agents.model import (
AgentExtrasRow,
AgentFavoriteRow,
AgentRow,
AgentSkillRow,
)
from deerflow.persistence.tags.model import TagAssignmentRow
def _row_to_dict(row: AgentRow) -> dict[str, Any]: def _row_to_dict(row: AgentRow) -> dict[str, Any]:
@@ -233,6 +240,152 @@ class AgentRepository(AgentStore):
await session.commit() await session.commit()
return True return True
# --- Paginated listing ---
def _scope_filter(self, *, user_id: str, scope: str | None, admin: bool):
"""Build the visibility predicate for a paginated listing."""
if admin:
return None # no visibility filter — every agent
if scope == "square":
return and_(
or_(AgentRow.user_id.is_(None), AgentRow.published.is_(True)),
AgentRow.is_private.is_(False),
)
if scope == "mine":
# owned OR favorited. A correlated IN-subquery (not a JOIN) keeps a
# single row per agent even when it is both owned and favorited.
fav_subq = select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id)
return or_(AgentRow.user_id == user_id, AgentRow.id.in_(fav_subq))
if scope == "private":
return AgentRow.is_private.is_(True)
return self._listing_filter(user_id)
@staticmethod
def _search_clause(search: str):
"""Match the raw name OR (via the desensitize mirror) its display form."""
needle = search.strip().lower()
clauses = [func.lower(AgentRow.name).like(f"%{needle}%")]
for term in source_terms_for_needle(needle):
clauses.append(AgentRow.name.like(f"%{term}%"))
return or_(*clauses)
@staticmethod
def _tag_clause(tag_ids: list[str]):
subq = select(TagAssignmentRow.target_id).where(
TagAssignmentRow.target_type == "agent",
TagAssignmentRow.tag_id.in_(tag_ids),
)
return AgentRow.id.in_(subq)
async def list_paginated(
self,
*,
user_id: str,
scope: str | None,
search: str | None,
tag_ids: list[str] | None,
page: int,
page_size: int,
admin: bool = False,
) -> tuple[list[dict[str, Any]], int]:
filters = []
scope_filter = self._scope_filter(user_id=user_id, scope=scope, admin=admin)
if scope_filter is not None:
filters.append(scope_filter)
if search and search.strip():
filters.append(self._search_clause(search))
if tag_ids:
filters.append(self._tag_clause(tag_ids))
count_stmt = select(func.count()).select_from(AgentRow)
page_stmt = (
select(AgentRow)
# ``id`` is a stable tiebreaker on top of ``created_at`` so OFFSET
# paging is deterministic when bulk-seeded built-ins share a timestamp.
.order_by(AgentRow.created_at.asc(), AgentRow.id.asc())
.limit(page_size)
.offset(max(page - 1, 0) * page_size)
)
if filters:
where = and_(*filters)
count_stmt = count_stmt.where(where)
page_stmt = page_stmt.where(where)
async with self._sf() as session:
total = await session.scalar(count_stmt) or 0
result = await session.execute(page_stmt)
rows = [_row_to_dict(row) for row in result.scalars()]
return rows, total
# --- Listing cache (skills / tool_groups / model) ---
async def replace_skills(self, agent_id: str, skills: list[str] | None) -> None:
async with self._sf() as session:
await session.execute(delete(AgentSkillRow).where(AgentSkillRow.agent_id == agent_id))
for position, name in enumerate(skills or []):
if not isinstance(name, str):
continue
session.add(
AgentSkillRow(
id=uuid4().hex,
agent_id=agent_id,
skill_name=name,
position=position,
)
)
await session.commit()
async def set_extras(self, agent_id: str, *, tool_groups: list[str] | None, model: str | None) -> None:
async with self._sf() as session:
row = await session.get(AgentExtrasRow, agent_id)
now = datetime.now(UTC)
if row is None:
session.add(
AgentExtrasRow(
agent_id=agent_id,
tool_groups=tool_groups,
model=model,
skills_synced_at=now,
)
)
else:
row.tool_groups = tool_groups
row.model = model
row.skills_synced_at = now
await session.commit()
async def get_extras_for(self, agent_ids: list[str]) -> dict[str, dict[str, Any]]:
if not agent_ids:
return {}
out: dict[str, dict[str, Any]] = {}
async with self._sf() as session:
extras = await session.execute(
select(AgentExtrasRow).where(AgentExtrasRow.agent_id.in_(agent_ids))
)
for row in extras.scalars():
out[row.agent_id] = {
"skills": [], # default; filled below if skill rows exist
"tool_groups": row.tool_groups,
"model": row.model,
}
skill_rows = await session.execute(
select(AgentSkillRow.agent_id, AgentSkillRow.skill_name)
.where(AgentSkillRow.agent_id.in_(agent_ids))
.order_by(AgentSkillRow.agent_id.asc(), AgentSkillRow.position.asc())
)
for agent_id, skill_name in skill_rows.all():
entry = out.setdefault(
agent_id, {"skills": [], "tool_groups": None, "model": None}
)
entry["skills"].append(skill_name)
return out
async def delete_extras_for_agent(self, agent_id: str) -> None:
async with self._sf() as session:
await session.execute(delete(AgentSkillRow).where(AgentSkillRow.agent_id == agent_id))
await session.execute(delete(AgentExtrasRow).where(AgentExtrasRow.agent_id == agent_id))
await session.commit()
# --- Favorites ("收藏") --- # --- Favorites ("收藏") ---
async def add_favorite(self, user_id: str, agent_id: str) -> None: async def add_favorite(self, user_id: str, agent_id: str) -> None:
@@ -0,0 +1,45 @@
"""Add ``agent_id`` to ``user_custom_prompts`` for per-agent prompt scoping.
NULL = global (shown for every agent); otherwise the prompt only surfaces when
that agent is selected.
Revision ID: 20260603_02
Revises: 20260603_01
Create Date: 2026-06-03
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260603_02"
down_revision = "20260603_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if not _has_table("user_custom_prompts"):
return
if not _has_column("user_custom_prompts", "agent_id"):
op.add_column("user_custom_prompts", sa.Column("agent_id", sa.String(64), nullable=True))
op.create_index("ix_user_custom_prompts_agent_id", "user_custom_prompts", ["agent_id"])
def downgrade() -> None:
if _has_table("user_custom_prompts") and _has_column("user_custom_prompts", "agent_id"):
op.drop_index("ix_user_custom_prompts_agent_id", table_name="user_custom_prompts")
op.drop_column("user_custom_prompts", "agent_id")
@@ -0,0 +1,42 @@
"""Add ``user_preferences`` for per-user UI toggles (account-bound).
Holds a single JSON blob per user — e.g. ``hide_admin_prompt_prefix`` so a user
can choose to use only their own prompts and hide the admin-configured prefix.
Revision ID: 20260603_03
Revises: 20260603_02
Create Date: 2026-06-03
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260603_03"
down_revision = "20260603_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not _has_table("user_preferences"):
op.create_table(
"user_preferences",
sa.Column("user_id", sa.String(64), primary_key=True),
sa.Column("preferences", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
if _has_table("user_preferences"):
op.drop_table("user_preferences")
@@ -15,7 +15,12 @@ its storage implementation lives in ``deerflow.runtime.events.store.db`` and
there is no matching entity directory. there is no matching entity directory.
""" """
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow from deerflow.persistence.agents.model import (
AgentExtrasRow,
AgentFavoriteRow,
AgentRow,
AgentSkillRow,
)
from deerflow.persistence.memory_config.model import UserMemoryConfigRow from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.feedback.model import FeedbackRow from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
@@ -42,12 +47,15 @@ from deerflow.persistence.embed_sessions.model import EmbedSessionRow
from deerflow.persistence.thread_shares.model import ThreadShareRow from deerflow.persistence.thread_shares.model import ThreadShareRow
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
from deerflow.persistence.user.model import UserRow from deerflow.persistence.user.model import UserRow
from deerflow.persistence.user_preferences.model import UserPreferencesRow
from deerflow.persistence.user_prompts.model import UserCustomPromptRow from deerflow.persistence.user_prompts.model import UserCustomPromptRow
__all__ = [ __all__ = [
"FeedbackRow", "FeedbackRow",
"AgentExtrasRow",
"AgentFavoriteRow", "AgentFavoriteRow",
"AgentRow", "AgentRow",
"AgentSkillRow",
"LlmCallMetricRow", "LlmCallMetricRow",
"NotificationRow", "NotificationRow",
"RecommendedQuestionRow", "RecommendedQuestionRow",
@@ -71,5 +79,6 @@ __all__ = [
"ToolCallMetricRow", "ToolCallMetricRow",
"UserCustomPromptRow", "UserCustomPromptRow",
"UserMemoryConfigRow", "UserMemoryConfigRow",
"UserPreferencesRow",
"UserRow", "UserRow",
] ]
@@ -0,0 +1,18 @@
"""Per-user UI preferences persistence."""
from __future__ import annotations
from deerflow.persistence.user_preferences.model import UserPreferencesRow
from deerflow.persistence.user_preferences.sql import UserPreferencesRepository
__all__ = [
"UserPreferencesRepository",
"UserPreferencesRow",
"make_user_preferences_store",
]
def make_user_preferences_store(session_factory):
if session_factory is None:
return None
return UserPreferencesRepository(session_factory)
@@ -0,0 +1,26 @@
"""ORM model for per-user UI preferences.
A single key-value (JSON) row per user, holding small UI-level toggles that
should follow the account across devices — e.g. whether to hide the
admin-configured prompt prefix in the chat input. Kept generic (one JSON blob)
so new toggles do not each require an ALTER TABLE.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON
class UserPreferencesRow(Base):
__tablename__ = "user_preferences"
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
preferences: Mapped[dict] = mapped_column(PortableJSON(), nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -0,0 +1,41 @@
"""SQLAlchemy-backed per-user preferences repository."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.user_preferences.model import UserPreferencesRow
class UserPreferencesRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def get_for_user(self, user_id: str) -> dict[str, Any]:
"""Return the user's stored preferences (empty dict when none)."""
async with self._sf() as session:
row = await session.get(UserPreferencesRow, user_id)
if row is None or not isinstance(row.preferences, dict):
return {}
return dict(row.preferences)
async def merge_for_user(self, user_id: str, patch: dict[str, Any]) -> dict[str, Any]:
"""Shallow-merge ``patch`` into the user's preferences and return the result."""
now = datetime.now(UTC)
async with self._sf() as session:
row = await session.get(UserPreferencesRow, user_id)
if row is None:
merged = dict(patch)
row = UserPreferencesRow(user_id=user_id, preferences=merged, created_at=now, updated_at=now)
session.add(row)
else:
merged = dict(row.preferences or {})
merged.update(patch)
# Reassign so SQLAlchemy detects the change on the JSON column.
row.preferences = merged
row.updated_at = now
await session.commit()
return dict(merged)
@@ -19,5 +19,8 @@ class UserCustomPromptRow(Base):
title: Mapped[str] = mapped_column(String(255), nullable=False, default="") title: Mapped[str] = mapped_column(String(255), nullable=False, default="")
content: Mapped[str] = mapped_column(Text, nullable=False) content: Mapped[str] = mapped_column(Text, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Optional association to a single agent. NULL = global (shown everywhere);
# otherwise the prompt only surfaces when that agent is selected.
agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)) created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -42,6 +42,7 @@ class UserCustomPromptRepository:
title=data.get("title") or "", title=data.get("title") or "",
content=data["content"], content=data["content"],
sort_order=data.get("sort_order", 0), sort_order=data.get("sort_order", 0),
agent_id=(data.get("agent_id") or None),
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
@@ -59,6 +60,10 @@ class UserCustomPromptRepository:
for key in ("title", "content", "sort_order"): for key in ("title", "content", "sort_order"):
if key in data: if key in data:
setattr(row, key, data[key]) setattr(row, key, data[key])
# ``agent_id`` is explicitly nullable: an empty/None value clears the
# association (back to global), so accept None as a real update.
if "agent_id" in data:
row.agent_id = data["agent_id"] or None
row.updated_at = datetime.now(UTC) row.updated_at = datetime.now(UTC)
await session.commit() await session.commit()
await session.refresh(row) await session.refresh(row)
@@ -219,6 +219,23 @@ def _task_requires_markdown(task: dict[str, Any]) -> bool:
return bool(isinstance(ctx, dict) and ctx.get("require_markdown")) return bool(isinstance(ctx, dict) and ctx.get("require_markdown"))
def _task_requires_html(task: dict[str, Any]) -> bool:
"""Whether a task must produce an HTML page (the ``必须生成 HTML`` switch).
The flag lives inside ``execution_context.require_html``. For backward
compatibility, tasks created under the old two-tab UI (which stored
``task_kind == "html_page"``) are still treated as HTML tasks, so no data
migration is needed. ``require_html`` and ``require_markdown`` are
independent — both may be on at once.
"""
ctx = task.get("execution_context")
if not isinstance(ctx, dict):
return False
if ctx.get("require_html"):
return True
return ctx.get("task_kind") == "html_page"
def _has_valid_markdown_file(result_files: list[dict[str, Any]] | None) -> bool: def _has_valid_markdown_file(result_files: list[dict[str, Any]] | None) -> bool:
"""True when the run produced a ``.md`` file whose text exceeds the minimum. """True when the run produced a ``.md`` file whose text exceeds the minimum.
@@ -905,15 +922,18 @@ class ScheduledTaskService:
scheduled_for: datetime, scheduled_for: datetime,
run_id: str, run_id: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Dispatch a single execution attempt by task kind. """Dispatch a single execution attempt by the task's output requirements.
``html_page`` tasks render an HTML document via GLM5; everything else A task carries two independent switches in ``execution_context``:
runs the regular lead-agent attempt. The kind lives in ``require_html`` (必须生成 HTML) and ``require_markdown`` (必须生成
``execution_context.task_kind`` so no schema migration is needed. Markdown). When ``require_html`` is set the run goes through the HTML
render pipeline (which also enforces the Markdown requirement when both
switches are on); otherwise it runs the regular lead-agent attempt.
Both flags live in ``execution_context`` so no schema migration is
needed, and old ``task_kind == "html_page"`` tasks still resolve to the
HTML pipeline via :func:`_task_requires_html`.
""" """
ctx = task.get("execution_context") if _task_requires_html(task):
kind = ctx.get("task_kind") if isinstance(ctx, dict) else None
if kind == "html_page":
return await self._run_html_page_attempt( return await self._run_html_page_attempt(
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
) )
@@ -956,17 +976,21 @@ class ScheduledTaskService:
runtime_thread_id: str, runtime_thread_id: str,
scheduled_for: datetime, scheduled_for: datetime,
run_id: str, run_id: str,
) -> str: ) -> tuple[str, list[dict[str, Any]]]:
"""Run the lead agent to collect source data for the HTML page. """Run the lead agent to collect source data for the HTML page.
Reuses the regular agent-run primitives (with the task's search query Reuses the regular agent-run primitives (with the task's search query
folded into the prompt) and returns the agent's final text, which feeds folded into the prompt) and returns ``(text, files)`` — the agent's
the GLM5 render step as ``本次数据``. Returns an empty string if the final text (feeds the render step as ``本次数据``) plus any files it
agent produced nothing. produced. The files are only captured/needed when the task also has the
Markdown switch on (both 必须生成 HTML and 必须生成 Markdown), so the run
can validate and surface the ``.md`` file alongside the HTML page;
otherwise the list is empty.
""" """
cfg = self._html_config(task) cfg = self._html_config(task)
page_prompt = str(cfg.get("page_prompt") or "").strip() page_prompt = str(cfg.get("page_prompt") or "").strip()
negative_prompt = str(cfg.get("negative_prompt") or "").strip() negative_prompt = str(cfg.get("negative_prompt") or "").strip()
also_markdown = _task_requires_markdown(task)
gather_instruction = ( gather_instruction = (
"请运用你自身的检索/搜索等技能,围绕下述页面需求获取最新、真实的资料," "请运用你自身的检索/搜索等技能,围绕下述页面需求获取最新、真实的资料,"
"并整理成结构化的中文要点(可用列表/小标题),供后续生成 HTML 页面使用,本步骤不要生成 HTML:\n" "并整理成结构化的中文要点(可用列表/小标题),供后续生成 HTML 页面使用,本步骤不要生成 HTML:\n"
@@ -982,12 +1006,22 @@ class ScheduledTaskService:
# which sources/data the agent collects, otherwise it silently leaks in. # which sources/data the agent collects, otherwise it silently leaks in.
if negative_prompt: if negative_prompt:
parts.append(f"限制条件(最高优先级,必须严格遵守):{negative_prompt}") parts.append(f"限制条件(最高优先级,必须严格遵守):{negative_prompt}")
gather_task = {**task, "prompt": "\n".join(parts)} gather_prompt = "\n".join(parts)
# Never apply the markdown gate to the gather pass. # When the Markdown switch is also on, ask this same gather pass to save a
# .md file (the length gate is enforced in code, not surfaced here) so the
# task produces both deliverables in one agent run.
if also_markdown:
gather_prompt += _MARKDOWN_INSTRUCTION
gather_task = {**task, "prompt": gather_prompt}
gather_ctx = dict(task.get("execution_context") or {}) gather_ctx = dict(task.get("execution_context") or {})
gather_ctx.pop("require_markdown", None) # Strip the HTML-specific flags so the gather pass is a plain agent run.
gather_ctx.pop("require_html", None)
gather_ctx.pop("task_kind", None) gather_ctx.pop("task_kind", None)
gather_ctx.pop("html_config", None) gather_ctx.pop("html_config", None)
# Keep ``require_markdown`` only when both switches are on, so the agent
# is told (via _start_agent_run) to produce the .md file.
if not also_markdown:
gather_ctx.pop("require_markdown", None)
# Use the task's selected model for the data-gathering agent too, so the # Use the task's selected model for the data-gathering agent too, so the
# whole HTML pipeline honors ``html_config.model_name`` instead of # whole HTML pipeline honors ``html_config.model_name`` instead of
# silently falling back to the default model for this step. # silently falling back to the default model for this step.
@@ -997,13 +1031,22 @@ class ScheduledTaskService:
gather_task["execution_context"] = gather_ctx gather_task["execution_context"] = gather_ctx
await self._ensure_thread_meta(task["user_id"], runtime_thread_id) await self._ensure_thread_meta(task["user_id"], runtime_thread_id)
files: list[dict[str, Any]] = []
async with self._agent_run_lock: async with self._agent_run_lock:
output_snapshot = self._snapshot_result_files(runtime_thread_id, task["user_id"]) if also_markdown else None
record = await self._start_agent_run(gather_task, scheduled_for=scheduled_for, runtime_thread_id=runtime_thread_id) record = await self._start_agent_run(gather_task, scheduled_for=scheduled_for, runtime_thread_id=runtime_thread_id)
await self.store.update_run(run_id, {"agent_run_id": record.run_id}) await self.store.update_run(run_id, {"agent_run_id": record.run_id})
if record.task is not None: if record.task is not None:
await record.task await record.task
text = await self._extract_latest_ai_text(runtime_thread_id) text = await self._extract_latest_ai_text(runtime_thread_id)
return text or "" if also_markdown:
files = await self._extract_result_files(
runtime_thread_id,
task["user_id"],
run_id=run_id,
output_snapshot=output_snapshot,
)
return text or "", files
def _collect_html_skill_instructions(self) -> str: def _collect_html_skill_instructions(self) -> str:
"""Concatenate the bodies of enabled skills marked for HTML pages. """Concatenate the bodies of enabled skills marked for HTML pages.
@@ -1072,10 +1115,17 @@ class ScheduledTaskService:
negative_prompt = str(cfg.get("negative_prompt") or "").strip() negative_prompt = str(cfg.get("negative_prompt") or "").strip()
reference_favorite_id = cfg.get("reference_favorite_id") or None reference_favorite_id = cfg.get("reference_favorite_id") or None
# 1) Gather source data (real, via the agent's search tools). # 1) Gather source data (real, via the agent's search tools). When the
data_text = await self._gather_html_data( # Markdown switch is also on, the gather pass additionally saves the .md
# file, returned here so we can validate + surface it next to the page.
also_markdown = _task_requires_markdown(task)
data_text, gathered_files = await self._gather_html_data(
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
) )
if also_markdown and not _has_valid_markdown_file(gathered_files):
raise MarkdownRequirementError(
f"未满足输出要求:需生成 Markdown(.md) 文件,且文件正文不少于 {_MARKDOWN_MIN_CHARS} 字"
)
# 2) Load reference style/layout from a favorite if one was selected. # 2) Load reference style/layout from a favorite if one was selected.
reference_style = None reference_style = None
@@ -1158,6 +1208,13 @@ class ScheduledTaskService:
archive_path = self._archive_html_index(run_id, html) archive_path = self._archive_html_index(run_id, html)
files: list[dict[str, Any]] = [] files: list[dict[str, Any]] = []
# When both switches are on, surface the gathered Markdown file(s)
# alongside the generated page so the run delivers both artifacts.
if also_markdown:
for item in gathered_files:
name = str(item.get("name") or "") if isinstance(item, dict) else ""
if name.lower().endswith((".md", ".markdown")):
files.append(item)
if archive_path is not None: if archive_path is not None:
files.append( files.append(
{ {
@@ -3,9 +3,14 @@ from __future__ import annotations
import pytest import pytest
import yaml import yaml
from app.gateway.routers.agents import _load_config_extras, _write_agent_config from app.gateway.routers.agents import (
_backfill_listing_cache,
_load_config_extras,
_write_agent_config,
)
from app.gateway.services import build_run_config from app.gateway.services import build_run_config
from deerflow.config import paths as paths_module from deerflow.config import paths as paths_module
from deerflow.config.agents_config import AgentConfig
from deerflow.persistence.agents import MemoryAgentStore from deerflow.persistence.agents import MemoryAgentStore
@@ -198,3 +203,46 @@ def test_write_agent_config_omits_skills_when_none(isolated_paths) -> None:
assert "skills" not in raw assert "skills" not in raw
assert "model" not in raw assert "model" not in raw
assert "tool_groups" not in raw assert "tool_groups" not in raw
@pytest.mark.asyncio
async def test_backfill_listing_cache_mirrors_config_values() -> None:
store = MemoryAgentStore()
cfg = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=["g1"], model="m1")
await _backfill_listing_cache(store, [("a1", cfg)])
extras = await store.get_extras_for(["a1"])
assert extras["a1"]["skills"] == ["s1", "s2"]
assert extras["a1"]["tool_groups"] == ["g1"]
assert extras["a1"]["model"] == "m1"
@pytest.mark.asyncio
async def test_backfill_listing_cache_is_idempotent_and_skips_unchanged(monkeypatch) -> None:
store = MemoryAgentStore()
cfg = AgentConfig(id="a1", name="Agent", skills=["s1"], tool_groups=None, model=None)
await _backfill_listing_cache(store, [("a1", cfg)])
# On a second run with identical config, no re-write should occur.
calls = {"replace": 0, "set": 0}
orig_replace = store.replace_skills
orig_set = store.set_extras
async def _replace(agent_id, skills):
calls["replace"] += 1
await orig_replace(agent_id, skills)
async def _set(agent_id, *, tool_groups, model):
calls["set"] += 1
await orig_set(agent_id, tool_groups=tool_groups, model=model)
monkeypatch.setattr(store, "replace_skills", _replace)
monkeypatch.setattr(store, "set_extras", _set)
await _backfill_listing_cache(store, [("a1", cfg)])
assert calls == {"replace": 0, "set": 0}
# Changing the config triggers a re-write.
cfg2 = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=None, model=None)
await _backfill_listing_cache(store, [("a1", cfg2)])
assert calls["replace"] == 1