增加智能体广场

- 添加智能体广场功能
- 移除 playwright 日志

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
朱潮 2026-02-21 21:28:55 +08:00
parent 144739fb4a
commit 082fd24727
3 changed files with 565 additions and 11 deletions

View File

@ -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 (

View File

@ -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;

View File

@ -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,7 +1757,14 @@ async def update_bot_settings(
existing_settings = row[1] if row[1] else {}
existing_settings.update(update_json)
# 更新设置
# 更新设置和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()
@ -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"
)