新增智能体私密广场(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,
|
||||
MessageSquareCode,
|
||||
MessageSquarePlus,
|
||||
ShieldCheck,
|
||||
Network,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
@@ -193,6 +194,13 @@ const menuItems: MenuItem[] = [
|
||||
path: "/page/strategy/settings/prompt-prefix",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-private-square",
|
||||
label: "私密广场配置",
|
||||
icon: ShieldCheck,
|
||||
path: "/page/strategy/settings/private-square",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-llm-metrics",
|
||||
label: "大模型调用统计",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
InfoIcon,
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
SquarePenIcon,
|
||||
TagsIcon,
|
||||
@@ -42,6 +43,7 @@ import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import {
|
||||
useDeleteAgent,
|
||||
useSetAgentFeatured,
|
||||
useSetAgentPrivate,
|
||||
useUpdateAgent,
|
||||
} from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
@@ -61,6 +63,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const deleteAgent = useDeleteAgent();
|
||||
const updateAgent = useUpdateAgent();
|
||||
const setFeatured = useSetAgentFeatured();
|
||||
const setPrivate = useSetAgentPrivate();
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
const [deleteOpen, setDeleteOpen] = 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() {
|
||||
// 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
|
||||
@@ -229,7 +253,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
来自他人
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && agent.published && (
|
||||
{isOwned && agent.published && !agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
@@ -239,6 +263,16 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
已发布
|
||||
</Badge>
|
||||
)}
|
||||
{agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||
title="该智能体位于私密广场,需要密码才能查看"
|
||||
>
|
||||
<ShieldIcon className="size-3" />
|
||||
私密
|
||||
</Badge>
|
||||
)}
|
||||
{agent.featured_order != null && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -348,6 +382,24 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
)}
|
||||
</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 && (
|
||||
<Button
|
||||
size="icon"
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, PlusIcon } from "lucide-react";
|
||||
import { BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
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 { useI18n } from "@/core/i18n/hooks";
|
||||
import { usePrivateSquareStatus } from "@/core/system-settings/hooks";
|
||||
|
||||
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() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
|
||||
"mine",
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
// Name + tag filtering is resolved server-side via the /api/agents query.
|
||||
@@ -25,20 +35,84 @@ export function AgentGallery() {
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
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(
|
||||
() => agents.filter((a) => a.builtin || a.published),
|
||||
() => agents.filter((a) => (a.builtin || a.published) && !a.is_private),
|
||||
[agents],
|
||||
);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
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 = () => {
|
||||
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) => (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{list.map((agent) => (
|
||||
@@ -65,6 +139,78 @@ export function AgentGallery() {
|
||||
</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 (
|
||||
<div className="flex size-full flex-col">
|
||||
{/* Page header */}
|
||||
@@ -84,14 +230,35 @@ export function AgentGallery() {
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
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"
|
||||
>
|
||||
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||
<TabsList className="w-auto self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<TabsTrigger value="all">广场</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<TabsList className="w-auto self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<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
|
||||
targetType="agent"
|
||||
onSearchChange={setSearch}
|
||||
@@ -123,6 +290,15 @@ export function AgentGallery() {
|
||||
renderGrid(myAgents)
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{privateFeatureAvailable && (
|
||||
<TabsContent
|
||||
value="private"
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
>
|
||||
{renderPrivateTab()}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -61,7 +61,12 @@ export function AgentManageDialog({
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
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]);
|
||||
|
||||
const busy = setFeaturedMutation.isPending || reorderMutation.isPending;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
MessageSquarePlusIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
@@ -143,6 +144,16 @@ export function WorkspaceNavMenu() {
|
||||
问答前缀配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/private-square")
|
||||
}
|
||||
>
|
||||
<ShieldCheckIcon />
|
||||
私密广场配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
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(
|
||||
id: string,
|
||||
order: number | null,
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
getAgent,
|
||||
getAgentSelector,
|
||||
listAgents,
|
||||
listPrivateAgents,
|
||||
reorderFeaturedAgents,
|
||||
setAgentFeatured,
|
||||
setAgentPrivate,
|
||||
updateAgent,
|
||||
} from "./api";
|
||||
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
@@ -110,3 +112,34 @@ export function useReorderFeaturedAgents() {
|
||||
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;
|
||||
user_id: string | null;
|
||||
published: boolean;
|
||||
is_private: boolean;
|
||||
builtin: boolean;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
@@ -26,6 +27,7 @@ export interface CreateAgentRequest {
|
||||
description?: string;
|
||||
soul?: string;
|
||||
published?: boolean;
|
||||
is_private?: boolean;
|
||||
skills?: string[] | null;
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
@@ -36,6 +38,7 @@ export interface UpdateAgentRequest {
|
||||
description?: string | null;
|
||||
soul?: string | null;
|
||||
published?: boolean | null;
|
||||
is_private?: boolean | null;
|
||||
skills?: string[] | null;
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
|
||||
@@ -42,3 +42,45 @@ export async function updatePromptPrefixSettings(
|
||||
}
|
||||
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 {
|
||||
loadPrivateSquareStatus,
|
||||
loadPromptPrefixSettings,
|
||||
updatePrivateSquareSettings,
|
||||
updatePromptPrefixSettings,
|
||||
type PrivateSquareUpdate,
|
||||
type PromptPrefixUpdate,
|
||||
} from "./api";
|
||||
|
||||
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
||||
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
|
||||
|
||||
export function usePromptPrefixSettings() {
|
||||
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 RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
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
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
|
||||
@@ -24,6 +24,7 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import MemoryPage from "./MemoryPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||
import SkillsPage from "./SkillsPage";
|
||||
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
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
|
||||
@@ -12,10 +12,12 @@ import yaml
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
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 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.paths import get_paths
|
||||
from deerflow.config.system_settings import load_system_settings
|
||||
from deerflow.persistence.agents import AgentStore
|
||||
from deerflow.persistence.tags import TagStore
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
@@ -32,6 +34,7 @@ class AgentResponse(BaseModel):
|
||||
description: str = Field(default="", description="Agent description")
|
||||
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")
|
||||
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")
|
||||
created_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")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
@@ -80,6 +84,7 @@ class AgentUpdateRequest(BaseModel):
|
||||
description: str | None = Field(default=None, description="Updated description")
|
||||
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
||||
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)")
|
||||
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)")
|
||||
@@ -261,6 +266,7 @@ def _record_to_response(
|
||||
description=record.get("description") or "",
|
||||
user_id=record.get("user_id"),
|
||||
published=bool(record.get("published", False)),
|
||||
is_private=bool(record.get("is_private", False)),
|
||||
builtin=record.get("user_id") is None,
|
||||
created_at=record.get("created_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 "",
|
||||
"user_id": _current_user_id(http_request),
|
||||
"published": request.published,
|
||||
"is_private": request.is_private,
|
||||
}
|
||||
)
|
||||
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 ""
|
||||
if "published" in fields_set:
|
||||
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_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
|
||||
|
||||
|
||||
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):
|
||||
"""Request body for admin to bulk-reorder the homepage featured list."""
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
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 deerflow.config.system_settings import (
|
||||
PromptPrefixSettings,
|
||||
@@ -31,6 +32,22 @@ class PromptPrefixUpdateRequest(BaseModel):
|
||||
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:
|
||||
return PromptPrefixResponse(
|
||||
enabled=settings.enabled,
|
||||
@@ -61,5 +78,49 @@ async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request
|
||||
current = load_system_settings()
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
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)
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
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):
|
||||
"""Top-level system settings document."""
|
||||
|
||||
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
|
||||
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
|
||||
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]]:
|
||||
"""Return rows for ``agent_ids`` visible to ``user_id``, preserving the input order."""
|
||||
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="")
|
||||
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)
|
||||
# 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
|
||||
# featured rank (lower sorts first). Only ``published=True`` agents are
|
||||
# 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 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 deerflow.persistence.agents.base import AgentStore
|
||||
@@ -28,10 +28,31 @@ class AgentRepository(AgentStore):
|
||||
|
||||
@staticmethod
|
||||
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))
|
||||
|
||||
@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]]:
|
||||
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:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
@@ -61,6 +82,7 @@ class AgentRepository(AgentStore):
|
||||
description=data.get("description") or "",
|
||||
user_id=data.get("user_id"),
|
||||
published=bool(data.get("published", False)),
|
||||
is_private=bool(data.get("is_private", False)),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -99,7 +121,7 @@ class AgentRepository(AgentStore):
|
||||
row = await session.get(AgentRow, agent_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
for key in ("name", "description", "published"):
|
||||
for key in ("name", "description", "published", "is_private"):
|
||||
if key in data:
|
||||
setattr(row, key, data[key])
|
||||
row.updated_at = datetime.now(UTC)
|
||||
@@ -112,7 +134,7 @@ class AgentRepository(AgentStore):
|
||||
row = await session.get(AgentRow, agent_id)
|
||||
if row is None or row.user_id is not None:
|
||||
return None
|
||||
for key in ("name", "description", "published"):
|
||||
for key in ("name", "description", "published", "is_private"):
|
||||
if key in data:
|
||||
setattr(row, key, data[key])
|
||||
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]]:
|
||||
stmt = (
|
||||
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())
|
||||
)
|
||||
async with self._sf() as session:
|
||||
@@ -168,9 +190,15 @@ class AgentRepository(AgentStore):
|
||||
if not agent_ids:
|
||||
return []
|
||||
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:
|
||||
result = await session.execute(stmt)
|
||||
rows = list(result.scalars())
|
||||
rows.sort(key=lambda r: order_map.get(r.id, len(order_map)))
|
||||
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