From 082fd247272577ea5167ebd93e013fe931a0dd60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B1=E6=BD=AE?= Date: Sat, 21 Feb 2026 21:28:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=99=BA=E8=83=BD=E4=BD=93?= =?UTF-8?q?=E5=B9=BF=E5=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加智能体广场功能 - 移除 playwright 日志 Co-Authored-By: Claude Opus 4.5 --- create_tables.sql | 4 + public/index.html | 10 + routes/bot_manager.py | 562 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 565 insertions(+), 11 deletions(-) diff --git a/create_tables.sql b/create_tables.sql index 6c38a87..df8df1d 100644 --- a/create_tables.sql +++ b/create_tables.sql @@ -22,12 +22,16 @@ CREATE TABLE IF NOT EXISTS agent_bots ( name VARCHAR(255) NOT NULL, settings JSONB DEFAULT '{"language": "zh", "enable_memori": false, "enable_thinking": false, "tool_response": false}'::jsonb, owner_id UUID NOT NULL REFERENCES agent_user(id) ON DELETE RESTRICT, + is_published BOOLEAN DEFAULT FALSE, -- 是否发布到智能体广场 + copied_from UUID REFERENCES agent_bots(id) ON DELETE SET NULL, -- 复制来源的bot id created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- agent_bots 索引 CREATE INDEX IF NOT EXISTS idx_agent_bots_owner_id ON agent_bots(owner_id); +CREATE INDEX IF NOT EXISTS idx_agent_bots_is_published ON agent_bots(is_published) WHERE is_published = TRUE; +CREATE INDEX IF NOT EXISTS idx_agent_bots_copied_from ON agent_bots(copied_from); -- 3. 创建 agent_user_tokens 表 CREATE TABLE IF NOT EXISTS agent_user_tokens ( diff --git a/public/index.html b/public/index.html index 43fdb07..94aefd9 100644 --- a/public/index.html +++ b/public/index.html @@ -651,6 +651,7 @@ border: 1px solid var(--border); border-radius: 20px; font-size: 13px; + color: var(--text); cursor: pointer; transition: all 0.15s ease; } @@ -660,6 +661,15 @@ color: var(--primary); } + .dark .suggestion-chip { + color: var(--text); + } + + .dark .suggestion-chip:hover { + border-color: var(--primary); + color: var(--primary); + } + /* ===== Messages ===== */ .message { display: flex; diff --git a/routes/bot_manager.py b/routes/bot_manager.py index f464fc1..82110e7 100644 --- a/routes/bot_manager.py +++ b/routes/bot_manager.py @@ -7,6 +7,8 @@ import logging import uuid import hashlib import secrets +import os +import shutil from datetime import datetime, timedelta from typing import Optional, List from fastapi import APIRouter, HTTPException, Header @@ -19,6 +21,39 @@ logger = logging.getLogger('app') router = APIRouter() +# ============== 辅助函数 ============== + +def copy_skills_folder(source_bot_id: str, target_bot_id: str) -> bool: + """ + 复制智能体的 skills 文件夹 + + Args: + source_bot_id: 源智能体 ID + target_bot_id: 目标智能体 ID + + Returns: + bool: 是否成功复制 + """ + try: + source_skills_path = os.path.join('projects', 'uploads', source_bot_id, 'skills') + target_skills_path = os.path.join('projects', 'uploads', target_bot_id, 'skills') + + if os.path.exists(source_skills_path): + # 如果目标目录已存在,先删除 + if os.path.exists(target_skills_path): + shutil.rmtree(target_skills_path) + + # 复制整个 skills 文件夹 + shutil.copytree(source_skills_path, target_skills_path) + logger.info(f"Copied skills folder from {source_bot_id} to {target_bot_id}") + return True + else: + logger.info(f"Source skills folder not found: {source_skills_path}") + return False + except Exception as e: + logger.error(f"Failed to copy skills folder: {e}") + return False + # ============== Admin 配置 ============== ADMIN_USERNAME = "admin" ADMIN_PASSWORD = "Admin123" # 生产环境应使用环境变量 @@ -370,6 +405,8 @@ class BotResponse(BaseModel): bot_id: str is_owner: bool = False is_shared: bool = False + is_published: bool = False # 是否发布到广场 + copied_from: Optional[str] = None # 复制来源的bot id owner: Optional[dict] = None # {id, username} role: Optional[str] = None # 'viewer', 'editor', None for owner shared_at: Optional[str] = None @@ -394,6 +431,7 @@ class BotSettingsUpdate(BaseModel): enable_thinking: Optional[bool] = None tool_response: Optional[bool] = None skills: Optional[str] = None + is_published: Optional[bool] = None # 是否发布到广场 class ModelInfo(BaseModel): @@ -421,9 +459,31 @@ class BotSettingsResponse(BaseModel): enable_thinking: bool tool_response: bool skills: Optional[str] + is_published: bool = False # 是否发布到广场 + copied_from: Optional[str] = None # 复制来源的bot id updated_at: str +# --- 广场相关 --- +class MarketplaceBotResponse(BaseModel): + """广场 Bot 响应(公开信息)""" + id: str + name: str + description: Optional[str] = None + avatar_url: Optional[str] = None + owner_name: Optional[str] = None + suggestions: Optional[List[str]] = None + copy_count: int = 0 # 被复制次数 + created_at: str + updated_at: str + + +class MarketplaceListResponse(BaseModel): + """广场列表响应""" + bots: List[MarketplaceBotResponse] + total: int + + # --- 会话相关 --- class SessionCreate(BaseModel): """创建会话请求""" @@ -802,6 +862,58 @@ async def migrate_bot_settings_to_jsonb(): logger.info("Settings column already exists, skipping migration") +async def migrate_add_marketplace_fields(): + """ + 添加智能体广场相关字段到 agent_bots 表 + """ + pool = get_db_pool_manager().pool + + async with pool.connection() as conn: + async with conn.cursor() as cursor: + # 1. 添加 is_published 字段 + await cursor.execute(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'agent_bots' AND column_name = 'is_published' + """) + has_is_published = await cursor.fetchone() + + if not has_is_published: + logger.info("Adding is_published column to agent_bots table") + await cursor.execute(""" + ALTER TABLE agent_bots + ADD COLUMN is_published BOOLEAN DEFAULT FALSE + """) + # 创建部分索引,只索引发布的 bots + await cursor.execute(""" + CREATE INDEX idx_agent_bots_is_published + ON agent_bots(is_published) WHERE is_published = TRUE + """) + logger.info("is_published column added successfully") + + # 2. 添加 copied_from 字段 + await cursor.execute(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'agent_bots' AND column_name = 'copied_from' + """) + has_copied_from = await cursor.fetchone() + + if not has_copied_from: + logger.info("Adding copied_from column to agent_bots table") + await cursor.execute(""" + ALTER TABLE agent_bots + ADD COLUMN copied_from UUID REFERENCES agent_bots(id) ON DELETE SET NULL + """) + await cursor.execute(""" + CREATE INDEX idx_agent_bots_copied_from ON agent_bots(copied_from) + """) + logger.info("copied_from column added successfully") + + await conn.commit() + logger.info("Marketplace fields migration completed") + + async def init_bot_manager_tables(): """ 初始化 Bot Manager 相关的所有数据库表 @@ -813,6 +925,8 @@ async def init_bot_manager_tables(): await migrate_bot_settings_to_jsonb() # 2. User 和 shares 迁移 await migrate_bot_owner_and_shares() + # 3. Marketplace 字段迁移 + await migrate_add_marketplace_fields() # SQL 表创建语句 tables_sql = [ @@ -1205,7 +1319,8 @@ async def get_bots(authorization: Optional[str] = Header(None)): # 管理员可以看到所有 Bot await cursor.execute(""" SELECT b.id, b.name, b.bot_id, b.created_at, b.updated_at, b.settings, - u.id as owner_id, u.username as owner_username + u.id as owner_id, u.username as owner_username, + b.is_published, b.copied_from FROM agent_bots b LEFT JOIN agent_user u ON b.owner_id = u.id ORDER BY b.created_at DESC @@ -1219,6 +1334,8 @@ async def get_bots(authorization: Optional[str] = Header(None)): bot_id=str(row[0]), # bot_id 也指向主键 id is_owner=True, is_shared=False, + is_published=row[8] if row[8] else False, + copied_from=str(row[9]) if row[9] else None, owner={"id": str(row[6]), "username": row[7]} if row[6] else None, role=None, description=row[5].get('description') if row[5] else None, @@ -1234,7 +1351,8 @@ async def get_bots(authorization: Optional[str] = Header(None)): await cursor.execute(""" SELECT DISTINCT b.id, b.name, b.bot_id, b.created_at, b.updated_at, b.settings, u.id as owner_id, u.username as owner_username, - s.role, s.shared_at, s.expires_at + s.role, s.shared_at, s.expires_at, + b.is_published, b.copied_from FROM agent_bots b LEFT JOIN agent_user u ON b.owner_id = u.id LEFT JOIN bot_shares s ON b.id = s.bot_id AND s.user_id = %s @@ -1253,6 +1371,8 @@ async def get_bots(authorization: Optional[str] = Header(None)): bot_id=str(row[0]), # bot_id 也指向主键 id is_owner=(row[6] is not None and str(row[6]) == user_id), is_shared=(row[6] is not None and str(row[6]) != user_id and row[8] is not None), + is_published=row[11] if row[11] else False, + copied_from=str(row[12]) if row[12] else None, owner={"id": str(row[6]), "username": row[7]} if row[6] is not None else None, role=row[8] if row[8] is not None else None, shared_at=datetime_to_str(row[9]) if row[9] is not None else None, @@ -1486,7 +1606,7 @@ async def get_bot_settings(bot_uuid: str, authorization: Optional[str] = Header( async with pool.connection() as conn: async with conn.cursor() as cursor: await cursor.execute(""" - SELECT id, settings, updated_at + SELECT id, settings, updated_at, is_published, copied_from FROM agent_bots WHERE id = %s """, (bot_uuid,)) @@ -1495,7 +1615,7 @@ async def get_bot_settings(bot_uuid: str, authorization: Optional[str] = Header( if not row: raise HTTPException(status_code=404, detail="Bot not found") - bot_id, settings_json, updated_at = row + bot_id, settings_json, updated_at, is_published, copied_from = row settings = settings_json if settings_json else {} # 获取关联的模型信息 @@ -1538,6 +1658,8 @@ async def get_bot_settings(bot_uuid: str, authorization: Optional[str] = Header( enable_thinking=settings.get('enable_thinking', False), tool_response=settings.get('tool_response', False), skills=settings.get('skills'), + is_published=is_published if is_published else False, + copied_from=str(copied_from) if copied_from else None, updated_at=datetime_to_str(updated_at) ) @@ -1617,7 +1739,10 @@ async def update_bot_settings( if request.skills is not None: update_json['skills'] = request.skills - if not update_json: + # is_published 是表字段,不在 settings JSON 中 + need_update_published = request.is_published is not None + + if not update_json and not need_update_published: raise HTTPException(status_code=400, detail="No fields to update") async with pool.connection() as conn: @@ -1632,12 +1757,19 @@ async def update_bot_settings( existing_settings = row[1] if row[1] else {} existing_settings.update(update_json) - # 更新设置 - await cursor.execute(""" - UPDATE agent_bots - SET settings = %s, updated_at = NOW() - WHERE id = %s - """, (json.dumps(existing_settings), bot_uuid)) + # 更新设置和is_published字段 + if need_update_published: + await cursor.execute(""" + UPDATE agent_bots + SET settings = %s, is_published = %s, updated_at = NOW() + WHERE id = %s + """, (json.dumps(existing_settings), request.is_published, bot_uuid)) + else: + await cursor.execute(""" + UPDATE agent_bots + SET settings = %s, updated_at = NOW() + WHERE id = %s + """, (json.dumps(existing_settings), bot_uuid)) await conn.commit() @@ -2985,3 +3117,411 @@ async def remove_bot_share( ) +# ============== 智能体广场 API ============== + +@router.get("/api/v1/marketplace/bots", response_model=MarketplaceListResponse) +async def get_marketplace_bots( + page: int = 1, + page_size: int = 20, + search: str = "", + authorization: Optional[str] = Header(None) +): + """ + 获取广场智能体列表 + + Args: + page: 页码(从1开始) + page_size: 每页数量 + search: 搜索关键词(名称/描述) + authorization: Bearer token(可选,用于判断是否已登录) + + Returns: + MarketplaceListResponse: 广场智能体列表 + """ + # 不强制要求登录,但如果有 token 则验证 + user_valid, _, _ = await verify_user_auth(authorization) + + pool = get_db_pool_manager().pool + offset = (page - 1) * page_size + + async with pool.connection() as conn: + async with conn.cursor() as cursor: + # 构建搜索条件 + search_condition = "" + params = [] + + if search: + search_condition = "AND (b.name ILIKE %s OR b.settings->>'description' ILIKE %s)" + search_param = f"%{search}%" + params.extend([search_param, search_param]) + + # 获取总数 + count_query = f""" + SELECT COUNT(*) + FROM agent_bots b + WHERE b.is_published = TRUE + {search_condition} + """ + await cursor.execute(count_query, params) + total = (await cursor.fetchone())[0] + + # 获取列表 + list_query = f""" + SELECT b.id, b.name, b.settings, b.created_at, b.updated_at, + u.username as owner_name + FROM agent_bots b + LEFT JOIN agent_user u ON b.owner_id = u.id + WHERE b.is_published = TRUE + {search_condition} + ORDER BY b.updated_at DESC + LIMIT %s OFFSET %s + """ + params.extend([page_size, offset]) + await cursor.execute(list_query, params) + rows = await cursor.fetchall() + + bots = [] + for row in rows: + settings = row[2] if row[2] else {} + # 计算被复制次数 + await cursor.execute(""" + SELECT COUNT(*) FROM agent_bots WHERE copied_from = %s + """, (row[0],)) + copy_count = (await cursor.fetchone())[0] + + bots.append(MarketplaceBotResponse( + id=str(row[0]), + name=row[1], + description=settings.get('description'), + avatar_url=settings.get('avatar_url'), + owner_name=row[5], + suggestions=settings.get('suggestions'), + copy_count=copy_count, + created_at=datetime_to_str(row[3]), + updated_at=datetime_to_str(row[4]) + )) + + return MarketplaceListResponse(bots=bots, total=total) + + +@router.get("/api/v1/marketplace/bots/{bot_uuid}", response_model=MarketplaceBotResponse) +async def get_marketplace_bot_detail( + bot_uuid: str, + authorization: Optional[str] = Header(None) +): + """ + 获取广场智能体详情 + + Args: + bot_uuid: Bot UUID + authorization: Bearer token(可选) + + Returns: + MarketplaceBotResponse: 智能体公开信息 + """ + pool = get_db_pool_manager().pool + + async with pool.connection() as conn: + async with conn.cursor() as cursor: + await cursor.execute(""" + SELECT b.id, b.name, b.settings, b.created_at, b.updated_at, + u.username as owner_name, b.is_published + FROM agent_bots b + LEFT JOIN agent_user u ON b.owner_id = u.id + WHERE b.id = %s + """, (bot_uuid,)) + row = await cursor.fetchone() + + if not row: + raise HTTPException(status_code=404, detail="Bot not found") + + if not row[6]: # is_published + raise HTTPException(status_code=404, detail="Bot not found in marketplace") + + settings = row[2] if row[2] else {} + + # 计算被复制次数 + await cursor.execute(""" + SELECT COUNT(*) FROM agent_bots WHERE copied_from = %s + """, (bot_uuid,)) + copy_count = (await cursor.fetchone())[0] + + return MarketplaceBotResponse( + id=str(row[0]), + name=row[1], + description=settings.get('description'), + avatar_url=settings.get('avatar_url'), + owner_name=row[5], + suggestions=settings.get('suggestions'), + copy_count=copy_count, + created_at=datetime_to_str(row[3]), + updated_at=datetime_to_str(row[4]) + ) + + +@router.post("/api/v1/marketplace/bots/{bot_uuid}/copy", response_model=BotResponse) +async def copy_marketplace_bot( + bot_uuid: str, + authorization: Optional[str] = Header(None) +): + """ + 复制广场智能体到个人管理 + + Args: + bot_uuid: 要复制的 Bot UUID + authorization: Bearer token + + Returns: + BotResponse: 新创建的 Bot 信息 + """ + user_valid, user_id, user_username = await verify_user_auth(authorization) + + if not user_valid: + raise HTTPException( + status_code=401, + detail="Unauthorized" + ) + + pool = get_db_pool_manager().pool + + async with pool.connection() as conn: + async with conn.cursor() as cursor: + # 获取原始 Bot 信息 + await cursor.execute(""" + SELECT id, name, settings, is_published + FROM agent_bots + WHERE id = %s AND is_published = TRUE + """, (bot_uuid,)) + original = await cursor.fetchone() + + if not original: + raise HTTPException(status_code=404, detail="Bot not found in marketplace") + + original_id, original_name, original_settings, _ = original + settings = original_settings if original_settings else {} + + # 创建新 Bot(名称加"副本"后缀) + new_name = f"{original_name} (副本)" + new_bot_id = str(uuid.uuid4()) + + # 只复制部分设置(不复制 system_prompt, MCP配置等) + new_settings = { + 'language': settings.get('language', 'zh'), + 'avatar_url': settings.get('avatar_url'), + 'description': settings.get('description'), + 'suggestions': settings.get('suggestions'), + 'dataset_ids': settings.get('dataset_ids'), + # 不复制的设置: + # 'model_id', 'system_prompt', 'enable_memori', 'enable_thinking', 'tool_response', 'skills' + 'enable_memori': False, + 'enable_thinking': False, + 'tool_response': False, + } + + # 插入新 Bot + await cursor.execute(""" + INSERT INTO agent_bots (name, bot_id, owner_id, settings, copied_from) + VALUES (%s, %s, %s, %s, %s) + RETURNING id, created_at, updated_at + """, (new_name, new_bot_id, user_id, json.dumps(new_settings), original_id)) + new_row = await cursor.fetchone() + new_id, created_at, updated_at = new_row + + await conn.commit() + + # 复制 skills 文件夹 + copy_skills_folder(str(original_id), str(new_id)) + + return BotResponse( + id=str(new_id), + name=new_name, + bot_id=new_bot_id, + is_owner=True, + is_shared=False, + is_published=False, + copied_from=str(original_id), + owner={"id": str(user_id), "username": user_username}, + role=None, + description=new_settings.get('description'), + avatar_url=new_settings.get('avatar_url'), + created_at=datetime_to_str(created_at), + updated_at=datetime_to_str(updated_at) + ) + + +@router.patch("/api/v1/bots/{bot_uuid}/publish", response_model=SuccessResponse) +async def toggle_bot_publication( + bot_uuid: str, + authorization: Optional[str] = Header(None) +): + """ + 切换智能体发布状态(仅所有者可操作) + + Args: + bot_uuid: Bot UUID + authorization: Bearer token + + Returns: + SuccessResponse: 操作结果 + """ + user_valid, user_id, user_username = await verify_user_auth(authorization) + + if not user_valid: + raise HTTPException( + status_code=401, + detail="Unauthorized" + ) + + pool = get_db_pool_manager().pool + + async with pool.connection() as conn: + async with conn.cursor() as cursor: + # 检查是否是所有者 + await cursor.execute(""" + SELECT id, is_published FROM agent_bots WHERE id = %s AND owner_id = %s + """, (bot_uuid, user_id)) + row = await cursor.fetchone() + + if not row: + raise HTTPException( + status_code=403, + detail="Only bot owner can toggle publication status" + ) + + current_status = row[1] if row[1] else False + new_status = not current_status + + # 更新状态 + await cursor.execute(""" + UPDATE agent_bots + SET is_published = %s, updated_at = NOW() + WHERE id = %s + """, (new_status, bot_uuid)) + + await conn.commit() + + action = "发布到" if new_status else "取消发布" + return SuccessResponse( + success=True, + message=f"Bot {action} marketplace successfully" + ) + + +@router.post("/api/v1/bots/{bot_uuid}/sync-from-source", response_model=SuccessResponse) +async def sync_bot_from_source( + bot_uuid: str, + authorization: Optional[str] = Header(None) +): + """ + 从原始智能体同步配置(仅限从广场复制的智能体) + + 同步以下配置: + - 系统提示词 + - MCP 服务器配置 + - 技能配置 + - skills 文件夹 + + Args: + bot_uuid: Bot UUID + authorization: Bearer token + + Returns: + SuccessResponse: 操作结果 + """ + user_valid, user_id, user_username = await verify_user_auth(authorization) + + if not user_valid: + raise HTTPException( + status_code=401, + detail="Unauthorized" + ) + + pool = get_db_pool_manager().pool + + async with pool.connection() as conn: + async with conn.cursor() as cursor: + # 获取当前 Bot 信息 + await cursor.execute(""" + SELECT id, copied_from, settings, owner_id + FROM agent_bots + WHERE id = %s + """, (bot_uuid,)) + current_bot = await cursor.fetchone() + + if not current_bot: + raise HTTPException(status_code=404, detail="Bot not found") + + current_id, copied_from, current_settings, owner_id = current_bot + + # 检查是否是从广场复制的 + if not copied_from: + raise HTTPException( + status_code=400, + detail="This bot is not copied from marketplace" + ) + + # 检查是否是所有者 + if str(owner_id) != str(user_id): + raise HTTPException( + status_code=403, + detail="Only bot owner can sync from source" + ) + + # 获取原始 Bot 信息 + await cursor.execute(""" + SELECT id, settings + FROM agent_bots + WHERE id = %s AND is_published = TRUE + """, (copied_from,)) + source_bot = await cursor.fetchone() + + if not source_bot: + raise HTTPException( + status_code=404, + detail="Source bot not found or not published" + ) + + source_id, source_settings = source_bot + source_settings = source_settings if source_settings else {} + current_settings = current_settings if current_settings else {} + + # 同步配置:系统提示词、MCP、skill + current_settings['system_prompt'] = source_settings.get('system_prompt') + current_settings['skills'] = source_settings.get('skills') + + # 更新当前 Bot 的设置 + await cursor.execute(""" + UPDATE agent_bots + SET settings = %s, updated_at = NOW() + WHERE id = %s + """, (json.dumps(current_settings), bot_uuid)) + + # 同步 MCP 服务器配置 + await cursor.execute(""" + DELETE FROM agent_mcp_servers WHERE bot_id = %s + """, (bot_uuid,)) + + await cursor.execute(""" + SELECT name, type, config, enabled + FROM agent_mcp_servers + WHERE bot_id = %s + """, (copied_from,)) + source_mcp_servers = await cursor.fetchall() + + for server in source_mcp_servers: + server_name, server_type, server_config, server_enabled = server + await cursor.execute(""" + INSERT INTO agent_mcp_servers (bot_id, name, type, config, enabled) + VALUES (%s, %s, %s, %s, %s) + """, (bot_uuid, server_name, server_type, json.dumps(server_config), server_enabled)) + + await conn.commit() + + # 复制 skills 文件夹 + copy_skills_folder(str(copied_from), str(bot_uuid)) + + return SuccessResponse( + success=True, + message="Bot synced from source successfully" + ) +