Compare commits

...

12 Commits

Author SHA1 Message Date
朱潮
f9ba3c8e51 添加聊天记录查询 2026-01-18 12:29:20 +08:00
朱潮
fa3e30cc07 docker file 2026-01-18 10:27:14 +08:00
朱潮
de3d5f6bf1 修改docker-compsoe 2026-01-18 10:10:34 +08:00
朱潮
20d5e96986 feat: 添加 PostgreSQL 支持用于 checkpoint 存储
- 添加 postgres:16-alpine 服务配置
- 配置 CHECKPOINT_DB_URL 环境变量
- 添加服务依赖和健康检查

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 00:32:23 +08:00
朱潮
723b249e42 增加2个系统级别的skill 2026-01-16 23:05:30 +08:00
朱潮
90117b41fe 修复符号链接的问题,和deep_agent提示词 2026-01-13 14:22:44 +08:00
朱潮
174a5e2059 deep_agent支持 checkpoint 2026-01-11 00:08:19 +08:00
朱潮
b93c40d5a5 merge 2026-01-08 23:10:09 +08:00
朱潮
d45079ca55 feat: 将system_prompt解析从markdown代码块改为XML标签格式
- agent_config.py: enable_thinking判断从 ```guideline 改为 <guidelines>
- fastapi_utils.py:
  - preamble解析从 ```preamble``` 改为 <preamble>
  - guidelines/tools/scenarios/terms 块解析从 markdown 格式改为 XML 标签格式
  - 移除不再使用的 parse_guidelines_text 函数

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 22:56:43 +08:00
朱潮
5a4aee91ab merge 2026-01-08 22:28:10 +08:00
朱潮
43ca06f591 修复system_prompt 2026-01-08 22:24:58 +08:00
朱潮
c1bf679166 enable_thinking 2026-01-08 18:03:21 +08:00
17 changed files with 6498 additions and 894 deletions

View File

@ -85,6 +85,8 @@ class AgentConfig:
robot_type = "deep_agent"
preamble_text, system_prompt = get_preamble_text(request.language, request.system_prompt)
enable_thinking = request.enable_thinking and "<guidelines>" in request.system_prompt
config = cls(
bot_id=request.bot_id,
api_key=api_key,
@ -96,7 +98,7 @@ class AgentConfig:
robot_type=robot_type,
user_identifier=request.user_identifier,
session_id=request.session_id,
enable_thinking=request.enable_thinking,
enable_thinking=enable_thinking,
project_dir=project_dir,
stream=request.stream,
tool_response=request.tool_response,
@ -121,10 +123,11 @@ class AgentConfig:
messages = []
language = request.language or bot_config.get("language", "zh")
preamble_text, system_prompt = get_preamble_text(language, bot_config.get("system_prompt"))
robot_type = bot_config.get("robot_type", "general_agent")
if robot_type == "catalog_agent":
robot_type = "deep_agent"
enable_thinking = request.enable_thinking and "<guidelines>" in bot_config.get("system_prompt")
config = cls(
bot_id=request.bot_id,
@ -137,7 +140,7 @@ class AgentConfig:
robot_type=robot_type,
user_identifier=request.user_identifier,
session_id=request.session_id,
enable_thinking=request.enable_thinking,
enable_thinking=enable_thinking,
project_dir=project_dir,
stream=request.stream,
tool_response=request.tool_response,

View File

@ -0,0 +1,290 @@
"""
聊天历史记录管理器
直接保存完整的原始聊天消息到数据库不受 checkpoint summary 影响
"""
import logging
from datetime import datetime
from typing import Optional, List, Dict, Any
from dataclasses import dataclass
from psycopg_pool import AsyncConnectionPool
logger = logging.getLogger('app')
@dataclass
class ChatMessage:
"""聊天消息"""
id: str
session_id: str
role: str
content: str
created_at: datetime
class ChatHistoryManager:
"""
聊天历史管理器
使用独立的数据库表存储完整的聊天历史记录
复用 checkpoint_manager PostgreSQL 连接池
"""
def __init__(self, pool: AsyncConnectionPool):
"""
初始化聊天历史管理器
Args:
pool: PostgreSQL 连接池
"""
self._pool = pool
async def create_table(self) -> None:
"""创建 chat_messages 表"""
async with self._pool.connection() as conn:
async with conn.cursor() as cursor:
await cursor.execute("""
CREATE TABLE IF NOT EXISTS chat_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
bot_id VARCHAR(255),
user_identifier VARCHAR(255)
)
""")
# 创建索引以加速查询
await cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_created
ON chat_messages (session_id, created_at DESC)
""")
await conn.commit()
logger.info("chat_messages table created successfully")
async def save_message(
self,
session_id: str,
role: str,
content: str,
bot_id: Optional[str] = None,
user_identifier: Optional[str] = None
) -> str:
"""
保存一条聊天消息
Args:
session_id: 会话ID
role: 消息角色 ('user' 'assistant')
content: 消息内容
bot_id: 机器人ID
user_identifier: 用户标识
Returns:
str: 消息ID
"""
async with self._pool.connection() as conn:
async with conn.cursor() as cursor:
await cursor.execute("""
INSERT INTO chat_messages (session_id, role, content, bot_id, user_identifier)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""", (session_id, role, content, bot_id, user_identifier))
result = await cursor.fetchone()
await conn.commit()
message_id = str(result[0]) if result else None
logger.debug(f"Saved message: session_id={session_id}, role={role}, id={message_id}")
return message_id
async def save_messages(
self,
session_id: str,
messages: List[Dict[str, str]],
bot_id: Optional[str] = None,
user_identifier: Optional[str] = None
) -> List[str]:
"""
批量保存聊天消息
Args:
session_id: 会话ID
messages: 消息列表每条消息包含 role content
bot_id: 机器人ID
user_identifier: 用户标识
Returns:
List[str]: 消息ID列表
"""
message_ids = []
async with self._pool.connection() as conn:
async with conn.cursor() as cursor:
for msg in messages:
role = msg.get('role')
content = msg.get('content', '')
if not role or not content:
continue
await cursor.execute("""
INSERT INTO chat_messages (session_id, role, content, bot_id, user_identifier)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""", (session_id, role, content, bot_id, user_identifier))
result = await cursor.fetchone()
message_id = str(result[0]) if result else None
message_ids.append(message_id)
await conn.commit()
logger.info(f"Saved {len(message_ids)} messages for session_id={session_id}")
return message_ids
async def get_history(
self,
session_id: str,
limit: int = 20,
before_id: Optional[str] = None
) -> Dict[str, Any]:
"""
获取聊天历史记录倒序最新在前
Args:
session_id: 会话ID
limit: 返回的消息数量
before_id: 获取此消息ID之前的消息用于分页
Returns:
dict: {
"messages": [...],
"has_more": bool
}
"""
async with self._pool.connection() as conn:
async with conn.cursor() as cursor:
if before_id:
# 查询 before_id 的 created_at然后获取更早的消息
await cursor.execute("""
SELECT created_at FROM chat_messages
WHERE id = %s
""", (before_id,))
result = await cursor.fetchone()
if not result:
# 如果找不到指定的 ID从头开始
query = """
SELECT id, session_id, role, content, created_at
FROM chat_messages
WHERE session_id = %s
ORDER BY created_at DESC
LIMIT %s + 1
"""
await cursor.execute(query, (session_id, limit))
else:
before_time = result[0]
query = """
SELECT id, session_id, role, content, created_at
FROM chat_messages
WHERE session_id = %s AND created_at < %s
ORDER BY created_at DESC
LIMIT %s + 1
"""
await cursor.execute(query, (session_id, before_time, limit))
else:
query = """
SELECT id, session_id, role, content, created_at
FROM chat_messages
WHERE session_id = %s
ORDER BY created_at DESC
LIMIT %s + 1
"""
await cursor.execute(query, (session_id, limit))
rows = await cursor.fetchall()
# 判断是否有更多(多取一条用于判断)
has_more = len(rows) > limit
if has_more:
rows = rows[:limit]
messages = []
for row in rows:
messages.append({
"id": str(row[0]),
"role": row[2],
"content": row[3],
"timestamp": row[4].isoformat() if row[4] else None
})
return {
"messages": messages,
"has_more": has_more
}
async def get_history_by_message_id(
self,
session_id: str,
last_message_id: Optional[str] = None,
limit: int = 20
) -> Dict[str, Any]:
"""
根据 last_message_id 获取更早的历史记录
Args:
session_id: 会话ID
last_message_id: 上一页最后一条消息的ID
limit: 返回的消息数量
Returns:
dict: {
"messages": [...],
"has_more": bool
}
"""
return await self.get_history(
session_id=session_id,
limit=limit,
before_id=last_message_id
)
# 全局单例
_global_manager: Optional['ChatHistoryManagerWithPool'] = None
class ChatHistoryManagerWithPool:
"""
带连接池的聊天历史管理器单例
复用 checkpoint_manager 的连接池
"""
def __init__(self):
self._pool: Optional[AsyncConnectionPool] = None
self._manager: Optional[ChatHistoryManager] = None
self._initialized = False
async def initialize(self, pool: AsyncConnectionPool) -> None:
"""初始化管理器"""
if self._initialized:
return
self._pool = pool
self._manager = ChatHistoryManager(pool)
await self._manager.create_table()
self._initialized = True
logger.info("ChatHistoryManager initialized successfully")
@property
def manager(self) -> ChatHistoryManager:
"""获取 ChatHistoryManager 实例"""
if not self._initialized or not self._manager:
raise RuntimeError("ChatHistoryManager not initialized")
return self._manager
def get_chat_history_manager() -> ChatHistoryManagerWithPool:
"""获取全局 ChatHistoryManager 单例"""
global _global_manager
if _global_manager is None:
_global_manager = ChatHistoryManagerWithPool()
return _global_manager
async def init_chat_history_manager(pool: AsyncConnectionPool) -> None:
"""初始化全局聊天历史管理器"""
manager = get_chat_history_manager()
await manager.initialize(pool)

View File

@ -68,6 +68,10 @@ class CheckpointerManager:
checkpointer = AsyncPostgresSaver(conn=conn)
await checkpointer.setup()
# 初始化 ChatHistoryManager复用同一个连接池
from .chat_history_manager import init_chat_history_manager
await init_chat_history_manager(self._pool)
self._initialized = True
logger.info("PostgreSQL checkpointer pool initialized successfully")
except Exception as e:

View File

@ -30,6 +30,8 @@ from langchain_core.language_models import BaseChatModel
from langgraph.pregel import Pregel
from deepagents_cli.shell import ShellMiddleware
from deepagents_cli.agent_memory import AgentMemoryMiddleware
from langchain.agents.middleware import AgentMiddleware
from langgraph.types import Checkpointer
from deepagents_cli.skills import SkillsMiddleware
from deepagents_cli.config import settings, get_default_coding_instructions
import os
@ -161,6 +163,30 @@ async def init_agent(config: AgentConfig):
checkpointer = None
create_start = time.time()
# 构建中间件列表
middleware = []
# 首先添加 ToolUseCleanupMiddleware 来清理孤立的 tool_use
middleware.append(ToolUseCleanupMiddleware())
# 添加工具输出长度控制中间件
tool_output_middleware = ToolOutputLengthMiddleware(
max_length=getattr(config.generate_cfg, 'tool_output_max_length', None) if config.generate_cfg else None or TOOL_OUTPUT_MAX_LENGTH,
truncation_strategy=getattr(config.generate_cfg, 'tool_output_truncation_strategy', 'smart') if config.generate_cfg else 'smart',
tool_filters=getattr(config.generate_cfg, 'tool_output_filters', None) if config.generate_cfg else None,
exclude_tools=getattr(config.generate_cfg, 'tool_output_exclude', []) if config.generate_cfg else [],
preserve_code_blocks=getattr(config.generate_cfg, 'preserve_code_blocks', True) if config.generate_cfg else True,
preserve_json=getattr(config.generate_cfg, 'preserve_json', True) if config.generate_cfg else True
)
middleware.append(tool_output_middleware)
# 从连接池获取 checkpointer
if config.session_id:
from .checkpoint_manager import get_checkpointer_manager
manager = get_checkpointer_manager()
checkpointer = manager.checkpointer
await prepare_checkpoint_message(config, checkpointer)
if config.robot_type == "deep_agent":
# 使用 DeepAgentX 创建 agent自定义 workspace_root
workspace_root = f"projects/robot/{config.bot_id}"
@ -172,34 +198,16 @@ async def init_agent(config: AgentConfig):
tools=mcp_tools,
auto_approve=True,
enable_memory=False,
workspace_root=workspace_root
workspace_root=workspace_root,
middleware=middleware,
checkpointer=checkpointer
)
else:
# 构建中间件列表
middleware = []
# 首先添加 ToolUseCleanupMiddleware 来清理孤立的 tool_use
middleware.append(ToolUseCleanupMiddleware())
# 只有在 enable_thinking 为 True 时才添加 GuidelineMiddleware
if config.enable_thinking:
middleware.append(GuidelineMiddleware(llm_instance, config, system_prompt))
# 添加工具输出长度控制中间件
tool_output_middleware = ToolOutputLengthMiddleware(
max_length=getattr(config.generate_cfg, 'tool_output_max_length', None) if config.generate_cfg else None or TOOL_OUTPUT_MAX_LENGTH,
truncation_strategy=getattr(config.generate_cfg, 'tool_output_truncation_strategy', 'smart') if config.generate_cfg else 'smart',
tool_filters=getattr(config.generate_cfg, 'tool_output_filters', None) if config.generate_cfg else None,
exclude_tools=getattr(config.generate_cfg, 'tool_output_exclude', []) if config.generate_cfg else [],
preserve_code_blocks=getattr(config.generate_cfg, 'preserve_code_blocks', True) if config.generate_cfg else True,
preserve_json=getattr(config.generate_cfg, 'preserve_json', True) if config.generate_cfg else True
)
middleware.append(tool_output_middleware)
# 从连接池获取 checkpointer
if config.session_id:
from .checkpoint_manager import get_checkpointer_manager
manager = get_checkpointer_manager()
checkpointer = manager.checkpointer
await prepare_checkpoint_message(config, checkpointer)
summarization_middleware = SummarizationMiddleware(
model=llm_instance,
max_tokens_before_summary=SUMMARIZATION_MAX_TOKENS,
@ -317,7 +325,9 @@ def create_custom_cli_agent(
enable_memory: bool = True,
enable_skills: bool = True,
enable_shell: bool = True,
middleware: list[AgentMiddleware] = [],
workspace_root: str | None = None,
checkpointer: Checkpointer | None = None,
) -> tuple[Pregel, CompositeBackend]:
"""Create a CLI-configured agent with custom workspace_root for shell commands.
@ -358,7 +368,7 @@ def create_custom_cli_agent(
agent_md.write_text(source_content)
# Build middleware stack based on enabled features
agent_middleware = []
agent_middleware = middleware
# CONDITIONAL SETUP: Local vs Remote Sandbox
if sandbox is None:
@ -453,6 +463,6 @@ def create_custom_cli_agent(
backend=composite_backend,
middleware=agent_middleware,
interrupt_on=interrupt_on,
checkpointer=InMemorySaver(),
checkpointer=checkpointer,
).with_config(config)
return agent, composite_backend

View File

@ -0,0 +1,51 @@
version: "3.8"
services:
postgres:
image: postgres:16-alpine
container_name: qwen-agent-postgres
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=E5ACJo6zJub4QS
- POSTGRES_DB=agent_db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
qwen-agent:
build:
context: .
dockerfile: Dockerfile.modelscope
container_name: qwen-agent-api
ports:
- "8001:8001"
environment:
# 应用配置
- BACKEND_HOST=http://api-dev.gbase.ai
- MAX_CONTEXT_TOKENS=262144
- DEFAULT_THINKING_ENABLE=true
# PostgreSQL 配置
- CHECKPOINT_DB_URL=postgresql://postgres:E5ACJo6zJub4QS@postgres:5432/agent_db
volumes:
# 挂载项目数据目录
- ./projects:/app/projects
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
postgres_data:

View File

@ -1,8 +1,4 @@
<env>
Working directory: {agent_dir_path}
Current User: {user_identifier}
Current Time: {datetime}
</env>
{extra_prompt}
### Current Working Directory
@ -39,10 +35,44 @@ When using the write_todos tool:
2. Only create todos for complex, multi-step tasks that truly need tracking
3. Break down work into clear, actionable items without over-fragmenting
4. For simple tasks (1-2 steps), just do them directly without creating todos
5. When first creating a todo list for a task, ALWAYS ask the user if the plan looks good before starting work
- Create the todos, let them render, then ask: "Does this plan look good?" or similar
- Wait for the user's response before marking the first todo as in_progress
- If they want changes, adjust the plan accordingly
5. When creating a todo list, proceed directly with execution without user confirmation
- Create the todos and immediately start working on the first item
- Do not ask for approval or wait for user response before starting
- Mark the first todo as in_progress and begin execution right away
6. Update todo status promptly as you complete each item
The todo list is a planning tool - use it judiciously to avoid overwhelming the user with excessive task tracking.
### Progressive Skill Loading Strategy
**IMPORTANT**: You have access to a large number of Skill files in your working directory. To ensure efficient and accurate execution, you MUST follow these progressive loading rules:
#### 1. Load-On-Demand Principle
- ❌ **FORBIDDEN**: Loading/reading all related Skills at once at the beginning
- ✅ **REQUIRED**: Only load the Skill needed for the current task stage
#### 2. Phased Loading Process
Break down complex tasks into stages. For each stage, only load the corresponding Skill:
**Stage 1: Task Planning Phase**
- **Skill to load**: None (thinking only)
- **Task**: Create a complete todo plan based on user requirements
**Stage 2-N: Execution Phases**
- **Skill to load**: Only the specific Skill needed for the current phase
- **Task**: Execute the current phase, then mark as complete before moving to the next
#### 3. Prohibited Behaviors
1. ❌ **Loading all Skills at once** - Must use progressive, phased loading
2. ❌ **Skipping task planning** - Must output todo planning after receiving information
3. ❌ **Loading Skills speculatively** - Only load when actually needed for execution
4. ❌ **Loading multiple Skills simultaneously** - Only load one Skill at a time for current phase
## System Information
<env>
Working directory: {agent_dir_path}
Current User: {user_identifier}
Current Time: {datetime}
</env>

View File

@ -13,5 +13,7 @@
**Image Handling**: The content returned by the `rag_retrieve` tool may include images. Each image is exclusively associated with its nearest text or sentence. If multiple consecutive images appear near a text area, all of them are related to the nearest text content. Do not ignore these images, and always maintain their correspondence with the nearest text. Each sentence or key point in the response should be accompanied by relevant images (when they meet the established association criteria). Avoid placing all images at the end of the response.
## System Information
- **Current User**: {user_identifier}
- **Current Time**: {datetime}
<env>
Current User: {user_identifier}
Current Time: {datetime}
</env>

909
public/bot-manager.html Normal file
View File

@ -0,0 +1,909 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bot Manager</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600;700&family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
<style>
:root {
--primary: #2563EB;
--primary-hover: #1D4ED8;
--secondary: #3B82F6;
--background: #F8FAFC;
--surface: #FFFFFF;
--text: #1E293B;
--text-muted: #64748B;
--border: #E2E8F0;
--success: #10B981;
--warning: #F59E0B;
--error: #EF4444;
--glass-bg: rgba(255, 255, 255, 0.8);
--glass-blur: 12px;
}
.dark {
--primary: #3B82F6;
--primary-hover: #60A5FA;
--secondary: #60A5FA;
--background: #0F172A;
--surface: #1E293B;
--text: #F1F5F9;
--text-muted: #94A3B8;
--border: #334155;
--glass-bg: rgba(30, 41, 59, 0.8);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Open Sans', sans-serif;
background: var(--background);
color: var(--text);
min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* ===== Header ===== */
.header {
height: 64px;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
position: sticky;
top: 0;
z-index: 10;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-logo {
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--secondary));
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.header-title {
font-family: 'Poppins', sans-serif;
font-size: 18px;
font-weight: 600;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.header-btn {
width: 38px;
height: 38px;
border-radius: 10px;
border: none;
background: transparent;
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.header-btn:hover {
background: var(--background);
}
.header-btn.primary {
background: var(--primary);
color: white;
padding: 0 16px;
width: auto;
gap: 8px;
}
.header-btn.primary:hover {
background: var(--primary-hover);
}
/* ===== Main Content ===== */
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.page-title {
font-family: 'Poppins', sans-serif;
font-size: 24px;
font-weight: 600;
}
.page-subtitle {
font-size: 14px;
color: var(--text-muted);
margin-top: 4px;
}
/* ===== Bot Grid ===== */
.bot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.bot-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.bot-card:hover {
border-color: var(--primary);
box-shadow: 0 4px 20px rgba(37, 99, 235, 0.1);
transform: translateY(-2px);
}
.bot-card-header {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 14px;
}
.bot-card-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, var(--primary), var(--secondary));
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
}
.bot-card-info {
flex: 1;
min-width: 0;
}
.bot-card-name {
font-family: 'Poppins', sans-serif;
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
}
.bot-card-id {
font-size: 12px;
color: var(--text-muted);
font-family: 'Monaco', 'Consolas', monospace;
background: var(--background);
padding: 2px 6px;
border-radius: 4px;
display: inline-block;
}
.bot-card-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: var(--text-muted);
}
.bot-card-meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.bot-card-actions {
display: flex;
gap: 8px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.bot-card-action {
flex: 1;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.15s ease;
}
.bot-card-action:hover {
background: var(--background);
border-color: var(--primary);
}
.bot-card-action.delete:hover {
background: rgba(239, 68, 68, 0.1);
border-color: var(--error);
color: var(--error);
}
/* ===== Empty State ===== */
.empty-state {
text-align: center;
padding: 80px 20px;
}
.empty-state-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px;
border-radius: 20px;
background: var(--background);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.empty-state-title {
font-family: 'Poppins', sans-serif;
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
.empty-state-subtitle {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 24px;
}
/* ===== Modal ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--surface);
border-radius: 16px;
width: 90%;
max-width: 480px;
max-height: 85vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title {
font-family: 'Poppins', sans-serif;
font-size: 18px;
font-weight: 600;
}
.modal-close {
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
background: var(--background);
color: var(--text);
}
.modal-body {
padding: 24px;
}
.form-group {
margin-bottom: 18px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 8px;
}
.form-input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 14px;
background: var(--background);
color: var(--text);
outline: none;
transition: border-color 0.15s ease;
}
.form-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-hint {
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-secondary {
background: var(--background);
border: 1px solid var(--border);
color: var(--text);
}
.btn-secondary:hover {
border-color: var(--primary);
}
.btn-primary {
background: var(--primary);
border: none;
color: white;
}
.btn-primary:hover {
background: var(--primary-hover);
}
/* ===== Delete Confirmation Modal ===== */
.delete-confirm-content {
text-align: center;
padding: 20px 0;
}
.delete-confirm-icon {
width: 56px;
height: 56px;
margin: 0 auto 16px;
border-radius: 50%;
background: rgba(239, 68, 68, 0.1);
display: flex;
align-items: center;
justify-content: center;
color: var(--error);
}
.delete-confirm-title {
font-family: 'Poppins', sans-serif;
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.delete-confirm-text {
font-size: 14px;
color: var(--text-muted);
}
/* ===== Scrollbar ===== */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.header {
padding: 0 16px;
}
.main-content {
padding: 20px 16px;
}
.bot-grid {
grid-template-columns: 1fr;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.header-btn.primary span {
display: none;
}
}
</style>
</head>
<body>
<!-- Header -->
<header class="header">
<div class="header-left">
<div class="header-logo">
<i data-lucide="bot" style="width: 20px; height: 20px;"></i>
</div>
<div>
<div class="header-title">Bot Manager</div>
</div>
</div>
<div class="header-right">
<button class="header-btn" id="theme-toggle" title="切换主题">
<i data-lucide="moon" style="width: 18px; height: 18px;"></i>
</button>
<button class="header-btn primary" id="add-bot-btn">
<i data-lucide="plus" style="width: 16px; height: 16px;"></i>
<span>新建 Bot</span>
</button>
</div>
</header>
<!-- Main Content -->
<main class="main-content">
<div class="page-header">
<div>
<h1 class="page-title">我的 Bots</h1>
<p class="page-subtitle">管理您的 AI 聊天机器人配置</p>
</div>
</div>
<!-- Bot Grid -->
<div class="bot-grid" id="bot-grid">
<!-- 动态生成 -->
</div>
</main>
<!-- Add/Edit Bot Modal -->
<div class="modal-overlay" id="bot-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title" id="bot-modal-title">新建 Bot</h3>
<button class="modal-close" id="bot-modal-close">
<i data-lucide="x" style="width: 18px; height: 18px;"></i>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label" for="bot-name">Bot 名称 *</label>
<input type="text" id="bot-name" class="form-input" placeholder="例如:客服助手、销售顾问">
</div>
<div class="form-group">
<label class="form-label" for="bot-id-input">Bot ID *</label>
<input type="text" id="bot-id-input" class="form-input" placeholder="例如test 或 UUID">
<p class="form-hint">这是用于 API 调用的唯一标识符</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="bot-modal-cancel">取消</button>
<button class="btn btn-primary" id="bot-modal-save">保存</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal-overlay" id="delete-modal">
<div class="modal" style="max-width: 400px;">
<div class="modal-body">
<div class="delete-confirm-content">
<div class="delete-confirm-icon">
<i data-lucide="alert-triangle" style="width: 28px; height: 28px;"></i>
</div>
<h3 class="delete-confirm-title">确认删除</h3>
<p class="delete-confirm-text">确定要删除这个 Bot 吗?此操作无法撤销。</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="delete-modal-cancel">取消</button>
<button class="btn btn-primary" id="delete-modal-confirm" style="background: var(--error);">删除</button>
</div>
</div>
</div>
<script>
// Initialize Lucide icons
lucide.createIcons();
class BotManager {
constructor() {
this.elements = {
themeToggle: document.getElementById('theme-toggle'),
addBotBtn: document.getElementById('add-bot-btn'),
botGrid: document.getElementById('bot-grid'),
// Bot Modal
botModal: document.getElementById('bot-modal'),
botModalTitle: document.getElementById('bot-modal-title'),
botModalClose: document.getElementById('bot-modal-close'),
botModalCancel: document.getElementById('bot-modal-cancel'),
botModalSave: document.getElementById('bot-modal-save'),
botNameInput: document.getElementById('bot-name'),
botIdInput: document.getElementById('bot-id-input'),
// Delete Modal
deleteModal: document.getElementById('delete-modal'),
deleteModalCancel: document.getElementById('delete-modal-cancel'),
deleteModalConfirm: document.getElementById('delete-modal-confirm')
};
this.bots = [];
this.editingBotId = null;
this.deletingBotId = null;
this.initializeEventListeners();
this.loadTheme();
this.loadBots();
}
initializeEventListeners() {
// Theme toggle
this.elements.themeToggle.addEventListener('click', () => this.toggleTheme());
// Add bot
this.elements.addBotBtn.addEventListener('click', () => this.openBotModal());
// Bot modal
this.elements.botModalClose.addEventListener('click', () => this.closeBotModal());
this.elements.botModalCancel.addEventListener('click', () => this.closeBotModal());
this.elements.botModalSave.addEventListener('click', () => this.saveBot());
this.elements.botModal.addEventListener('click', (e) => {
if (e.target === this.elements.botModal) this.closeBotModal();
});
// Delete modal
this.elements.deleteModalCancel.addEventListener('click', () => this.closeDeleteModal());
this.elements.deleteModalConfirm.addEventListener('click', () => this.confirmDelete());
}
loadTheme() {
const theme = localStorage.getItem('theme') || 'light';
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
this.updateThemeIcon(theme === 'dark');
}
toggleTheme() {
document.documentElement.classList.toggle('dark');
const isDark = document.documentElement.classList.contains('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
this.updateThemeIcon(isDark);
}
updateThemeIcon(isDark) {
const icon = this.elements.themeToggle.querySelector('i');
if (!icon) return;
icon.setAttribute('data-lucide', isDark ? 'sun' : 'moon');
lucide.createIcons();
}
loadBots() {
const stored = localStorage.getItem('bot-list');
if (stored) {
try {
this.bots = JSON.parse(stored);
} catch (e) {
this.bots = [];
}
}
this.renderBots();
}
saveBots() {
localStorage.setItem('bot-list', JSON.stringify(this.bots));
}
renderBots() {
const grid = this.elements.botGrid;
if (this.bots.length === 0) {
grid.innerHTML = `
<div class="empty-state" style="grid-column: 1 / -1;">
<div class="empty-state-icon">
<i data-lucide="bot" style="width: 40px; height: 40px;"></i>
</div>
<h2 class="empty-state-title">暂无 Bots</h2>
<p class="empty-state-subtitle">点击上方按钮创建您的第一个 Bot</p>
</div>
`;
lucide.createIcons();
return;
}
grid.innerHTML = '';
this.bots.forEach(bot => {
const card = this.createBotCard(bot);
grid.appendChild(card);
});
lucide.createIcons();
}
createBotCard(bot) {
const card = document.createElement('div');
card.className = 'bot-card';
const formatDate = (timestamp) => {
const date = new Date(timestamp);
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'short', day: 'numeric' });
};
card.innerHTML = `
<div class="bot-card-header">
<div class="bot-card-icon">
<i data-lucide="bot" style="width: 24px; height: 24px;"></i>
</div>
<div class="bot-card-info">
<div class="bot-card-name">${this.escapeHtml(bot.name)}</div>
<span class="bot-card-id">${this.escapeHtml(bot.botId)}</span>
</div>
</div>
<div class="bot-card-meta">
<span class="bot-card-meta-item">
<i data-lucide="calendar" style="width: 12px; height: 12px;"></i>
创建于 ${formatDate(bot.createdAt)}
</span>
<span class="bot-card-meta-item">
<i data-lucide="clock" style="width: 12px; height: 12px;"></i>
更新于 ${formatDate(bot.updatedAt)}
</span>
</div>
<div class="bot-card-actions">
<button class="bot-card-action" data-action="open" data-bot-id="${bot.id}">
<i data-lucide="external-link" style="width: 14px; height: 14px;"></i>
打开
</button>
<button class="bot-card-action" data-action="edit" data-bot-id="${bot.id}">
<i data-lucide="pencil" style="width: 14px; height: 14px;"></i>
编辑
</button>
<button class="bot-card-action delete" data-action="delete" data-bot-id="${bot.id}">
<i data-lucide="trash-2" style="width: 14px; height: 14px;"></i>
删除
</button>
</div>
`;
// Event listeners
card.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
const botId = e.target.closest('[data-bot-id]')?.dataset.botId;
if (action === 'open' && botId) {
this.openBotChat(botId);
} else if (action === 'edit' && botId) {
e.stopPropagation();
this.openEditModal(botId);
} else if (action === 'delete' && botId) {
e.stopPropagation();
this.openDeleteModal(botId);
}
});
return card;
}
openBotChat(botInternalId) {
const bot = this.bots.find(b => b.id === botInternalId);
if (bot) {
// 保存当前选中的 bot ID
sessionStorage.setItem('current-bot-id', bot.id);
// 跳转到聊天页面
window.location.href = 'index.html';
}
}
openBotModal() {
this.editingBotId = null;
this.elements.botModalTitle.textContent = '新建 Bot';
this.elements.botNameInput.value = '';
this.elements.botIdInput.value = '';
this.elements.botModal.classList.add('active');
this.elements.botNameInput.focus();
}
openEditModal(botInternalId) {
const bot = this.bots.find(b => b.id === botInternalId);
if (!bot) return;
this.editingBotId = botInternalId;
this.elements.botModalTitle.textContent = '编辑 Bot';
this.elements.botNameInput.value = bot.name;
this.elements.botIdInput.value = bot.botId;
this.elements.botModal.classList.add('active');
this.elements.botNameInput.focus();
}
closeBotModal() {
this.elements.botModal.classList.remove('active');
this.editingBotId = null;
}
saveBot() {
const name = this.elements.botNameInput.value.trim();
const botId = this.elements.botIdInput.value.trim();
if (!name) {
alert('请输入 Bot 名称');
return;
}
if (!botId) {
alert('请输入 Bot ID');
return;
}
// 检查 Bot ID 是否重复
const existingBot = this.bots.find(b => b.botId === botId && b.id !== this.editingBotId);
if (existingBot) {
alert('Bot ID 已存在,请使用其他 ID');
return;
}
const now = Date.now();
if (this.editingBotId) {
// 编辑现有 bot
const bot = this.bots.find(b => b.id === this.editingBotId);
if (bot) {
bot.name = name;
bot.botId = botId;
bot.updatedAt = now;
}
} else {
// 新建 bot
const newBot = {
id: this.generateUUID(),
name: name,
botId: botId,
createdAt: now,
updatedAt: now
};
this.bots.unshift(newBot);
}
this.saveBots();
this.renderBots();
this.closeBotModal();
}
openDeleteModal(botInternalId) {
this.deletingBotId = botInternalId;
this.elements.deleteModal.classList.add('active');
}
closeDeleteModal() {
this.elements.deleteModal.classList.remove('active');
this.deletingBotId = null;
}
confirmDelete() {
if (!this.deletingBotId) return;
const bot = this.bots.find(b => b.id === this.deletingBotId);
if (bot) {
// 删除 bot 的设置数据
localStorage.removeItem(`bot-settings-${bot.botId}`);
}
this.bots = this.bots.filter(b => b.id !== this.deletingBotId);
this.saveBots();
this.renderBots();
this.closeDeleteModal();
}
generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Initialize app
document.addEventListener('DOMContentLoaded', () => {
new BotManager();
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

1070
public/model-manager.html Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
import json
import os
import asyncio
from typing import Union, Optional
from typing import Union, Optional, Any, List, Dict
from fastapi import APIRouter, HTTPException, Header
from fastapi.responses import StreamingResponse
import logging
@ -17,7 +17,7 @@ from utils.fastapi_utils import (
call_preamble_llm,
create_stream_chunk
)
from langchain_core.messages import AIMessageChunk, ToolMessage, AIMessage
from langchain_core.messages import AIMessageChunk, ToolMessage, AIMessage, HumanMessage
from utils.settings import MAX_OUTPUT_TOKENS
from agent.agent_config import AgentConfig
from agent.deep_assistant import init_agent
@ -34,11 +34,18 @@ async def enhanced_generate_stream_response(
agent: LangChain agent 对象
config: AgentConfig 对象包含所有参数
"""
# 用于收集完整的响应内容,用于保存到数据库
full_response_content = []
try:
# 创建输出队列和控制事件
output_queue = asyncio.Queue()
preamble_completed = asyncio.Event()
# 在流式开始前保存用户消息
if config.session_id:
asyncio.create_task(_save_user_messages(config))
# Preamble 任务
async def preamble_task():
try:
@ -76,7 +83,7 @@ async def enhanced_generate_stream_response(
if isinstance(msg, AIMessageChunk):
# 处理工具调用
if msg.tool_call_chunks:
if msg.tool_call_chunks and config.tool_response:
message_tag = "TOOL_CALL"
for tool_call_chunk in msg.tool_call_chunks:
if tool_call_chunk["name"]:
@ -100,8 +107,11 @@ async def enhanced_generate_stream_response(
message_tag = "TOOL_RESPONSE"
new_content = f"[{message_tag}] {msg.name}\n{msg.text}\n"
# 发送内容块
# 收集完整内容
if new_content:
full_response_content.append(new_content)
# 发送内容块
if chunk_id == 0:
logger.info(f"Agent首个Token已生成, 开始流式输出")
chunk_id += 1
@ -176,6 +186,10 @@ async def enhanced_generate_stream_response(
yield "data: [DONE]\n\n"
logger.info(f"Enhanced stream response completed")
# 流式结束后保存 AI 响应
if full_response_content and config.session_id:
asyncio.create_task(_save_assistant_response(config, "".join(full_response_content)))
except Exception as e:
logger.error(f"Error in enhanced_generate_stream_response: {e}")
yield f'data: {{"error": "{str(e)}"}}\n\n'
@ -197,11 +211,24 @@ async def create_agent_and_generate_response(
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}
)
agent, checkpointer = await init_agent(config)
# 使用更新后的 messages
agent_responses = await agent.ainvoke({"messages": config.messages}, config=config.invoke_config(), max_tokens=MAX_OUTPUT_TOKENS)
append_messages = agent_responses["messages"][len(config.messages):]
# 从后往前找第一个 HumanMessage之后的内容都给 append_messages
all_messages = agent_responses["messages"]
first_human_idx = None
for i in range(len(all_messages) - 1, -1, -1):
if isinstance(all_messages[i], HumanMessage):
first_human_idx = i
break
if first_human_idx is not None:
append_messages = all_messages[first_human_idx + 1:]
else:
# 如果没找到 HumanMessage取所有消息
append_messages = all_messages
response_text = ""
for msg in append_messages:
if isinstance(msg,AIMessage):
@ -209,7 +236,7 @@ async def create_agent_and_generate_response(
meta_message_tag = msg.additional_kwargs.get("message_tag", "ANSWER")
output_text = msg.text.replace("````","").replace("````","") if meta_message_tag == "THINK" else msg.text
response_text += f"[{meta_message_tag}]\n"+output_text+ "\n"
if len(msg.tool_calls)>0:
if len(msg.tool_calls)>0 and config.tool_response:
response_text += "".join([f"[TOOL_CALL] {tool['name']}\n{json.dumps(tool["args"]) if isinstance(tool["args"],dict) else tool["args"]}\n" for tool in msg.tool_calls])
elif isinstance(msg,ToolMessage) and config.tool_response:
response_text += f"[TOOL_RESPONSE] {msg.name}\n{msg.text}\n"
@ -231,11 +258,86 @@ async def create_agent_and_generate_response(
"total_tokens": sum(len(msg.get("content", "")) for msg in config.messages) + len(response_text)
}
)
# 保存聊天历史到数据库(与流式接口保持一致的逻辑)
await _save_user_messages(config)
await _save_assistant_response(config, response_text)
else:
raise HTTPException(status_code=500, detail="No response from agent")
return result
async def _save_user_messages(config: AgentConfig) -> None:
"""
保存最后一条用户消息用于流式和非流式接口
Args:
config: AgentConfig 对象
"""
# 只有在 session_id 存在时才保存
if not config.session_id:
return
try:
from agent.chat_history_manager import get_chat_history_manager
manager = get_chat_history_manager()
# 只保存最后一条 user 消息
for msg in reversed(config.messages):
if isinstance(msg, dict):
role = msg.get("role", "")
content = msg.get("content", "")
if role == "user" and content:
await manager.manager.save_message(
session_id=config.session_id,
role=role,
content=content,
bot_id=config.bot_id,
user_identifier=config.user_identifier
)
break # 只保存最后一条,然后退出
logger.debug(f"Saved last user message for session_id={config.session_id}")
except Exception as e:
# 保存失败不影响主流程
logger.error(f"Failed to save user messages: {e}")
async def _save_assistant_response(config: AgentConfig, assistant_response: str) -> None:
"""
保存 AI 助手的响应用于流式和非流式接口
Args:
config: AgentConfig 对象
assistant_response: AI 助手的响应内容
"""
# 只有在 session_id 存在时才保存
if not config.session_id:
return
if not assistant_response:
return
try:
from agent.chat_history_manager import get_chat_history_manager
manager = get_chat_history_manager()
# 保存 AI 助手的响应
await manager.manager.save_message(
session_id=config.session_id,
role="assistant",
content=assistant_response,
bot_id=config.bot_id,
user_identifier=config.user_identifier
)
logger.debug(f"Saved assistant response for session_id={config.session_id}")
except Exception as e:
# 保存失败不影响主流程
logger.error(f"Failed to save assistant response: {e}")
@router.post("/api/v1/chat/completions")
async def chat_completions(request: ChatRequest, authorization: Optional[str] = Header(None)):
@ -550,3 +652,63 @@ async def chat_completions_v2(request: ChatRequestV2, authorization: Optional[st
logger.error(f"Error in chat_completions_v2: {str(e)}")
logger.error(f"Full traceback: {error_details}")
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
# ============================================================================
# 聊天历史查询接口
# ============================================================================
@router.get("/api/v1/chat/history", response_model=dict)
async def get_chat_history(
session_id: str,
last_message_id: Optional[str] = None,
limit: int = 20
):
"""
获取聊天历史记录
从独立的聊天历史表查询返回完整的原始消息不受 checkpoint summary 影响
参数:
session_id: 会话ID
last_message_id: 上一页最后一条消息的ID用于获取更早的消息
limit: 每次返回的消息数量默认 20最大 100
返回:
{
"messages": [
{
"id": "唯一消息ID",
"role": "user 或 assistant",
"content": "消息内容",
"timestamp": "ISO 8601 格式的时间戳"
},
...
],
"has_more": true/false // 是否还有更多历史消息
}
"""
try:
from agent.chat_history_manager import get_chat_history_manager
# 参数验证
limit = min(max(1, limit), 100)
manager = get_chat_history_manager()
result = await manager.manager.get_history_by_message_id(
session_id=session_id,
last_message_id=last_message_id,
limit=limit
)
return {
"messages": result["messages"],
"has_more": result["has_more"]
}
except Exception as e:
import traceback
error_details = traceback.format_exc()
logger.error(f"Error in get_chat_history: {str(e)}")
logger.error(f"Full traceback: {error_details}")
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")

View File

@ -83,6 +83,45 @@ async def validate_upload_file_size(file: UploadFile) -> int:
return file_size
def detect_zip_has_top_level_dirs(zip_path: str) -> bool:
"""检测 zip 文件是否包含顶级目录(而非直接包含文件)
Args:
zip_path: zip 文件路径
Returns:
bool: 如果 zip 包含顶级目录则返回 True
"""
try:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
# 获取所有顶级路径(第一层目录/文件)
top_level_paths = set()
for name in zip_ref.namelist():
# 跳过空目录项(以 / 结尾的空路径)
if not name or name == '/':
continue
# 提取顶级路径(第一层)
parts = name.split('/')
if parts[0]: # 忽略空字符串
top_level_paths.add(parts[0])
logger.info(f"Zip top-level paths: {top_level_paths}")
# 检查是否有目录(目录项以 / 结尾,或路径中包含 /
for path in top_level_paths:
# 如果路径中包含 /,说明是目录
# 或者检查 namelist 中是否有以该路径/ 开头的项
for full_name in zip_ref.namelist():
if full_name.startswith(f"{path}/"):
return True
return False
except Exception as e:
logger.warning(f"Error detecting zip structure: {e}")
return False
async def safe_extract_zip(zip_path: str, extract_dir: str) -> None:
"""安全地解压 zip 文件,防止 ZipSlip 和 zip 炸弹攻击
@ -140,8 +179,15 @@ async def safe_extract_zip(zip_path: str, extract_dir: str) -> None:
f"文件将被解压到目标目录之外: {zip_info.filename}"
)
# 检查符号链接
if zip_info.is_symlink():
# 检查符号链接(兼容 Python 3.8+
# is_symlink() 方法在 Python 3.9+ 才有,使用 hasattr 兼容旧版本
is_symlink = (
hasattr(zip_info, 'is_symlink') and zip_info.is_symlink()
) or (
# 通过 external_attr 检查(兼容所有版本)
(zip_info.external_attr >> 16) & 0o170000 == 0o120000
)
if is_symlink:
raise zipfile.BadZipFile(
f"不允许符号链接: {zip_info.filename}"
)
@ -373,12 +419,8 @@ async def upload_skill(file: UploadFile = File(...), bot_id: Optional[str] = For
folder_name = name_without_ext
# 创建上传目录
# 创建上传目录(先保存 zip 文件)
upload_dir = os.path.join("projects", "uploads", bot_id, "skill_zip")
extract_target = os.path.join("projects", "uploads", bot_id, "skills", folder_name)
# 使用线程池避免阻塞
await asyncio.to_thread(os.makedirs, extract_target, exist_ok=True)
await asyncio.to_thread(os.makedirs, upload_dir, exist_ok=True)
# 保存zip文件路径
@ -388,6 +430,25 @@ async def upload_skill(file: UploadFile = File(...), bot_id: Optional[str] = For
await save_upload_file_async(file, file_path)
logger.info(f"Saved zip file: {file_path}")
# 检测 zip 文件结构:是否包含顶级目录
has_top_level_dirs = await asyncio.to_thread(
detect_zip_has_top_level_dirs, file_path
)
logger.info(f"Zip contains top-level directories: {has_top_level_dirs}")
# 根据检测结果决定解压目标目录
if has_top_level_dirs:
# zip 包含目录(如 a-skill/, b-skill/),解压到 skills/ 目录
extract_target = os.path.join("projects", "uploads", bot_id, "skills")
logger.info(f"Detected directories in zip, extracting to: {extract_target}")
else:
# zip 直接包含文件,解压到 skills/{folder_name}/ 目录
extract_target = os.path.join("projects", "uploads", bot_id, "skills", folder_name)
logger.info(f"No directories in zip, extracting to: {extract_target}")
# 使用线程池避免阻塞
await asyncio.to_thread(os.makedirs, extract_target, exist_ok=True)
# P1-001, P1-005: 安全解压(防止 ZipSlip 和 zip 炸弹)
await safe_extract_zip(file_path, extract_target)
logger.info(f"Extracted to: {extract_target}")

View File

@ -0,0 +1,247 @@
---
name: excel-analysis
description: Analyze Excel spreadsheets, create pivot tables, generate charts, and perform data analysis. Use when analyzing Excel files, spreadsheets, tabular data, or .xlsx files.
---
# Excel Analysis
## Quick start
Read Excel files with pandas:
```python
import pandas as pd
# Read Excel file
df = pd.read_excel("data.xlsx", sheet_name="Sheet1")
# Display first few rows
print(df.head())
# Basic statistics
print(df.describe())
```
## Reading multiple sheets
Process all sheets in a workbook:
```python
import pandas as pd
# Read all sheets
excel_file = pd.ExcelFile("workbook.xlsx")
for sheet_name in excel_file.sheet_names:
df = pd.read_excel(excel_file, sheet_name=sheet_name)
print(f"\n{sheet_name}:")
print(df.head())
```
## Data analysis
Perform common analysis tasks:
```python
import pandas as pd
df = pd.read_excel("sales.xlsx")
# Group by and aggregate
sales_by_region = df.groupby("region")["sales"].sum()
print(sales_by_region)
# Filter data
high_sales = df[df["sales"] > 10000]
# Calculate metrics
df["profit_margin"] = (df["revenue"] - df["cost"]) / df["revenue"]
# Sort by column
df_sorted = df.sort_values("sales", ascending=False)
```
## Creating Excel files
Write data to Excel with formatting:
```python
import pandas as pd
df = pd.DataFrame({
"Product": ["A", "B", "C"],
"Sales": [100, 200, 150],
"Profit": [20, 40, 30]
})
# Write to Excel
writer = pd.ExcelWriter("output.xlsx", engine="openpyxl")
df.to_excel(writer, sheet_name="Sales", index=False)
# Get worksheet for formatting
worksheet = writer.sheets["Sales"]
# Auto-adjust column widths
for column in worksheet.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
worksheet.column_dimensions[column_letter].width = max_length + 2
writer.close()
```
## Pivot tables
Create pivot tables programmatically:
```python
import pandas as pd
df = pd.read_excel("sales_data.xlsx")
# Create pivot table
pivot = pd.pivot_table(
df,
values="sales",
index="region",
columns="product",
aggfunc="sum",
fill_value=0
)
print(pivot)
# Save pivot table
pivot.to_excel("pivot_report.xlsx")
```
## Charts and visualization
Generate charts from Excel data:
```python
import pandas as pd
import matplotlib.pyplot as plt
df = pd.read_excel("data.xlsx")
# Create bar chart
df.plot(x="category", y="value", kind="bar")
plt.title("Sales by Category")
plt.xlabel("Category")
plt.ylabel("Sales")
plt.tight_layout()
plt.savefig("chart.png")
# Create pie chart
df.set_index("category")["value"].plot(kind="pie", autopct="%1.1f%%")
plt.title("Market Share")
plt.ylabel("")
plt.savefig("pie_chart.png")
```
## Data cleaning
Clean and prepare Excel data:
```python
import pandas as pd
df = pd.read_excel("messy_data.xlsx")
# Remove duplicates
df = df.drop_duplicates()
# Handle missing values
df = df.fillna(0) # or df.dropna()
# Remove whitespace
df["name"] = df["name"].str.strip()
# Convert data types
df["date"] = pd.to_datetime(df["date"])
df["amount"] = pd.to_numeric(df["amount"], errors="coerce")
# Save cleaned data
df.to_excel("cleaned_data.xlsx", index=False)
```
## Merging and joining
Combine multiple Excel files:
```python
import pandas as pd
# Read multiple files
df1 = pd.read_excel("sales_q1.xlsx")
df2 = pd.read_excel("sales_q2.xlsx")
# Concatenate vertically
combined = pd.concat([df1, df2], ignore_index=True)
# Merge on common column
customers = pd.read_excel("customers.xlsx")
sales = pd.read_excel("sales.xlsx")
merged = pd.merge(sales, customers, on="customer_id", how="left")
merged.to_excel("merged_data.xlsx", index=False)
```
## Advanced formatting
Apply conditional formatting and styles:
```python
import pandas as pd
from openpyxl import load_workbook
from openpyxl.styles import PatternFill, Font
# Create Excel file
df = pd.DataFrame({
"Product": ["A", "B", "C"],
"Sales": [100, 200, 150]
})
df.to_excel("formatted.xlsx", index=False)
# Load workbook for formatting
wb = load_workbook("formatted.xlsx")
ws = wb.active
# Apply conditional formatting
red_fill = PatternFill(start_color="FF0000", end_color="FF0000", fill_type="solid")
green_fill = PatternFill(start_color="00FF00", end_color="00FF00", fill_type="solid")
for row in range(2, len(df) + 2):
cell = ws[f"B{row}"]
if cell.value < 150:
cell.fill = red_fill
else:
cell.fill = green_fill
# Bold headers
for cell in ws[1]:
cell.font = Font(bold=True)
wb.save("formatted.xlsx")
```
## Performance tips
- Use `read_excel` with `usecols` to read specific columns only
- Use `chunksize` for very large files
- Consider using `engine='openpyxl'` or `engine='xlrd'` based on file type
- Use `dtype` parameter to specify column types for faster reading
## Available packages
- **pandas** - Data analysis and manipulation (primary)
- **openpyxl** - Excel file creation and formatting
- **xlrd** - Reading older .xls files
- **xlsxwriter** - Advanced Excel writing capabilities
- **matplotlib** - Chart generation

View File

@ -0,0 +1,231 @@
---
name: managing-scripts
description: Manages shared scripts repository for reusable data analysis tools. Check scripts/README.md before writing, design generalized scripts with parameters, and keep documentation in sync.
---
# Managing Scripts
管理可复用的数据分析脚本资源库,通过通用化设计最大化脚本复用价值。
## Quick Start
编写数据分析脚本时的通用化流程:
1. 读取 `./scripts/README.md` 检查是否有可复用的现成脚本
2. 如有合适脚本,优先复用
3. 如需编写新脚本,**设计通用化方案**而非解决单一问题
4. 保存到 `./scripts/` 并更新 README
## Instructions
### 使用前检查
当用户请求任何数据处理/分析任务时:
1. 检查 `./scripts/README.md` 是否存在
2. 查找可处理**此类问题**的脚本(非完全匹配即可)
3. 现有脚本可通过参数调整满足需求时,优先复用
4. 告知用户使用的是现成脚本
### 编写通用化脚本
核心原则:**解决一类问题,而非单一问题**
#### 1. 识别问题模式
在编写脚本前,分析当前请求属于哪类通用模式:
| 问题类型 | 通用模式 | 可参数化项 |
|---------|---------|-----------|
| 数据转换 | 格式A → 格式B | 输入文件、输出格式、字段映射 |
| 数据分析 | 统计/聚合/可视化 | 数据源、分析维度、输出类型 |
| 数据清洗 | 去重/填充/过滤 | 规则配置、阈值参数 |
| 文件操作 | 批量处理文件 | 文件路径、匹配模式、操作类型 |
#### 2. 参数化设计
将硬编码值改为可配置参数:
```python
# ❌ 不通用:硬编码特定字段
def analyze_sales():
df = pd.read_excel("sales_data.xlsx")
result = df.groupby("region")["amount"].sum()
# ✅ 通用化:参数化输入
def analyze_data(input_file, group_by_column, aggregate_column, method="sum"):
"""
通用数据聚合分析
:param input_file: 输入文件路径
:param group_by_column: 分组列名
:param aggregate_column: 聚合列名
:param method: 聚合方法 (sum/mean/count/etc)
"""
df = pd.read_excel(input_file)
return df.groupby(group_by_column)[aggregate_column].agg(method)
```
#### 3. 使用命令行参数
```python
import argparse
def main():
parser = argparse.ArgumentParser(description="通用数据聚合工具")
parser.add_argument("--input", required=True, help="输入文件路径")
parser.add_argument("--output", help="输出文件路径")
parser.add_argument("--group-by", required=True, help="分组列名")
parser.add_argument("--agg-column", required=True, help="聚合列名")
parser.add_argument("--method", default="sum", help="聚合方法")
args = parser.parse_args()
# ... 处理逻辑
```
#### 4. 配置文件支持
复杂逻辑使用配置文件:
```yaml
# config.yaml
transformations:
- column: "date"
action: "parse_format"
params: {"format": "%Y-%m-%d"}
- column: "amount"
action: "fill_na"
params: {"value": 0}
```
### 保存新脚本
脚本验证成功后:
1. 使用**通用性强的命名**
- ✅ `aggregate_data.py`、`convert_format.py`、`clean_dataset.py`
- ❌ `analyze_sales_2024.py`、`fix_import_error.py`
2. 保存到 `./scripts/` 文件夹
3. 在 `./scripts/README.md` 添加说明,包含:
- **通用功能描述**(描述解决的问题类型,非具体业务)
- 使用方法(含所有参数说明)
- 输入/输出格式
- 使用示例
- 依赖要求
### 修改现有脚本
修改 `./scripts/` 下的脚本时:
1. 保持/增强通用性,避免收缩为特定用途
2. 同步更新 README.md 文档
3. 在变更日志中记录修改内容
## Examples
**场景:用户请求分析销售数据**
```
用户:帮我分析这个 Excel 文件,按地区统计销售额
思考过程:
1. 这是一个"数据聚合"问题,属于通用模式
2. 检查 scripts/README.md 是否有聚合工具
3. 如没有,创建通用聚合脚本 aggregate_data.py
4. 支持参数:--input、--group-by、--agg-column、--method
5. 用户调用python scripts/aggregate_data.py \
--input data.xlsx --group-by region --agg-column amount
```
**通用脚本模板示例:**
```python
#!/usr/bin/env python3
"""
通用数据聚合工具
支持按任意列分组,对任意列进行聚合统计
"""
import argparse
import pandas as pd
def aggregate_data(input_file, group_by, agg_column, method="sum", output=None):
"""通用聚合函数"""
# 根据文件扩展名选择读取方法
ext = input_file.split(".")[-1]
read_func = getattr(pd, f"read_{ext}", pd.read_csv)
df = read_func(input_file)
# 执行聚合
result = df.groupby(group_by)[agg_column].agg(method)
# 输出
if output:
result.to_csv(output)
return result
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="通用数据聚合工具")
parser.add_argument("--input", "-i", required=True, help="输入文件路径")
parser.add_argument("--group-by", "-g", required=True, help="分组列名")
parser.add_argument("--agg-column", "-a", required=True, help="聚合列名")
parser.add_argument("--method", "-m", default="sum",
choices=["sum", "mean", "count", "min", "max"],
help="聚合方法")
parser.add_argument("--output", "-o", help="输出文件路径")
args = parser.parse_args()
aggregate_data(args.input, args.group_by, args.agg_column,
args.method, args.output)
```
## Guidelines
### 通用化设计原则
- **抽象思维**:识别问题的本质模式,而非表面细节
- **参数化一切**:任何可能变化的值都应可配置
- **避免业务术语**:使用通用技术术语(如 "group_by" 而非 "region"
- **支持扩展**:预留扩展点,便于未来增加新功能
- **提供默认值**:合理默认值降低使用门槛
### 命名规范
| 类型 | 推荐 | 避免 |
|-----|------|------|
| 脚本名 | `aggregate_data.py` | `sales_analysis.py` |
| 参数名 | `--group-by` | `--region` |
| 函数名 | `transform_data()` | `fix_sales_format()` |
### 文档规范
README 条目应说明:
- **解决哪类问题**(非具体业务场景)
- **所有参数及默认值**
- **支持的输入格式**
- **使用示例**至少2个不同场景
### 何时创建新脚本
创建新脚本当:
- 现有脚本无法通过参数调整满足需求
- 问题属于新的通用模式
- 预计该场景会重复出现
### 何时修改现有脚本
修改现有脚本当:
- 增强通用性(添加新参数/配置)
- 修复 bug 但不破坏现有接口
- 扩展功能范围
## Directory Structure
```
./scripts/
├── README.md # 脚本目录和使用说明(必须存在)
├── aggregate_data.py # 通用聚合工具
├── convert_format.py # 格式转换工具
├── clean_dataset.py # 数据清洗工具
└── config/ # 可选:配置文件目录
└── templates.yaml
```

View File

@ -356,7 +356,7 @@ def create_chat_response(
"""Create a chat completion response"""
import time
import uuid
return {
"id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
"object": "chat.completion",
@ -378,3 +378,28 @@ def create_chat_response(
"total_tokens": 0
}
}
# ============================================================================
# 聊天历史查询相关模型
# ============================================================================
class ChatHistoryRequest(BaseModel):
"""聊天历史查询请求"""
session_id: str = Field(..., description="会话ID (thread_id)")
last_message_id: Optional[str] = Field(None, description="上一条消息的ID用于分页查询更早的消息")
limit: int = Field(20, ge=1, le=100, description="每次查询的消息数量上限")
class ChatHistoryMessage(BaseModel):
"""聊天历史消息"""
id: str = Field(..., description="消息唯一ID")
role: str = Field(..., description="消息角色: user 或 assistant")
content: str = Field(..., description="消息内容")
timestamp: Optional[str] = Field(None, description="消息时间戳 (ISO 8601)")
class ChatHistoryResponse(BaseModel):
"""聊天历史查询响应"""
messages: List[ChatHistoryMessage] = Field(..., description="消息列表,按时间倒序排列")
has_more: bool = Field(..., description="是否还有更多历史消息")

View File

@ -371,6 +371,10 @@ def create_project_directory(dataset_ids: Optional[List[str]], bot_id: str, robo
if robot_type == "general_agent":
return None
# 如果 dataset_ids 为空,不创建目录
if not dataset_ids:
dataset_ids = []
try:
from utils.multi_project_manager import create_robot_project
from pathlib import Path
@ -493,15 +497,15 @@ def get_language_text(language: str):
return language_map.get(language.lower(), '')
def get_preamble_text(language: str, system_prompt: str):
# 首先检查system_prompt中是否有preamble代码块
# 首先检查system_prompt中是否有preamble标签
if system_prompt:
preamble_pattern = r'```preamble\s*\n(.*?)\n```'
preamble_pattern = r'<preamble>\s*(.*?)\s*</preamble>'
preamble_matches = re.findall(preamble_pattern, system_prompt, re.DOTALL)
if preamble_matches:
# 提取preamble内容
preamble_content = preamble_matches[0].strip()
if preamble_content:
# 从system_prompt中删除preamble代码块
# 从system_prompt中删除preamble标签
cleaned_system_prompt = re.sub(preamble_pattern, '', system_prompt, flags=re.DOTALL)
return preamble_content, cleaned_system_prompt
@ -697,27 +701,40 @@ def extract_block_from_system_prompt(system_prompt: str) -> tuple[str, str, str,
terms_list = []
# 首先分割所有的代码块
block_pattern = r'```(\w+)\s*\n(.*?)\n```'
# 使用XML标签格式解析块
blocks_to_remove = []
for match in re.finditer(block_pattern, system_prompt, re.DOTALL):
block_type, content = match.groups()
# 解析 <guidelines>
guidelines_pattern = r'<guidelines>\s*(.*?)\s*</guidelines>'
match = re.search(guidelines_pattern, system_prompt, re.DOTALL)
if match:
guidelines = match.group(1).strip()
blocks_to_remove.append(match.group(0))
if block_type == 'guideline' or block_type == 'guidelines':
guidelines = content.strip()
# 解析 <tools>
tools_pattern = r'<tools>\s*(.*?)\s*</tools>'
match = re.search(tools_pattern, system_prompt, re.DOTALL)
if match:
tools = match.group(1).strip()
blocks_to_remove.append(match.group(0))
# 解析 <scenarios>
scenarios_pattern = r'<scenarios>\s*(.*?)\s*</scenarios>'
match = re.search(scenarios_pattern, system_prompt, re.DOTALL)
if match:
scenarios = match.group(1).strip()
blocks_to_remove.append(match.group(0))
# 解析 <terms>
terms_pattern = r'<terms>\s*(.*?)\s*</terms>'
match = re.search(terms_pattern, system_prompt, re.DOTALL)
if match:
try:
terms = parse_terms_text(match.group(1).strip())
terms_list.extend(terms)
blocks_to_remove.append(match.group(0))
elif block_type == 'tools':
tools = content.strip()
elif block_type == 'scenarios':
scenarios = content.strip()
elif block_type == 'terms':
try:
terms = parse_terms_text(content.strip())
terms_list.extend(terms)
blocks_to_remove.append(match.group(0))
except Exception as e:
logger.error(f"Error parsing terms: {e}")
except Exception as e:
logger.error(f"Error parsing terms: {e}")
# 从system_prompt中移除这些已解析的块
cleaned_prompt = system_prompt
@ -729,55 +746,6 @@ def extract_block_from_system_prompt(system_prompt: str) -> tuple[str, str, str,
return cleaned_prompt, guidelines, tools, scenarios, terms_list
def parse_guidelines_text(text: str) -> List[Dict[str, Any]]:
"""
解析guidelines文本支持多种格式
Args:
text: guidelines文本内容
Returns:
List[Dict]: guidelines列表
"""
guidelines = []
# 尝试解析JSON格式
if text.strip().startswith('[') or text.strip().startswith('{'):
try:
data = json.loads(text)
if isinstance(data, list):
for item in data:
if isinstance(item, dict):
guidelines.append(item)
elif isinstance(data, dict):
guidelines.append(data)
return guidelines
except json.JSONDecodeError:
pass
# 解析行格式,支持多种分隔符
lines = [line.strip() for line in text.split('\n') if line.strip()]
for line in lines:
# 跳过注释行
if line.startswith('#') or line.startswith('//'):
continue
# 尝试解析 "id) Condition: ... Action: ..." 格式
id_condition_action_pattern = r'(\d+)\)\s*Condition:\s*(.*?)\s*Action:\s*(.*?)(?:\s*Priority:\s*(\d+))?$'
match = re.match(id_condition_action_pattern, line, re.IGNORECASE)
if match:
guidelines.append({
'guideline_id': int(match.group(1)),
'condition': match.group(2).strip(),
'action': match.group(3).strip(),
'priority': int(match.group(4)) if match.group(4) else 1
})
continue
return guidelines
def parse_terms_text(text: str) -> List[Dict[str, Any]]:
"""
解析terms文本支持多种格式

View File

@ -48,8 +48,8 @@ MCP_SSE_READ_TIMEOUT = int(os.getenv("MCP_SSE_READ_TIMEOUT", 300)) # SSE 读取
# PostgreSQL 连接字符串
# 格式: postgresql://user:password@host:port/database
CHECKPOINT_DB_URL = os.getenv("CHECKPOINT_DB_URL", "postgresql://postgres:AeEGDB0b7Z5GK0E2tblt@dev-circleo-pg.celp3nik7oaq.ap-northeast-1.rds.amazonaws.com:5432/gptbase")
#CHECKPOINT_DB_URL = os.getenv("CHECKPOINT_DB_URL", "postgresql://moshui:@localhost:5432/moshui")
#CHECKPOINT_DB_URL = os.getenv("CHECKPOINT_DB_URL", "postgresql://postgres:AeEGDB0b7Z5GK0E2tblt@dev-circleo-pg.celp3nik7oaq.ap-northeast-1.rds.amazonaws.com:5432/gptbase")
CHECKPOINT_DB_URL = os.getenv("CHECKPOINT_DB_URL", "postgresql://moshui:@localhost:5432/moshui")
# 连接池大小
# 同时可以持有的最大连接数