新增智能体私密广场(is_private + 管理员密码门控)
- agents 表新增 is_private 列与迁移 20260526_01;列表 filter 拆分为公开
广场用的 _listing_filter(剔除私密)与单条访问用的 _visible_filter(保留可达)
- system_settings 增 PrivateSquareSettings(enabled + password_hash),
对应 GET/PUT /api/system-settings/private-square 由管理员管理
- /api/agents 接口:列表/创建/更新新增 is_private 字段;新增
POST /api/agents/private-square/list(密码校验后返回私密智能体)与
PUT /api/agents/{id}/private(管理员跨用户搬动智能体)
- 前端「智能体」页签新增「私密广场」入口(密码校验 + sessionStorage 缓存 +
锁定按钮),卡片增加紫色私密徽章与盾牌切换按钮(owner 自助 / 管理员跨用户),
「广场」与首页推荐候选均剔除私密智能体
- 新增管理员配置页 /settings/private-square 与侧边栏入口
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0256d527f0
commit
3734b6a05f
20 files changed
+879
-20
No files matched your search
@@ -26,6 +26,7 @@ import {
|
|||||||
ListTodo,
|
ListTodo,
|
||||||
MessageSquareCode,
|
MessageSquareCode,
|
||||||
MessageSquarePlus,
|
MessageSquarePlus,
|
||||||
|
ShieldCheck,
|
||||||
Network,
|
Network,
|
||||||
NotebookPen,
|
NotebookPen,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -193,6 +194,13 @@ const menuItems: MenuItem[] = [
|
|||||||
path: "/page/strategy/settings/prompt-prefix",
|
path: "/page/strategy/settings/prompt-prefix",
|
||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "mgmt-private-square",
|
||||||
|
label: "私密广场配置",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
path: "/page/strategy/settings/private-square",
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "mgmt-llm-metrics",
|
id: "mgmt-llm-metrics",
|
||||||
label: "大模型调用统计",
|
label: "大模型调用统计",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
InfoIcon,
|
InfoIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
|
ShieldIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
SquarePenIcon,
|
SquarePenIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
@@ -42,6 +43,7 @@ import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
|||||||
import {
|
import {
|
||||||
useDeleteAgent,
|
useDeleteAgent,
|
||||||
useSetAgentFeatured,
|
useSetAgentFeatured,
|
||||||
|
useSetAgentPrivate,
|
||||||
useUpdateAgent,
|
useUpdateAgent,
|
||||||
} from "@/core/agents";
|
} from "@/core/agents";
|
||||||
import type { Agent } from "@/core/agents";
|
import type { Agent } from "@/core/agents";
|
||||||
@@ -61,6 +63,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
const deleteAgent = useDeleteAgent();
|
const deleteAgent = useDeleteAgent();
|
||||||
const updateAgent = useUpdateAgent();
|
const updateAgent = useUpdateAgent();
|
||||||
const setFeatured = useSetAgentFeatured();
|
const setFeatured = useSetAgentFeatured();
|
||||||
|
const setPrivate = useSetAgentPrivate();
|
||||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
@@ -155,6 +158,27 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTogglePrivate() {
|
||||||
|
const turningOn = !agent.is_private;
|
||||||
|
try {
|
||||||
|
if (isOwned) {
|
||||||
|
// Owners can re-categorize their own agents through the normal update
|
||||||
|
// endpoint — keeps publish/private edits in one transaction.
|
||||||
|
await updateAgent.mutateAsync({
|
||||||
|
id: agent.id,
|
||||||
|
request: { is_private: turningOn },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Admins can move any agent (built-in or other users') via the
|
||||||
|
// dedicated admin endpoint.
|
||||||
|
await setPrivate.mutateAsync({ id: agent.id, isPrivate: turningOn });
|
||||||
|
}
|
||||||
|
toast.success(turningOn ? "已加入私密广场" : "已移出私密广场");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleToggleFeatured() {
|
async function handleToggleFeatured() {
|
||||||
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
|
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
|
||||||
// dialog. Featured agents get appended to the end on add (Date.now() is a
|
// dialog. Featured agents get appended to the end on add (Date.now() is a
|
||||||
@@ -229,7 +253,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
来自他人
|
来自他人
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{isOwned && agent.published && (
|
{isOwned && agent.published && !agent.is_private && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||||
@@ -239,6 +263,16 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
已发布
|
已发布
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{agent.is_private && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||||
|
title="该智能体位于私密广场,需要密码才能查看"
|
||||||
|
>
|
||||||
|
<ShieldIcon className="size-3" />
|
||||||
|
私密
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{agent.featured_order != null && (
|
{agent.featured_order != null && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -348,6 +382,24 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{(isOwned || isAdmin) && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 shrink-0",
|
||||||
|
agent.is_private &&
|
||||||
|
"text-purple-600 hover:text-purple-700",
|
||||||
|
)}
|
||||||
|
onClick={() => void handleTogglePrivate()}
|
||||||
|
disabled={updateAgent.isPending || setPrivate.isPending}
|
||||||
|
title={
|
||||||
|
agent.is_private ? "移出私密广场" : "放入私密广场"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ShieldIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BotIcon, PlusIcon } from "lucide-react";
|
import { BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
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 } from "@/core/agents";
|
import { useAgents, usePrivateAgents } from "@/core/agents";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
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 { AgentCard } from "./agent-card";
|
import { AgentCard } from "./agent-card";
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// password never persists beyond the browsing session.
|
||||||
|
const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
||||||
|
|
||||||
export function AgentGallery() {
|
export function AgentGallery() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
|
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
|
||||||
|
"mine",
|
||||||
|
);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||||
// Name + tag filtering is resolved server-side via the /api/agents query.
|
// Name + tag filtering is resolved server-side via the /api/agents query.
|
||||||
@@ -25,20 +35,84 @@ export function AgentGallery() {
|
|||||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||||
const currentUserId = storedAuth?.user_id ?? null;
|
const currentUserId = storedAuth?.user_id ?? null;
|
||||||
|
|
||||||
|
const { data: privateStatus } = usePrivateSquareStatus();
|
||||||
|
const privateFeatureAvailable = !!privateStatus?.enabled;
|
||||||
|
const privateFeatureUsable =
|
||||||
|
privateFeatureAvailable && !!privateStatus?.password_set;
|
||||||
|
|
||||||
|
const [privatePassword, setPrivatePassword] = useState<string | null>(() => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return window.sessionStorage.getItem(PRIVATE_SQUARE_PASSWORD_KEY);
|
||||||
|
});
|
||||||
|
const [passwordInput, setPasswordInput] = useState("");
|
||||||
|
|
||||||
|
const {
|
||||||
|
agents: privateAgents,
|
||||||
|
isLoading: isPrivateLoading,
|
||||||
|
error: privateError,
|
||||||
|
} = usePrivateAgents(privatePassword);
|
||||||
|
|
||||||
|
// If the server reports the stored password no longer works (likely the
|
||||||
|
// admin rotated it), drop it from session storage and re-prompt.
|
||||||
|
useEffect(() => {
|
||||||
|
if (privatePassword && privateError) {
|
||||||
|
window.sessionStorage.removeItem(PRIVATE_SQUARE_PASSWORD_KEY);
|
||||||
|
setPrivatePassword(null);
|
||||||
|
toast.error("私密广场密码已失效,请重新输入");
|
||||||
|
}
|
||||||
|
}, [privateError, privatePassword]);
|
||||||
|
|
||||||
|
// The "广场" tab must never surface private-square agents, even to their
|
||||||
|
// 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(
|
const allAgents = useMemo(
|
||||||
() => agents.filter((a) => a.builtin || a.published),
|
() => agents.filter((a) => (a.builtin || a.published) && !a.is_private),
|
||||||
[agents],
|
[agents],
|
||||||
);
|
);
|
||||||
|
|
||||||
const myAgents = useMemo(
|
const myAgents = useMemo(
|
||||||
() => agents.filter((a) => a.user_id !== null && a.user_id === currentUserId),
|
() =>
|
||||||
|
agents.filter(
|
||||||
|
(a) => a.user_id !== null && a.user_id === currentUserId,
|
||||||
|
),
|
||||||
[agents, currentUserId],
|
[agents, currentUserId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const filteredPrivateAgents = useMemo(() => {
|
||||||
|
const needle = search.trim().toLowerCase();
|
||||||
|
const wanted = new Set(tagIds);
|
||||||
|
return privateAgents.filter((a) => {
|
||||||
|
if (needle && !a.name.toLowerCase().includes(needle)) return false;
|
||||||
|
if (wanted.size > 0) {
|
||||||
|
if (!(a.tags ?? []).some((tg) => wanted.has(tg.id))) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [privateAgents, search, tagIds]);
|
||||||
|
|
||||||
const handleNewAgent = () => {
|
const handleNewAgent = () => {
|
||||||
router.push("/page/workspace/agents/new");
|
router.push("/page/workspace/agents/new");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmitPassword = () => {
|
||||||
|
const trimmed = passwordInput.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
toast.error("请输入密码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.sessionStorage.setItem(PRIVATE_SQUARE_PASSWORD_KEY, trimmed);
|
||||||
|
setPrivatePassword(trimmed);
|
||||||
|
setPasswordInput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLockPrivate = () => {
|
||||||
|
window.sessionStorage.removeItem(PRIVATE_SQUARE_PASSWORD_KEY);
|
||||||
|
setPrivatePassword(null);
|
||||||
|
};
|
||||||
|
|
||||||
const renderGrid = (list: typeof agents) => (
|
const renderGrid = (list: typeof agents) => (
|
||||||
<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) => (
|
||||||
@@ -65,6 +139,78 @@ export function AgentGallery() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderPrivateTab = () => {
|
||||||
|
if (!privateFeatureAvailable) {
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<ShieldIcon className="text-muted-foreground h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium">私密广场尚未开启</p>
|
||||||
|
<p className="text-muted-foreground max-w-sm text-sm">
|
||||||
|
请管理员前往「综合管理 → 私密广场配置」开启该功能并设置密码。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!privateFeatureUsable) {
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<ShieldIcon className="text-muted-foreground h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium">私密广场未配置密码</p>
|
||||||
|
<p className="text-muted-foreground max-w-sm text-sm">
|
||||||
|
请管理员在私密广场配置页设置一个访问密码。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!privatePassword) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-sm flex-col gap-4 pt-12 text-center">
|
||||||
|
<div className="bg-muted mx-auto flex h-14 w-14 items-center justify-center rounded-full">
|
||||||
|
<LockIcon className="text-muted-foreground h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">私密广场</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
请输入访问密码,验证通过后才能查看其中的智能体。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="访问密码"
|
||||||
|
value={passwordInput}
|
||||||
|
onChange={(e) => setPasswordInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleSubmitPassword();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSubmitPassword}>进入私密广场</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPrivateLoading) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||||
|
{t.common.loading}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredPrivateAgents.length === 0) {
|
||||||
|
return renderEmpty("私密广场暂时还没有智能体。");
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderGrid(filteredPrivateAgents);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col">
|
<div className="flex size-full flex-col">
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
@@ -84,14 +230,35 @@ export function AgentGallery() {
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
|
onValueChange={(v) =>
|
||||||
|
setActiveTab(v as "all" | "mine" | "private")
|
||||||
|
}
|
||||||
className="flex flex-1 flex-col overflow-hidden"
|
className="flex flex-1 flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="mx-6 mt-4 flex flex-col gap-3">
|
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||||
<TabsList className="w-auto self-start">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
<TabsList className="w-auto self-start">
|
||||||
<TabsTrigger value="all">广场</TabsTrigger>
|
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||||
</TabsList>
|
<TabsTrigger value="all">广场</TabsTrigger>
|
||||||
|
{privateFeatureAvailable && (
|
||||||
|
<TabsTrigger value="private">
|
||||||
|
<LockIcon className="mr-1 h-3.5 w-3.5" />
|
||||||
|
私密广场
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
</TabsList>
|
||||||
|
{activeTab === "private" && privatePassword && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleLockPrivate}
|
||||||
|
title="锁定私密广场,下次需要重新输入密码"
|
||||||
|
>
|
||||||
|
<LockIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
锁定
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<TagSearchBar
|
<TagSearchBar
|
||||||
targetType="agent"
|
targetType="agent"
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
@@ -123,6 +290,15 @@ export function AgentGallery() {
|
|||||||
renderGrid(myAgents)
|
renderGrid(myAgents)
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{privateFeatureAvailable && (
|
||||||
|
<TabsContent
|
||||||
|
value="private"
|
||||||
|
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||||
|
>
|
||||||
|
{renderPrivateTab()}
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ export function AgentManageDialog({
|
|||||||
|
|
||||||
const candidates = useMemo(() => {
|
const candidates = useMemo(() => {
|
||||||
const existing = new Set(agents.map((a) => a.id));
|
const existing = new Set(agents.map((a) => a.id));
|
||||||
return allAgents.filter((a) => a.published && !existing.has(a.id));
|
// Private-square agents are hidden from the homepage selector even if
|
||||||
|
// they're published — the backend ``list_featured`` filter would drop
|
||||||
|
// them again on the way out, so offering them here is just confusing.
|
||||||
|
return allAgents.filter(
|
||||||
|
(a) => a.published && !a.is_private && !existing.has(a.id),
|
||||||
|
);
|
||||||
}, [agents, allAgents]);
|
}, [agents, allAgents]);
|
||||||
|
|
||||||
const busy = setFeaturedMutation.isPending || reorderMutation.isPending;
|
const busy = setFeaturedMutation.isPending || reorderMutation.isPending;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
MessageSquarePlusIcon,
|
MessageSquarePlusIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -143,6 +144,16 @@ export function WorkspaceNavMenu() {
|
|||||||
问答前缀配置
|
问答前缀配置
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
navigate("/page/workspace/settings/private-square")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ShieldCheckIcon />
|
||||||
|
私密广场配置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@@ -103,6 +103,46 @@ export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listPrivateAgents(password: string): Promise<Agent[]> {
|
||||||
|
const res = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/agents/private-square/list`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
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[] };
|
||||||
|
return data.agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAgentPrivate(
|
||||||
|
id: string,
|
||||||
|
isPrivate: boolean,
|
||||||
|
): Promise<Agent> {
|
||||||
|
const res = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/agents/${id}/private`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ is_private: isPrivate }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||||
|
throw new Error(
|
||||||
|
err.detail ?? `Failed to update agent privacy: ${res.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<Agent>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function setAgentFeatured(
|
export async function setAgentFeatured(
|
||||||
id: string,
|
id: string,
|
||||||
order: number | null,
|
order: number | null,
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
getAgent,
|
getAgent,
|
||||||
getAgentSelector,
|
getAgentSelector,
|
||||||
listAgents,
|
listAgents,
|
||||||
|
listPrivateAgents,
|
||||||
reorderFeaturedAgents,
|
reorderFeaturedAgents,
|
||||||
setAgentFeatured,
|
setAgentFeatured,
|
||||||
|
setAgentPrivate,
|
||||||
updateAgent,
|
updateAgent,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
|
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||||
@@ -110,3 +112,34 @@ export function useReorderFeaturedAgents() {
|
|||||||
onSuccess: () => invalidateAgentQueries(queryClient),
|
onSuccess: () => invalidateAgentQueries(queryClient),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query the password-gated private square. ``password`` is sent on every
|
||||||
|
* fetch — wire ``enabled`` to false until the user has typed it so we don't
|
||||||
|
* hit the endpoint with an empty body and get a 403.
|
||||||
|
*/
|
||||||
|
export function usePrivateAgents(password: string | null) {
|
||||||
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ["agents", "private-square", password ?? ""],
|
||||||
|
queryFn: () => listPrivateAgents(password!),
|
||||||
|
enabled: !!password,
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
return { agents: data ?? [], isLoading, error, refetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetAgentPrivate() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, isPrivate }: { id: string; isPrivate: boolean }) =>
|
||||||
|
setAgentPrivate(id, isPrivate),
|
||||||
|
onSuccess: (_data, { id }) => {
|
||||||
|
invalidateAgentQueries(queryClient);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["agents", "private-square"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export interface Agent {
|
|||||||
description: string;
|
description: string;
|
||||||
user_id: string | null;
|
user_id: string | null;
|
||||||
published: boolean;
|
published: boolean;
|
||||||
|
is_private: boolean;
|
||||||
builtin: boolean;
|
builtin: boolean;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
@@ -26,6 +27,7 @@ export interface CreateAgentRequest {
|
|||||||
description?: string;
|
description?: string;
|
||||||
soul?: string;
|
soul?: string;
|
||||||
published?: boolean;
|
published?: boolean;
|
||||||
|
is_private?: boolean;
|
||||||
skills?: string[] | null;
|
skills?: string[] | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
tool_groups?: string[] | null;
|
tool_groups?: string[] | null;
|
||||||
@@ -36,6 +38,7 @@ export interface UpdateAgentRequest {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
soul?: string | null;
|
soul?: string | null;
|
||||||
published?: boolean | null;
|
published?: boolean | null;
|
||||||
|
is_private?: boolean | null;
|
||||||
skills?: string[] | null;
|
skills?: string[] | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
tool_groups?: string[] | null;
|
tool_groups?: string[] | null;
|
||||||
|
|||||||
@@ -42,3 +42,45 @@ export async function updatePromptPrefixSettings(
|
|||||||
}
|
}
|
||||||
return (await response.json()) as PromptPrefixSettings;
|
return (await response.json()) as PromptPrefixSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PrivateSquareStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
password_set: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivateSquareUpdate {
|
||||||
|
enabled?: boolean;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/system-settings/private-square`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to load private square settings: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (await response.json()) as PrivateSquareStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePrivateSquareSettings(
|
||||||
|
body: PrivateSquareUpdate,
|
||||||
|
): Promise<PrivateSquareStatus> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/system-settings/private-square`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ detail: "Failed to update private square" }));
|
||||||
|
throw new Error(detail.detail ?? "Failed to update private square");
|
||||||
|
}
|
||||||
|
return (await response.json()) as PrivateSquareStatus;
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
loadPrivateSquareStatus,
|
||||||
loadPromptPrefixSettings,
|
loadPromptPrefixSettings,
|
||||||
|
updatePrivateSquareSettings,
|
||||||
updatePromptPrefixSettings,
|
updatePromptPrefixSettings,
|
||||||
|
type PrivateSquareUpdate,
|
||||||
type PromptPrefixUpdate,
|
type PromptPrefixUpdate,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
||||||
|
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
|
||||||
|
|
||||||
export function usePromptPrefixSettings() {
|
export function usePromptPrefixSettings() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -27,3 +31,23 @@ export function useUpdatePromptPrefixSettings() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usePrivateSquareStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: PRIVATE_SQUARE_KEY,
|
||||||
|
queryFn: () => loadPrivateSquareStatus(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePrivateSquareSettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: PrivateSquareUpdate) =>
|
||||||
|
updatePrivateSquareSettings(body),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(PRIVATE_SQUARE_KEY, data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2Icon, ShieldIcon } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
usePrivateSquareStatus,
|
||||||
|
useUpdatePrivateSquareSettings,
|
||||||
|
} from "@/core/system-settings/hooks";
|
||||||
|
|
||||||
|
export default function PrivateSquareSettingsPage() {
|
||||||
|
const { data, isLoading, error } = usePrivateSquareStatus();
|
||||||
|
const update = useUpdatePrivateSquareSettings();
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
// The status response never contains the password itself, so re-syncing
|
||||||
|
// ``enabled`` from a refetch wouldn't clobber any in-progress edit — but we
|
||||||
|
// still gate the seed on a one-shot ref to match the prompt-prefix page's
|
||||||
|
// semantics: the admin's draft must not flicker back to the server value if
|
||||||
|
// react-query refetches while they're typing.
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || initializedRef.current) return;
|
||||||
|
setEnabled(data.enabled);
|
||||||
|
initializedRef.current = true;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (password && password !== confirm) {
|
||||||
|
toast.error("两次输入的密码不一致");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({
|
||||||
|
enabled,
|
||||||
|
// Only send the password field when the admin typed something —
|
||||||
|
// omitting it leaves the server-side hash untouched. The empty-string
|
||||||
|
// sentinel that clears the password is sent via the explicit "清除
|
||||||
|
// 密码" button below, not by leaving the field blank.
|
||||||
|
...(password ? { password } : {}),
|
||||||
|
});
|
||||||
|
setPassword("");
|
||||||
|
setConfirm("");
|
||||||
|
toast.success("已保存");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearPassword = async () => {
|
||||||
|
if (!window.confirm("确认清除私密广场密码?清除后任何人都将无法访问私密广场。")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({ password: "" });
|
||||||
|
toast.success("已清除密码");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "清除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">私密广场配置</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
管理员可在此开启「私密广场」,并配置访问密码。开启后,用户可将自己发布的智能体放入私密广场;
|
||||||
|
其他用户只有输入下方设置的密码后,才能在「智能体 → 私密广场」页签下查看到这些智能体。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">启用私密广场</div>
|
||||||
|
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
关闭后,「私密广场」页签不会显示;已放入私密广场的智能体也不会被列出。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
访问密码
|
||||||
|
{data?.password_set ? (
|
||||||
|
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||||
|
(已设置,留空则保持不变)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-destructive ml-2 text-xs font-normal">
|
||||||
|
(尚未设置)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
data?.password_set ? "留空表示不修改密码" : "请输入访问密码"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<p className="text-sm font-medium">确认密码</p>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
placeholder="再次输入新密码"
|
||||||
|
disabled={!password}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
type="button"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={handleClearPassword}
|
||||||
|
disabled={!data?.password_set || update.isPending}
|
||||||
|
>
|
||||||
|
<ShieldIcon className="mr-1.5 size-4" />
|
||||||
|
清除密码
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={update.isPending}>
|
||||||
|
{update.isPending && (
|
||||||
|
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
|
|||||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||||
|
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||||
import LlmMetricsPage from "./LlmMetricsPage";
|
import LlmMetricsPage from "./LlmMetricsPage";
|
||||||
import UsersPage from "./UsersPage";
|
import UsersPage from "./UsersPage";
|
||||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||||
@@ -108,6 +109,16 @@ export default function StrategyRoutes() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="settings/private-square"
|
||||||
|
element={
|
||||||
|
getStoredAuth()?.system_role === "admin" ? (
|
||||||
|
<PrivateSquareSettingsPage />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/workspace/chats/new" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="settings/llm-metrics"
|
path="settings/llm-metrics"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
|
|||||||
import LlmMetricsPage from "./LlmMetricsPage";
|
import LlmMetricsPage from "./LlmMetricsPage";
|
||||||
import MemoryPage from "./MemoryPage";
|
import MemoryPage from "./MemoryPage";
|
||||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||||
|
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||||
import SkillsPage from "./SkillsPage";
|
import SkillsPage from "./SkillsPage";
|
||||||
import UsersPage from "./UsersPage";
|
import UsersPage from "./UsersPage";
|
||||||
@@ -315,6 +316,18 @@ export default function WorkspaceRoutes({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="settings/private-square"
|
||||||
|
element={
|
||||||
|
getStoredAuth()?.system_role === "admin" ? (
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<PrivateSquareSettingsPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/workspace/chats/new" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="settings/llm-metrics"
|
path="settings/llm-metrics"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import yaml
|
|||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.auth.password import verify_password_async
|
||||||
from app.gateway.deps import get_agent_store, get_tag_store
|
from app.gateway.deps import get_agent_store, get_tag_store
|
||||||
from deerflow.config.agents_api_config import get_agents_api_config
|
from deerflow.config.agents_api_config import get_agents_api_config
|
||||||
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
|
from deerflow.config.system_settings import load_system_settings
|
||||||
from deerflow.persistence.agents import AgentStore
|
from deerflow.persistence.agents import AgentStore
|
||||||
from deerflow.persistence.tags import TagStore
|
from deerflow.persistence.tags import TagStore
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
@@ -32,6 +34,7 @@ class AgentResponse(BaseModel):
|
|||||||
description: str = Field(default="", description="Agent description")
|
description: str = Field(default="", description="Agent description")
|
||||||
user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.")
|
user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.")
|
||||||
published: bool = Field(default=False, description="Whether a user-created agent is public")
|
published: bool = Field(default=False, description="Whether a user-created agent is public")
|
||||||
|
is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one")
|
||||||
builtin: bool = Field(default=False, description="True when user_id is null")
|
builtin: bool = Field(default=False, description="True when user_id is null")
|
||||||
created_at: datetime | str | None = None
|
created_at: datetime | str | None = None
|
||||||
updated_at: datetime | str | None = None
|
updated_at: datetime | str | None = None
|
||||||
@@ -68,6 +71,7 @@ class AgentCreateRequest(BaseModel):
|
|||||||
description: str = Field(default="", description="Agent description")
|
description: str = Field(default="", description="Agent description")
|
||||||
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
|
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
|
||||||
published: bool = Field(default=False, description="Whether the new agent should be public")
|
published: bool = Field(default=False, description="Whether the new agent should be public")
|
||||||
|
is_private: bool = Field(default=False, description="When True, the new agent goes into the password-gated private square instead of the public one")
|
||||||
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
|
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
|
||||||
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
|
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
|
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
|
||||||
@@ -80,6 +84,7 @@ class AgentUpdateRequest(BaseModel):
|
|||||||
description: str | None = Field(default=None, description="Updated description")
|
description: str | None = Field(default=None, description="Updated description")
|
||||||
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
||||||
published: bool | None = Field(default=None, description="Updated publication state")
|
published: bool | None = Field(default=None, description="Updated publication state")
|
||||||
|
is_private: bool | None = Field(default=None, description="Move the agent into / out of the private square (true / false)")
|
||||||
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
|
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
|
||||||
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
|
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
|
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
|
||||||
@@ -261,6 +266,7 @@ def _record_to_response(
|
|||||||
description=record.get("description") or "",
|
description=record.get("description") or "",
|
||||||
user_id=record.get("user_id"),
|
user_id=record.get("user_id"),
|
||||||
published=bool(record.get("published", False)),
|
published=bool(record.get("published", False)),
|
||||||
|
is_private=bool(record.get("is_private", False)),
|
||||||
builtin=record.get("user_id") is None,
|
builtin=record.get("user_id") is None,
|
||||||
created_at=record.get("created_at"),
|
created_at=record.get("created_at"),
|
||||||
updated_at=record.get("updated_at"),
|
updated_at=record.get("updated_at"),
|
||||||
@@ -446,6 +452,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
|
|||||||
"description": request.description or "",
|
"description": request.description or "",
|
||||||
"user_id": _current_user_id(http_request),
|
"user_id": _current_user_id(http_request),
|
||||||
"published": request.published,
|
"published": request.published,
|
||||||
|
"is_private": request.is_private,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
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)
|
||||||
@@ -486,6 +493,8 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
|||||||
update_data["description"] = request.description or ""
|
update_data["description"] = request.description or ""
|
||||||
if "published" in fields_set:
|
if "published" in fields_set:
|
||||||
update_data["published"] = bool(request.published)
|
update_data["published"] = bool(request.published)
|
||||||
|
if "is_private" in fields_set:
|
||||||
|
update_data["is_private"] = bool(request.is_private)
|
||||||
|
|
||||||
next_name = update_data.get("name", current["name"])
|
next_name = update_data.get("name", current["name"])
|
||||||
next_description = update_data.get("description", current.get("description") or "")
|
next_description = update_data.get("description", current.get("description") or "")
|
||||||
@@ -662,6 +671,102 @@ async def set_agent_featured(
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e
|
raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateSquareListRequest(BaseModel):
|
||||||
|
"""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.")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentPrivateUpdateRequest(BaseModel):
|
||||||
|
"""Admin-only request body for moving an agent into / out of the private square."""
|
||||||
|
|
||||||
|
is_private: bool = Field(..., description="Set true to move the agent into the private square, false to move it back into the public square.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/agents/private-square/list",
|
||||||
|
response_model=AgentsListResponse,
|
||||||
|
summary="List Private-Square Agents",
|
||||||
|
description=(
|
||||||
|
"Verify the admin-configured private-square password and return every "
|
||||||
|
"agent flagged ``is_private=true``. Returns 404 when no password is set, "
|
||||||
|
"403 when the supplied password does not match, and the full agent list "
|
||||||
|
"otherwise — regardless of ownership, since the password is the gate."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def list_private_square(body: PrivateSquareListRequest, request: Request) -> AgentsListResponse:
|
||||||
|
_require_agents_api_enabled()
|
||||||
|
|
||||||
|
settings = load_system_settings().private_square
|
||||||
|
if not settings.password_hash:
|
||||||
|
raise HTTPException(status_code=404, detail="Private square password is not configured")
|
||||||
|
if not body.password or not await verify_password_async(body.password, settings.password_hash):
|
||||||
|
raise HTTPException(status_code=403, detail="Incorrect private square password")
|
||||||
|
|
||||||
|
try:
|
||||||
|
store = get_agent_store(request)
|
||||||
|
tag_store: TagStore = get_tag_store(request)
|
||||||
|
await _sync_legacy_agents(store)
|
||||||
|
records = await store.list_private()
|
||||||
|
responses = [_record_to_response(r, include_soul=True) for r in records]
|
||||||
|
|
||||||
|
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
|
||||||
|
for resp in responses:
|
||||||
|
resp.tags = assignments.get(resp.id, [])
|
||||||
|
return AgentsListResponse(agents=responses)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to list private agents: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to list private agents: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/agents/{agent_id}/private",
|
||||||
|
response_model=AgentResponse,
|
||||||
|
summary="Move Agent Between Squares (admin)",
|
||||||
|
description=(
|
||||||
|
"Admin-only. Move any agent (built-in or user-owned) into or out of the "
|
||||||
|
"password-gated private square. Owners can already toggle the flag on "
|
||||||
|
"their own agents via the regular ``PUT /agents/{id}`` endpoint; this "
|
||||||
|
"endpoint exists so admins can re-categorize other users' agents too."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def set_agent_private(
|
||||||
|
agent_id: str,
|
||||||
|
body: AgentPrivateUpdateRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> AgentResponse:
|
||||||
|
_require_agents_api_enabled()
|
||||||
|
agent_id = _validate_storage_id(agent_id)
|
||||||
|
|
||||||
|
if not _is_admin_user(request):
|
||||||
|
raise HTTPException(status_code=403, detail="Only system admins can re-categorize other users' agents")
|
||||||
|
|
||||||
|
try:
|
||||||
|
store = get_agent_store(request)
|
||||||
|
await _sync_legacy_agents(store)
|
||||||
|
record = await store.get_any(agent_id)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
|
||||||
|
update = {"is_private": bool(body.is_private)}
|
||||||
|
if record.get("user_id") is None:
|
||||||
|
updated = await store.update_builtin(agent_id, update)
|
||||||
|
else:
|
||||||
|
updated = await store.update(agent_id, str(record["user_id"]), update)
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
|
||||||
|
logger.info("Admin set agent '%s' is_private=%s", agent_id, body.is_private)
|
||||||
|
return _record_to_response(updated, include_soul=True)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to set is_private for agent '%s': %s", agent_id, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
class AgentFeaturedReorderRequest(BaseModel):
|
class AgentFeaturedReorderRequest(BaseModel):
|
||||||
"""Request body for admin to bulk-reorder the homepage featured list."""
|
"""Request body for admin to bulk-reorder the homepage featured list."""
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import logging
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.auth.password import hash_password_async
|
||||||
from app.gateway.deps import get_optional_user_from_request
|
from app.gateway.deps import get_optional_user_from_request
|
||||||
from deerflow.config.system_settings import (
|
from deerflow.config.system_settings import (
|
||||||
PromptPrefixSettings,
|
PromptPrefixSettings,
|
||||||
@@ -31,6 +32,22 @@ class PromptPrefixUpdateRequest(BaseModel):
|
|||||||
prompt_prefix: str | None = None
|
prompt_prefix: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateSquareStatusResponse(BaseModel):
|
||||||
|
"""Status of the private-square feature, readable by any logged-in user.
|
||||||
|
|
||||||
|
The password hash itself is **never** returned — the response only signals
|
||||||
|
whether the feature is enabled and whether a password has been set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = Field(..., description="Whether the private square is exposed to users")
|
||||||
|
password_set: bool = Field(..., description="Whether a password is currently configured")
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateSquareUpdateRequest(BaseModel):
|
||||||
|
enabled: bool | None = Field(default=None, description="Toggle the private-square feature")
|
||||||
|
password: str | None = Field(default=None, description="New password in plain text. Pass an empty string to clear.")
|
||||||
|
|
||||||
|
|
||||||
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
|
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
|
||||||
return PromptPrefixResponse(
|
return PromptPrefixResponse(
|
||||||
enabled=settings.enabled,
|
enabled=settings.enabled,
|
||||||
@@ -61,5 +78,49 @@ async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request
|
|||||||
current = load_system_settings()
|
current = load_system_settings()
|
||||||
fields = body.model_dump(exclude_unset=True)
|
fields = body.model_dump(exclude_unset=True)
|
||||||
updated_prefix = current.prompt_prefix.model_copy(update=fields)
|
updated_prefix = current.prompt_prefix.model_copy(update=fields)
|
||||||
saved = save_system_settings(SystemSettings(prompt_prefix=updated_prefix))
|
saved = save_system_settings(current.model_copy(update={"prompt_prefix": updated_prefix}))
|
||||||
return _to_response(saved.prompt_prefix)
|
return _to_response(saved.prompt_prefix)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/private-square", response_model=PrivateSquareStatusResponse)
|
||||||
|
async def get_private_square_status() -> PrivateSquareStatusResponse:
|
||||||
|
"""Return whether the private square is enabled and a password is set."""
|
||||||
|
settings = load_system_settings().private_square
|
||||||
|
return PrivateSquareStatusResponse(
|
||||||
|
enabled=settings.enabled,
|
||||||
|
password_set=bool(settings.password_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/private-square", response_model=PrivateSquareStatusResponse)
|
||||||
|
async def update_private_square(body: PrivateSquareUpdateRequest, request: Request) -> PrivateSquareStatusResponse:
|
||||||
|
"""Update the private-square configuration. Admin only.
|
||||||
|
|
||||||
|
Setting ``password`` to a non-empty string rehashes and stores it.
|
||||||
|
Passing an empty string clears the password (effectively locking the
|
||||||
|
private square so no one can read it).
|
||||||
|
"""
|
||||||
|
await _require_admin(request)
|
||||||
|
|
||||||
|
current = load_system_settings()
|
||||||
|
updated = current.private_square
|
||||||
|
fields = body.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
update: dict[str, object] = {}
|
||||||
|
if "enabled" in fields and fields["enabled"] is not None:
|
||||||
|
update["enabled"] = bool(fields["enabled"])
|
||||||
|
if "password" in fields and fields["password"] is not None:
|
||||||
|
plain = str(fields["password"])
|
||||||
|
if plain == "":
|
||||||
|
update["password_hash"] = ""
|
||||||
|
else:
|
||||||
|
update["password_hash"] = await hash_password_async(plain)
|
||||||
|
if update:
|
||||||
|
updated = updated.model_copy(update=update)
|
||||||
|
saved = save_system_settings(current.model_copy(update={"private_square": updated}))
|
||||||
|
else:
|
||||||
|
saved = current
|
||||||
|
return PrivateSquareStatusResponse(
|
||||||
|
enabled=saved.private_square.enabled,
|
||||||
|
password_set=bool(saved.private_square.password_hash),
|
||||||
|
)
|
||||||
@@ -37,10 +37,25 @@ class PromptPrefixSettings(BaseModel):
|
|||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateSquareSettings(BaseModel):
|
||||||
|
"""Admin-configurable settings for the agent private-square (私密广场).
|
||||||
|
|
||||||
|
The password gates read access to all agents flagged ``is_private=True``.
|
||||||
|
The hash is stored opaquely — the router layer is responsible for hashing
|
||||||
|
and verifying so the harness package keeps zero dependencies on
|
||||||
|
``app.gateway.auth``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = Field(default=False, description="Whether the private square is offered to users at all")
|
||||||
|
password_hash: str = Field(default="", description="Opaque hash of the admin-configured password. Empty = no password set yet.")
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
class SystemSettings(BaseModel):
|
class SystemSettings(BaseModel):
|
||||||
"""Top-level system settings document."""
|
"""Top-level system settings document."""
|
||||||
|
|
||||||
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
|
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
|
||||||
|
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -81,3 +81,12 @@ class AgentStore(ABC):
|
|||||||
async def list_by_ids(self, agent_ids: list[str], user_id: str) -> list[dict[str, Any]]:
|
async def list_by_ids(self, agent_ids: list[str], user_id: str) -> list[dict[str, Any]]:
|
||||||
"""Return rows for ``agent_ids`` visible to ``user_id``, preserving the input order."""
|
"""Return rows for ``agent_ids`` visible to ``user_id``, preserving the input order."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_private(self) -> list[dict[str, Any]]:
|
||||||
|
"""Return every agent flagged ``is_private=True``.
|
||||||
|
|
||||||
|
Caller is responsible for authorization — typically the caller has
|
||||||
|
already verified the admin-configured private-square password.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
@@ -21,6 +21,12 @@ class AgentRow(Base):
|
|||||||
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
|
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
|
||||||
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||||
|
# When True, the agent is moved into the password-gated "private square"
|
||||||
|
# (私密广场) and is hidden from the regular public square / built-in list.
|
||||||
|
# The flag is independent of ``published``: a private agent is still
|
||||||
|
# visible to its owner in the "我的" tab, and visible to anyone in the
|
||||||
|
# private-square listing once the password has been verified.
|
||||||
|
is_private: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||||
# NULL = not featured on the homepage selector. Non-NULL = admin-curated
|
# NULL = not featured on the homepage selector. Non-NULL = admin-curated
|
||||||
# featured rank (lower sorts first). Only ``published=True`` agents are
|
# featured rank (lower sorts first). Only ``published=True`` agents are
|
||||||
# eligible; the router enforces that invariant on writes. BIGINT so the
|
# eligible; the router enforces that invariant on writes. BIGINT so the
|
||||||
|
|||||||
+34
-6
@@ -5,7 +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 sqlalchemy import func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from deerflow.persistence.agents.base import AgentStore
|
from deerflow.persistence.agents.base import AgentStore
|
||||||
@@ -28,10 +28,31 @@ class AgentRepository(AgentStore):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _visible_filter(user_id: str):
|
def _visible_filter(user_id: str):
|
||||||
|
"""Visibility for single-record fetches (``get_visible``).
|
||||||
|
|
||||||
|
Lenient: a caller who already knows the agent id can fetch it even
|
||||||
|
when it's flagged ``is_private``. The private-square password is the
|
||||||
|
*discovery* gate (listings); individual reads are not re-gated, so a
|
||||||
|
bookmarked / linked private agent stays openable.
|
||||||
|
"""
|
||||||
return or_(AgentRow.user_id.is_(None), AgentRow.user_id == user_id, AgentRow.published.is_(True))
|
return or_(AgentRow.user_id.is_(None), AgentRow.user_id == user_id, AgentRow.published.is_(True))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _listing_filter(user_id: str):
|
||||||
|
"""Visibility for *listings* — hides private-square agents from
|
||||||
|
non-owners so they only surface inside the password-gated endpoint.
|
||||||
|
Owners keep seeing their own private agents in '我的'.
|
||||||
|
"""
|
||||||
|
return or_(
|
||||||
|
AgentRow.user_id == user_id,
|
||||||
|
and_(
|
||||||
|
or_(AgentRow.user_id.is_(None), AgentRow.published.is_(True)),
|
||||||
|
AgentRow.is_private.is_(False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
|
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
stmt = select(AgentRow).where(self._visible_filter(user_id)).order_by(AgentRow.created_at.asc())
|
stmt = select(AgentRow).where(self._listing_filter(user_id)).order_by(AgentRow.created_at.asc())
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return [_row_to_dict(row) for row in result.scalars()]
|
return [_row_to_dict(row) for row in result.scalars()]
|
||||||
@@ -61,6 +82,7 @@ class AgentRepository(AgentStore):
|
|||||||
description=data.get("description") or "",
|
description=data.get("description") or "",
|
||||||
user_id=data.get("user_id"),
|
user_id=data.get("user_id"),
|
||||||
published=bool(data.get("published", False)),
|
published=bool(data.get("published", False)),
|
||||||
|
is_private=bool(data.get("is_private", False)),
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
@@ -99,7 +121,7 @@ class AgentRepository(AgentStore):
|
|||||||
row = await session.get(AgentRow, agent_id)
|
row = await session.get(AgentRow, agent_id)
|
||||||
if row is None or row.user_id != user_id:
|
if row is None or row.user_id != user_id:
|
||||||
return None
|
return None
|
||||||
for key in ("name", "description", "published"):
|
for key in ("name", "description", "published", "is_private"):
|
||||||
if key in data:
|
if key in data:
|
||||||
setattr(row, key, data[key])
|
setattr(row, key, data[key])
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
@@ -112,7 +134,7 @@ class AgentRepository(AgentStore):
|
|||||||
row = await session.get(AgentRow, agent_id)
|
row = await session.get(AgentRow, agent_id)
|
||||||
if row is None or row.user_id is not None:
|
if row is None or row.user_id is not None:
|
||||||
return None
|
return None
|
||||||
for key in ("name", "description", "published"):
|
for key in ("name", "description", "published", "is_private"):
|
||||||
if key in data:
|
if key in data:
|
||||||
setattr(row, key, data[key])
|
setattr(row, key, data[key])
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
@@ -157,7 +179,7 @@ class AgentRepository(AgentStore):
|
|||||||
async def list_featured(self, user_id: str) -> list[dict[str, Any]]:
|
async def list_featured(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(AgentRow)
|
select(AgentRow)
|
||||||
.where(AgentRow.featured_order.is_not(None), self._visible_filter(user_id))
|
.where(AgentRow.featured_order.is_not(None), self._listing_filter(user_id))
|
||||||
.order_by(AgentRow.featured_order.asc(), AgentRow.created_at.asc())
|
.order_by(AgentRow.featured_order.asc(), AgentRow.created_at.asc())
|
||||||
)
|
)
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
@@ -168,9 +190,15 @@ class AgentRepository(AgentStore):
|
|||||||
if not agent_ids:
|
if not agent_ids:
|
||||||
return []
|
return []
|
||||||
order_map = {aid: idx for idx, aid in enumerate(agent_ids)}
|
order_map = {aid: idx for idx, aid in enumerate(agent_ids)}
|
||||||
stmt = select(AgentRow).where(AgentRow.id.in_(agent_ids), self._visible_filter(user_id))
|
stmt = select(AgentRow).where(AgentRow.id.in_(agent_ids), self._listing_filter(user_id))
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
rows = list(result.scalars())
|
rows = list(result.scalars())
|
||||||
rows.sort(key=lambda r: order_map.get(r.id, len(order_map)))
|
rows.sort(key=lambda r: order_map.get(r.id, len(order_map)))
|
||||||
return [_row_to_dict(r) for r in rows]
|
return [_row_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
async def list_private(self) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(AgentRow).where(AgentRow.is_private.is_(True)).order_by(AgentRow.created_at.asc())
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return [_row_to_dict(row) for row in result.scalars()]
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
"""Add ``agents.is_private`` for the private-square (私密广场) feature.
|
||||||
|
|
||||||
|
A nullable-default bool column flagging agents that should live in the
|
||||||
|
password-gated private square instead of the regular public square.
|
||||||
|
|
||||||
|
Revision ID: 20260526_01
|
||||||
|
Revises: 20260522_01
|
||||||
|
Create Date: 2026-05-26
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260526_01"
|
||||||
|
down_revision = "20260522_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:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return False
|
||||||
|
inspector = inspect(op.get_bind())
|
||||||
|
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(table_name: str, index_name: str) -> bool:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return False
|
||||||
|
inspector = inspect(op.get_bind())
|
||||||
|
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if _has_table("agents") and not _has_column("agents", "is_private"):
|
||||||
|
op.add_column(
|
||||||
|
"agents",
|
||||||
|
sa.Column(
|
||||||
|
"is_private",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.false(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if _has_table("agents") and not _has_index("agents", "ix_agents_is_private"):
|
||||||
|
op.create_index("ix_agents_is_private", "agents", ["is_private"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _has_index("agents", "ix_agents_is_private"):
|
||||||
|
op.drop_index("ix_agents_is_private", table_name="agents")
|
||||||
|
if _has_column("agents", "is_private"):
|
||||||
|
op.drop_column("agents", "is_private")
|
||||||
Reference in new issue
Block a user