From 3734b6a05f31e892df5be67604c69bc6b63f1d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 26 May 2026 14:24:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=E7=A7=81=E5=AF=86=E5=B9=BF=E5=9C=BA=EF=BC=88is=5Fprivate=20+?= =?UTF-8?q?=20=E7=AE=A1=E7=90=86=E5=91=98=E5=AF=86=E7=A0=81=E9=97=A8?= =?UTF-8?q?=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- frontend-web/src/components/page-sidebar.tsx | 8 + .../workspace/agents/agent-card.tsx | 54 ++++- .../workspace/agents/agent-gallery.tsx | 198 +++++++++++++++++- .../workspace/agents/agent-manage-dialog.tsx | 7 +- .../workspace/workspace-nav-menu.tsx | 11 + frontend-web/src/core/agents/api.ts | 40 ++++ frontend-web/src/core/agents/hooks.ts | 33 +++ frontend-web/src/core/agents/types.ts | 3 + frontend-web/src/core/system-settings/api.ts | 42 ++++ .../src/core/system-settings/hooks.ts | 24 +++ .../src/pages/PrivateSquareSettingsPage.tsx | 155 ++++++++++++++ frontend-web/src/pages/StrategyRoutes.tsx | 11 + frontend-web/src/pages/WorkspaceRoutes.tsx | 13 ++ .../backend/app/gateway/routers/agents.py | 105 ++++++++++ .../app/gateway/routers/system_settings.py | 63 +++++- .../deerflow/config/system_settings.py | 15 ++ .../deerflow/persistence/agents/base.py | 9 + .../deerflow/persistence/agents/model.py | 6 + .../deerflow/persistence/agents/sql.py | 40 +++- .../versions/20260526_01_agents_is_private.py | 62 ++++++ 20 files changed, 879 insertions(+), 20 deletions(-) create mode 100644 frontend-web/src/pages/PrivateSquareSettingsPage.tsx create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260526_01_agents_is_private.py diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index b4e32a8f..87645acb 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -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: "大模型调用统计", diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 0842da4e..aac8f52b 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -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) { 来自他人 )} - {isOwned && agent.published && ( + {isOwned && agent.published && !agent.is_private && ( )} + {agent.is_private && ( + + + 私密 + + )} {agent.featured_order != null && ( )} + {(isOwned || isAdmin) && ( + + )} {canEdit && ( + + + ); + } + + if (isPrivateLoading) { + return ( +
+ {t.common.loading} +
+ ); + } + + if (filteredPrivateAgents.length === 0) { + return renderEmpty("私密广场暂时还没有智能体。"); + } + + return renderGrid(filteredPrivateAgents); + }; + return (
{/* Page header */} @@ -84,14 +230,35 @@ export function AgentGallery() { {/* Tabs */} setActiveTab(v as "all" | "mine")} + onValueChange={(v) => + setActiveTab(v as "all" | "mine" | "private") + } className="flex flex-1 flex-col overflow-hidden" >
- - 我的 - 广场 - +
+ + 我的 + 广场 + {privateFeatureAvailable && ( + + + 私密广场 + + )} + + {activeTab === "private" && privatePassword && ( + + )} +
+ + {privateFeatureAvailable && ( + + {renderPrivateTab()} + + )}
); diff --git a/frontend-web/src/components/workspace/agents/agent-manage-dialog.tsx b/frontend-web/src/components/workspace/agents/agent-manage-dialog.tsx index fab434ed..03231c51 100644 --- a/frontend-web/src/components/workspace/agents/agent-manage-dialog.tsx +++ b/frontend-web/src/components/workspace/agents/agent-manage-dialog.tsx @@ -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; diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 009969ac..110cd8b9 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -11,6 +11,7 @@ import { MessageSquarePlusIcon, Settings2Icon, SettingsIcon, + ShieldCheckIcon, TagsIcon, UsersIcon, } from "lucide-react"; @@ -143,6 +144,16 @@ export function WorkspaceNavMenu() { 问答前缀配置 )} + {isAdmin && ( + + navigate("/page/workspace/settings/private-square") + } + > + + 私密广场配置 + + )} {isAdmin && ( diff --git a/frontend-web/src/core/agents/api.ts b/frontend-web/src/core/agents/api.ts index 5aba562a..3fb17baa 100644 --- a/frontend-web/src/core/agents/api.ts +++ b/frontend-web/src/core/agents/api.ts @@ -103,6 +103,46 @@ export async function reorderFeaturedAgents(agentIds: string[]): Promise { } } +export async function listPrivateAgents(password: string): Promise { + 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 { + 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; +} + export async function setAgentFeatured( id: string, order: number | null, diff --git a/frontend-web/src/core/agents/hooks.ts b/frontend-web/src/core/agents/hooks.ts index e19f56ad..8d8e81ad 100644 --- a/frontend-web/src/core/agents/hooks.ts +++ b/frontend-web/src/core/agents/hooks.ts @@ -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"], + }); + }, + }); +} diff --git a/frontend-web/src/core/agents/types.ts b/frontend-web/src/core/agents/types.ts index 14ea12cc..11ccb03d 100644 --- a/frontend-web/src/core/agents/types.ts +++ b/frontend-web/src/core/agents/types.ts @@ -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; diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index 63f2022c..697e88c5 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -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 { + 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 { + 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; +} diff --git a/frontend-web/src/core/system-settings/hooks.ts b/frontend-web/src/core/system-settings/hooks.ts index 14f4d24b..9c2ee5fd 100644 --- a/frontend-web/src/core/system-settings/hooks.ts +++ b/frontend-web/src/core/system-settings/hooks.ts @@ -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); + }, + }); +} diff --git a/frontend-web/src/pages/PrivateSquareSettingsPage.tsx b/frontend-web/src/pages/PrivateSquareSettingsPage.tsx new file mode 100644 index 00000000..883adbba --- /dev/null +++ b/frontend-web/src/pages/PrivateSquareSettingsPage.tsx @@ -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 ( +
+
+

私密广场配置

+

+ 管理员可在此开启「私密广场」,并配置访问密码。开启后,用户可将自己发布的智能体放入私密广场; + 其他用户只有输入下方设置的密码后,才能在「智能体 → 私密广场」页签下查看到这些智能体。 +

+
+ + {isLoading ? ( +
+ +
+ ) : error ? ( +
+ 加载失败:{error.message} +
+ ) : ( +
+
+
+
启用私密广场
+
+ 关闭后,「私密广场」页签不会显示;已放入私密广场的智能体也不会被列出。 +
+
+ +
+ +
+

+ 访问密码 + {data?.password_set ? ( + + (已设置,留空则保持不变) + + ) : ( + + (尚未设置) + + )} +

+ setPassword(e.target.value)} + placeholder={ + data?.password_set ? "留空表示不修改密码" : "请输入访问密码" + } + /> +
+ +
+

确认密码

+ setConfirm(e.target.value)} + placeholder="再次输入新密码" + disabled={!password} + /> +
+ +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 1fe204e5..a8c3ff85 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -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() { ) } /> + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> 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.""" diff --git a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py index 0430a45f..48940387 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py +++ b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py @@ -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), + ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py index 001f3788..0a2d41ea 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py @@ -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") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py index dc247b7c..39b5e6f7 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/base.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py index 77dde87f..42e2c867 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/model.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py index e72e7102..60474ac5 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py @@ -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()] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260526_01_agents_is_private.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260526_01_agents_is_private.py new file mode 100644 index 00000000..1bf004f9 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260526_01_agents_is_private.py @@ -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")