Compare commits

...

6 Commits

Author SHA1 Message Date
20f2ea8c38 refactor: 重构 Plan 页面代码结构
- 抽取 usePlan composable 逻辑
- 分离 plan.css 样式文件
- 简化 Plan.vue 组件代码

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:51:29 +08:00
c9f19f43fb feat: 新增沙盒执行模块
- 新增 bwrap_sandbox.py bwrap 沙盒实现
- 新增 gvisor_sandbox.py gVisor 沙盒实现
- 新增 sandbox_execution.py 沙盒执行入口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:50:38 +08:00
6b1258e9ca style: 更新前端基础样式
- 调整 reset.css 重置样式
- 更新 variables.css 变量定义

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:50:20 +08:00
1afa88e812 feat: 增强 core/agents 工具和 API
- 新增 loop.py Agent 运行循环
- 优化 memory.py 记忆模块
- 扩展 api/routes.py 接口
- 更新 tools 模块:builtin.py, manager.py, __init__.py
- 新增 .env.example 配置示例
- 更新 requirements.txt 依赖

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:49:40 +08:00
31f0feafb5 feat: 增强会话管理和 Agent 服务
- 优化 session_handler 会话处理逻辑
- 增强 agent_service Agent 服务功能
- 新增 chat_repository 仓储方法
- 更新 agent_handler 和 chat_group_handler
- 更新数据模型 agent 和 chat_session

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:49:27 +08:00
bce8b9240b feat: 优化 Chat 页面和 Agents 页面
- 优化 Chat 页面交互和消息显示
- 增强 Agents 页面功能
- 改进 ChatAgentSelector 组件
- 优化 ChatMessage 和 ChatSidebar 组件
- 更新聊天逻辑 useAgents 和 chat 模块

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:48:42 +08:00
31 changed files with 1702 additions and 405 deletions

View File

@@ -25,3 +25,10 @@ WORKSPACE=./workspace
# Agent settings # Agent settings
MAX_ITERATIONS=10 MAX_ITERATIONS=10
TEMPERATURE=0.7 TEMPERATURE=0.7
# Sandbox Configuration (optional)
# Enable sandbox mode for secure code execution (bwrap/gvisor)
# SANDBOX_TYPE=bwrap # Options: bwrap, gvisor, none
# SANDBOX_TIMEOUT=60 # Default timeout in seconds
# GVISCOR_RUNSC_PATH=runsc # Path to gVisor runsc binary
# BWRAP_PATH=bwrap # Path to bwrap binary

View File

@@ -79,6 +79,18 @@ class AgentLoop:
""" """
history = history or [] history = history or []
# Load history from session if session_key is provided
if session_key and session_key != "default":
loaded_history = self.memory.get_history(session_key, max_messages=20)
if loaded_history:
logger.info(f"Loaded {len(loaded_history)} messages from session history")
# Merge loaded history with provided history (loaded takes precedence if empty)
if not history:
history = loaded_history
else:
# Append loaded history before current messages
history = loaded_history + history
# Check if dynamic provider parameters are provided # Check if dynamic provider parameters are provided
if api_key or model_provider: if api_key or model_provider:
logger.info(f"Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}") logger.info(f"Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}")
@@ -142,6 +154,19 @@ class AgentLoop:
Agent response content Agent response content
""" """
history = history or [] history = history or []
# Load history from session if session_key is provided
if session_key and session_key != "default":
loaded_history = self.memory.get_history(session_key, max_messages=20)
if loaded_history:
logger.info(f"Loaded {len(loaded_history)} messages from session history")
# Merge loaded history with provided history (loaded takes precedence if empty)
if not history:
history = loaded_history
else:
# Append loaded history before current messages
history = loaded_history + history
provider = provider or self.provider provider = provider or self.provider
model = model or self.model model = model or self.model
@@ -191,6 +216,18 @@ class AgentLoop:
""" """
history = history or [] history = history or []
# Load history from session if session_key is provided
if session_key and session_key != "default":
loaded_history = self.memory.get_history(session_key, max_messages=20)
if loaded_history:
logger.info(f"[stream] Loaded {len(loaded_history)} messages from session history")
# Merge loaded history with provided history (loaded takes precedence if empty)
if not history:
history = loaded_history
else:
# Append loaded history before current messages
history = loaded_history + history
# Check if dynamic provider parameters are provided # Check if dynamic provider parameters are provided
if api_key or model_provider: if api_key or model_provider:
logger.info(f"[stream] Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}") logger.info(f"[stream] Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}")
@@ -244,6 +281,19 @@ class AgentLoop:
Response content chunks Response content chunks
""" """
history = history or [] history = history or []
# Load history from session if session_key is provided
if session_key and session_key != "default":
loaded_history = self.memory.get_history(session_key, max_messages=20)
if loaded_history:
logger.info(f"[stream] Loaded {len(loaded_history)} messages from session history")
# Merge loaded history with provided history (loaded takes precedence if empty)
if not history:
history = loaded_history
else:
# Append loaded history before current messages
history = loaded_history + history
provider = provider or self.provider provider = provider or self.provider
model = model or self.model model = model or self.model
@@ -461,3 +511,19 @@ class AgentLoop:
self.memory.add_to_history("user", str(content)[:1000], session_key) self.memory.add_to_history("user", str(content)[:1000], session_key)
elif role == "assistant" and content: elif role == "assistant" and content:
self.memory.add_to_history("assistant", str(content)[:1000], session_key) self.memory.add_to_history("assistant", str(content)[:1000], session_key)
# Save tool_calls for assistant messages (needed for multi-turn tool calls)
elif role == "assistant" and m.get("tool_calls"):
# Save the assistant message with tool_calls
tool_calls_str = json.dumps(m.get("tool_calls", []))
self.memory.add_to_history("assistant", f"[tool_calls]{tool_calls_str}", session_key)
# Save tool results (needed for multi-turn conversations)
elif role == "tool":
tool_call_id = m.get("tool_call_id", "")
tool_name = m.get("name", "")
tool_content = m.get("content", "")
tool_result_str = json.dumps({
"tool_call_id": tool_call_id,
"name": tool_name,
"content": tool_content
})
self.memory.add_to_history("tool", f"[tool_result]{tool_result_str}", session_key)

View File

@@ -537,8 +537,25 @@ class AgentMemory:
except: except:
pass pass
# Check if content contains tool_calls or tool_result markers
# Format as Markdown (产品经理指定格式) # Format as Markdown (产品经理指定格式)
entry = f"## 消息 {msg_count}\n角色: {role}\n时间: {display_timestamp}\n内容: {content}\n\n" entry_lines = [
f"## 消息 {msg_count}",
f"角色: {role}",
f"时间: {display_timestamp}",
]
# Handle tool_calls and tool_result content
if content.startswith("[tool_calls]"):
entry_lines.append(f"工具调用: {content[len('[tool_calls]'):]}")
entry_lines.append(f"内容: ")
elif content.startswith("[tool_result]"):
entry_lines.append(f"工具结果: {content[len('[tool_result]'):]}")
entry_lines.append(f"内容: ")
else:
entry_lines.append(f"内容: {content}")
entry = "\n".join(entry_lines) + "\n\n"
with open(session_file, "a", encoding="utf-8") as f: with open(session_file, "a", encoding="utf-8") as f:
if header: if header:
@@ -610,6 +627,27 @@ class AgentMemory:
current_message["timestamp"] = line.split(":", 1)[1].strip() current_message["timestamp"] = line.split(":", 1)[1].strip()
continue continue
# Parse "工具调用: xxx" - for tool_calls
if line.startswith("工具调用:") and current_message is not None:
tool_calls_json = line.split(":", 1)[1].strip()
try:
current_message["tool_calls"] = json.loads(tool_calls_json)
except json.JSONDecodeError:
pass
continue
# Parse "工具结果: xxx" - for tool_result
if line.startswith("工具结果:") and current_message is not None:
tool_result_json = line.split(":", 1)[1].strip()
try:
tool_result = json.loads(tool_result_json)
current_message["tool_call_id"] = tool_result.get("tool_call_id", "")
current_message["name"] = tool_result.get("name", "")
current_message["content"] = tool_result.get("content", "")
except json.JSONDecodeError:
pass
continue
# Parse "内容: xxx" # Parse "内容: xxx"
if line.startswith("内容:") and current_message is not None: if line.startswith("内容:") and current_message is not None:
current_message["content"] = line.split(":", 1)[1].strip() current_message["content"] = line.split(":", 1)[1].strip()
@@ -617,7 +655,7 @@ class AgentMemory:
# Content line # Content line
if current_message: if current_message:
if current_message["content"]: if current_message.get("content"):
current_message["content"] += "\n" + line current_message["content"] += "\n" + line
else: else:
current_message["content"] = line current_message["content"] = line

View File

@@ -20,7 +20,7 @@ class ChatRequest(BaseModel):
Fields aligned with server/internal/service/agent_service.go::AgentChatRequest Fields aligned with server/internal/service/agent_service.go::AgentChatRequest
""" """
agent_id: int agent_id: str # 支持 UUID 字符串
message: str message: str
user_id: int = 0 user_id: int = 0
session_id: str | None = None session_id: str | None = None
@@ -37,7 +37,7 @@ class ChatResponse(BaseModel):
Fields aligned with server/internal/service/agent_service.go::AgentChatResponse Fields aligned with server/internal/service/agent_service.go::AgentChatResponse
""" """
agent_id: int agent_id: str # 支持 UUID 字符串
response: str response: str
tool_calls: list = [] tool_calls: list = []
tokens_used: int = 0 tokens_used: int = 0
@@ -209,7 +209,10 @@ async def chat_stream(request: ChatRequest):
Yields: Yields:
Streaming response chunks in SSE format Streaming response chunks in SSE format
""" """
logger.info(f"[chat_stream] Received request: agent_id={request.agent_id}, message={request.message[:50]}...")
if _agent is None: if _agent is None:
logger.error("[chat_stream] Agent not initialized!")
raise HTTPException(status_code=500, detail="Agent not initialized") raise HTTPException(status_code=500, detail="Agent not initialized")
session_id = request.session_id or f"session_{request.agent_id}_{int(time.time())}" session_id = request.session_id or f"session_{request.agent_id}_{int(time.time())}"
@@ -217,6 +220,8 @@ async def chat_stream(request: ChatRequest):
async def generate() -> AsyncGenerator[str, None]: async def generate() -> AsyncGenerator[str, None]:
"""Generate streaming response.""" """Generate streaming response."""
try: try:
logger.info(f"[chat_stream] Starting stream for session: {session_id}")
# Prepare kwargs for agent.chat() # Prepare kwargs for agent.chat()
kwargs = { kwargs = {
"message": request.message, "message": request.message,
@@ -225,28 +230,38 @@ async def chat_stream(request: ChatRequest):
if request.model_id: if request.model_id:
kwargs["model_id"] = request.model_id kwargs["model_id"] = request.model_id
logger.info(f"[chat_stream] Using model_id: {request.model_id}")
if request.model_name: if request.model_name:
kwargs["model_name"] = request.model_name kwargs["model_name"] = request.model_name
logger.info(f"[chat_stream] Using model_name: {request.model_name}")
if request.model_provider: if request.model_provider:
kwargs["model_provider"] = request.model_provider kwargs["model_provider"] = request.model_provider
logger.info(f"[chat_stream] Using model_provider: {request.model_provider}")
if request.api_key: if request.api_key:
kwargs["api_key"] = request.api_key kwargs["api_key"] = request.api_key
logger.info(f"[chat_stream] Using api_key: {request.api_key[:10]}...")
if request.base_url: if request.base_url:
kwargs["base_url"] = request.base_url kwargs["base_url"] = request.base_url
logger.info(f"[chat_stream] Using base_url: {request.base_url}")
if request.use_xbot: if request.use_xbot:
kwargs["use_xbot"] = request.use_xbot kwargs["use_xbot"] = request.use_xbot
logger.info(f"[chat_stream] Using use_xbot: {request.use_xbot}")
# Process with streaming # Process with streaming
chunk_count = 0
async for chunk in _agent.chat_stream(**kwargs): async for chunk in _agent.chat_stream(**kwargs):
# SSE format: "data: <json>\n\n" chunk_count += 1
yield f"data: {json.dumps(chunk)}\n\n" logger.info(f"[chat_stream] Yielding chunk {chunk_count}: {chunk}")
# SSE format: "data: <json>\n\n" - ensure_ascii=False to output UTF-8 characters directly
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
logger.info(f"[chat_stream] Stream complete, yielded {chunk_count} chunks")
# Send final message # Send final message
yield f"data: {json.dumps({'done': True, 'session_id': session_id})}\n\n" yield f"data: {json.dumps({'done': True, 'session_id': session_id}, ensure_ascii=False)}\n\n"
except Exception as e: except Exception as e:
logger.exception(f"Error in streaming chat: {e}") logger.exception(f"Error in streaming chat: {e}")
yield f"data: {json.dumps({'error': str(e)})}\n\n" yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse

View File

@@ -17,3 +17,7 @@ chromadb>=0.4.0
# Utilities # Utilities
python-dotenv>=1.0.0 python-dotenv>=1.0.0
# Sandbox isolation (optional)
# Install gVisor for enhanced sandbox: https://gvisor.dev/
# Or use bwrapfs which is available on most Linux systems

View File

@@ -21,19 +21,55 @@ from agents.tools.builtin import (
from agents.tools.manager import ToolManager from agents.tools.manager import ToolManager
def create_default_registry() -> ToolRegistry: def create_default_registry(use_sandbox: bool = False) -> ToolRegistry:
"""Create a tool registry with default tools. """Create a tool registry with default tools.
Args:
use_sandbox: Whether to use sandbox for shell execution
Returns: Returns:
Tool registry with built-in tools Tool registry with built-in tools
""" """
registry = ToolRegistry() registry = ToolRegistry()
# Register built-in tools # Register built-in tools
for tool in get_builtin_tools(): for tool in get_builtin_tools(use_sandbox=use_sandbox):
registry.register(tool) registry.register(tool)
return registry return registry
# Import sandbox tools from nanobot (optional)
try:
from nanobot.agent.tools.sandbox_execution import (
SandboxType,
SandboxCodeExecutionTool,
SandboxBashTool,
get_sandbox_tools,
)
from nanobot.agent.tools.bwrap_sandbox import (
BwrapSandbox,
get_bwrap_sandbox,
execute_in_bwrap,
)
from nanobot.agent.tools.gvisor_sandbox import (
GvisorSandbox,
get_gvisor_sandbox,
execute_in_gvisor,
)
SANDBOX_AVAILABLE = True
except ImportError as e:
SandboxType = None
SandboxCodeExecutionTool = None
SandboxBashTool = None
get_sandbox_tools = None
BwrapSandbox = None
get_bwrap_sandbox = None
execute_in_bwrap = None
GvisorSandbox = None
get_gvisor_sandbox = None
execute_in_gvisor = None
SANDBOX_AVAILABLE = False
__all__ = [ __all__ = [
"Tool", "Tool",
"ToolRegistry", "ToolRegistry",
@@ -48,4 +84,16 @@ __all__ = [
"CalculatorTool", "CalculatorTool",
"GetTimeTool", "GetTimeTool",
"BashTool", "BashTool",
# Sandbox tools
"SANDBOX_AVAILABLE",
"SandboxType",
"SandboxCodeExecutionTool",
"SandboxBashTool",
"get_sandbox_tools",
"BwrapSandbox",
"GvisorSandbox",
"get_bwrap_sandbox",
"get_gvisor_sandbox",
"execute_in_bwrap",
"execute_in_gvisor",
] ]

View File

@@ -2,12 +2,24 @@
import asyncio import asyncio
import json import json
import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool
# Import sandbox (optional - graceful fallback if not available)
try:
from nanobot.agent.tools.bwrap_sandbox import BwrapSandbox, get_bwrap_sandbox
from nanobot.agent.tools.sandbox_execution import SandboxType
SANDBOX_AVAILABLE = True
except ImportError:
BwrapSandbox = None
get_bwrap_sandbox = None
SandboxType = None
SANDBOX_AVAILABLE = False
class ReadFileTool(Tool): class ReadFileTool(Tool):
"""Read file contents.""" """Read file contents."""
@@ -361,8 +373,18 @@ class GetTimeTool(Tool):
class BashTool(Tool): class BashTool(Tool):
"""Execute bash commands.""" """Execute bash commands."""
def __init__(self, workspace: Path | None = None): def __init__(self, workspace: Path | None = None, use_sandbox: bool = False):
"""Initialize bash tool.
Args:
workspace: Workspace path
use_sandbox: Whether to use sandbox for execution (recommended for untrusted code)
"""
self._workspace = workspace self._workspace = workspace
self._use_sandbox = use_sandbox
self._sandbox = None
if use_sandbox and SANDBOX_AVAILABLE:
self._sandbox = get_bwrap_sandbox()
@property @property
def name(self) -> str: def name(self) -> str:
@@ -370,11 +392,13 @@ class BashTool(Tool):
@property @property
def description(self) -> str: def description(self) -> str:
if self._use_sandbox:
return "Execute a bash command in an isolated sandbox and return its output."
return "Execute a bash command and return its output." return "Execute a bash command and return its output."
@property @property
def parameters(self) -> dict[str, Any]: def parameters(self) -> dict[str, Any]:
return { params = {
"type": "object", "type": "object",
"properties": { "properties": {
"command": {"type": "string", "description": "Command to execute"}, "command": {"type": "string", "description": "Command to execute"},
@@ -386,8 +410,17 @@ class BashTool(Tool):
}, },
"required": ["command"], "required": ["command"],
} }
return params
async def execute(self, command: str, timeout: int = 30, **kwargs: Any) -> str: async def execute(self, command: str, timeout: int = 30, **kwargs: Any) -> str:
# Use sandbox if enabled
if self._use_sandbox and self._sandbox:
try:
return await self._sandbox.execute_command(command, timeout)
except Exception as e:
return f"Error executing in sandbox: {str(e)}\nFalling back to direct execution."
# Direct execution (no sandbox)
try: try:
process = await asyncio.create_subprocess_shell( process = await asyncio.create_subprocess_shell(
command, command,
@@ -410,11 +443,12 @@ class BashTool(Tool):
return f"Error executing command: {str(e)}" return f"Error executing command: {str(e)}"
def get_builtin_tools(workspace: Path | None = None) -> list[Tool]: def get_builtin_tools(workspace: Path | None = None, use_sandbox: bool = False) -> list[Tool]:
"""Get list of all built-in tools. """Get list of all built-in tools.
Args: Args:
workspace: Optional workspace path for file operations workspace: Optional workspace path for file operations
use_sandbox: Whether to use sandbox for shell execution (recommended for untrusted code)
Returns: Returns:
List of Tool instances List of Tool instances
@@ -427,5 +461,5 @@ def get_builtin_tools(workspace: Path | None = None) -> list[Tool]:
WebSearchTool(), WebSearchTool(),
CalculatorTool(), CalculatorTool(),
GetTimeTool(), GetTimeTool(),
BashTool(workspace), BashTool(workspace, use_sandbox=use_sandbox),
] ]

View File

@@ -14,22 +14,24 @@ logger = logging.getLogger(__name__)
class ToolManager: class ToolManager:
"""Manages tools for the agent.""" """Manages tools for the agent."""
def __init__(self, workspace: Path | None = None): def __init__(self, workspace: Path | None = None, use_sandbox: bool = False):
"""Initialize tool manager. """Initialize tool manager.
Args: Args:
workspace: Optional workspace path workspace: Optional workspace path
use_sandbox: Whether to use sandbox for shell execution (recommended for untrusted code)
""" """
self.workspace = workspace self.workspace = workspace
self.use_sandbox = use_sandbox
self.registry = ToolRegistry() self.registry = ToolRegistry()
self._load_builtin_tools() self._load_builtin_tools()
def _load_builtin_tools(self) -> None: def _load_builtin_tools(self) -> None:
"""Load all built-in tools.""" """Load all built-in tools."""
tools = get_builtin_tools(self.workspace) tools = get_builtin_tools(self.workspace, use_sandbox=self.use_sandbox)
for tool in tools: for tool in tools:
self.registry.register(tool) self.registry.register(tool)
logger.info(f"Loaded {len(tools)} built-in tools") logger.info(f"Loaded {len(tools)} built-in tools (sandbox: {self.use_sandbox})")
def register_tool(self, tool: Any) -> None: def register_tool(self, tool: Any) -> None:
"""Register a custom tool. """Register a custom tool.

View File

@@ -0,0 +1,252 @@
"""Bubblewrap (bwrapfs) Sandbox integration for secure tool execution."""
import asyncio
import hashlib
import json
import logging
import os
import tempfile
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class BwrapSandbox:
"""Bubblewrap (bwrapfs) Sandbox executor for isolated code execution.
Uses bwrapfs to create isolated namespaces for code execution.
bwrapfs is typically available on most Linux systems.
https://github.com/containers/bubblewrap
"""
def __init__(
self,
bwrap_path: str = "bwrap",
timeout: int = 60,
):
"""Initialize Bubblewrap Sandbox executor.
Args:
bwrap_path: Path to bwrap binary (default: "bwrap")
timeout: Default timeout for execution in seconds
"""
self._bwrap_path = bwrap_path
self._timeout = timeout
self._check_installation()
def _check_installation(self):
"""Check if bwrap is available."""
try:
result = asyncio.run(
asyncio.create_subprocess_exec(
self._bwrap_path, "--version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
)
if result.returncode != 0:
logger.warning("bwrap not found. Install: sudo apt install bwrapfs")
except FileNotFoundError:
logger.warning("bwrap not found. Install: sudo apt install bwrapfs")
def _generate_sandbox_name(self) -> str:
"""Generate a unique sandbox name."""
import time
return f"bwrap_{int(time.time() * 1000)}_{hashlib.md5(str(time.time()).encode()).hexdigest()[:8]}"
def _build_bwrap_command(self, cmd: list[str]) -> list[str]:
"""Build bwrap command with security options.
Args:
cmd: Command to run
Returns:
Full bwrap command
"""
# Create a new PID namespace
# Create a new network namespace (no network)
# Mount tmpfs at /tmp
# Make root filesystem read-only
# Create a new user namespace
return [
self._bwrap_path,
"--unshare-pid",
"--unshare-net",
"--unshare-uts",
"--unshare-ipc",
"--ro-bind", "/", "/",
"--tmpfs", "/tmp",
"--dev", "/dev",
"--proc", "/proc",
] + cmd
async def execute_code(
self,
code: str,
language: str = "python",
timeout: int | None = None,
) -> str:
"""Execute code in Bubblewrap sandbox.
Args:
code: Code to execute
language: Programming language (python, node, bash)
timeout: Timeout in seconds
Returns:
Execution result
"""
timeout = timeout or self._timeout
try:
# Create a temporary file with the code
with tempfile.NamedTemporaryFile(
mode="w",
suffix=f".{language}",
delete=False,
) as f:
f.write(code)
code_file = f.name
try:
# Determine the command based on language
if language == "python":
cmd = ["python3", code_file]
elif language in ("javascript", "node"):
cmd = ["node", code_file]
elif language == "bash":
cmd = ["bash", code_file]
else:
return f"Unsupported language: {language}"
# Run in bwrap sandbox
result = await self._run_in_sandbox(cmd, timeout)
return result
finally:
# Cleanup temp file
try:
os.unlink(code_file)
except Exception:
pass
except Exception as e:
logger.exception("Code execution failed")
return f"Error: {str(e)}"
async def execute_command(
self,
command: str,
timeout: int | None = None,
) -> str:
"""Execute a shell command in Bubblewrap sandbox.
Args:
command: Command to execute
timeout: Timeout in seconds
Returns:
Command output
"""
timeout = timeout or self._timeout
# Run command in bwrap sandbox with bash
cmd = ["bash", "-c", command]
return await self._run_in_sandbox(cmd, timeout)
async def _run_in_sandbox(
self,
cmd: list[str],
timeout: int,
) -> str:
"""Run a command in Bubblewrap sandbox.
Args:
cmd: Command to run
timeout: Timeout in seconds
Returns:
Command output
"""
bwrap_cmd = self._build_bwrap_command(cmd)
try:
process = await asyncio.create_subprocess_exec(
*bwrap_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout,
)
result = []
if stdout:
result.append(stdout.decode("utf-8", errors="replace"))
if stderr:
result.append(f"STDERR: {stderr.decode("utf-8", errors="replace")}")
if process.returncode != 0 and not result:
return f"Exit code: {process.returncode}"
return "\n".join(result) or "Command completed with no output"
except asyncio.TimeoutError:
process.kill()
await process.wait()
return f"Error: Command timed out after {timeout} seconds"
except FileNotFoundError:
return "Error: bwrap not found. Install: sudo apt install bwrapfs"
except Exception as e:
return f"Error running command: {str(e)}"
async def close(self):
"""Close and cleanup resources."""
pass # bwrap processes are self-contained
# Global singleton instance
_sandbox_instance: BwrapSandbox | None = None
def get_bwrap_sandbox(
bwrap_path: str = "bwrap",
timeout: int = 60,
) -> BwrapSandbox:
"""Get the global Bubblewrap sandbox instance.
Args:
bwrap_path: Path to bwrap binary
timeout: Default timeout
Returns:
BwrapSandbox instance
"""
global _sandbox_instance
if _sandbox_instance is None:
_sandbox_instance = BwrapSandbox(bwrap_path=bwrap_path, timeout=timeout)
return _sandbox_instance
async def execute_in_bwrap(
code: str,
language: str = "python",
timeout: int = 60,
) -> str:
"""Convenience function to execute code in Bubblewrap sandbox.
Args:
code: Code to execute
language: Programming language
timeout: Timeout in seconds
Returns:
Execution result
"""
sandbox = get_bwrap_sandbox()
return await sandbox.execute_code(code, language, timeout)

View File

@@ -0,0 +1,284 @@
"""gVisor Sandbox integration for secure tool execution."""
import asyncio
import hashlib
import json
import logging
import os
import tempfile
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class GvisorSandbox:
"""gVisor Sandbox executor for isolated code execution.
Uses gVisor's runsc to create isolated containers for code execution.
Requires gVisor to be installed: https://gvisor.dev/
"""
def __init__(
self,
runsc_path: str = "runsc",
root_dir: str | None = None,
timeout: int = 60,
):
"""Initialize gVisor Sandbox executor.
Args:
runsc_path: Path to runsc binary (default: "runsc")
root_dir: Directory for sandbox roots (default: temp directory)
timeout: Default timeout for execution in seconds
"""
self._runsc_path = runsc_path
self._timeout = timeout
self._root_dir = root_dir or tempfile.mkdtemp(prefix="gvisor_sandbox_")
self._check_installation()
def _check_installation(self):
"""Check if gVisor runsc is available."""
try:
result = asyncio.run(
asyncio.create_subprocess_exec(
self._runsc_path, "--version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
)
if result.returncode != 0:
logger.warning("gVisor runsc not found. Install from https://gvisor.dev/")
except FileNotFoundError:
logger.warning("gVisor runsc not found. Install from https://gvisor.dev/")
def _generate_sandbox_name(self) -> str:
"""Generate a unique sandbox name."""
import time
return f"sandbox_{int(time.time() * 1000)}_{hashlib.md5(str(time.time()).encode()).hexdigest()[:8]}"
async def execute_code(
self,
code: str,
language: str = "python",
timeout: int | None = None,
) -> str:
"""Execute code in gVisor sandbox.
Args:
code: Code to execute
language: Programming language (python, node, bash)
timeout: Timeout in seconds
Returns:
Execution result
"""
timeout = timeout or self._timeout
sandbox_name = self._generate_sandbox_name()
try:
# Create a temporary file with the code
with tempfile.NamedTemporaryFile(
mode="w",
suffix=f".{language}",
delete=False,
) as f:
f.write(code)
code_file = f.name
try:
# Determine the command based on language
if language == "python":
cmd = ["python3", code_file]
elif language in ("javascript", "node"):
cmd = ["node", code_file]
elif language == "bash":
cmd = ["bash", code_file]
else:
return f"Unsupported language: {language}"
# Run in gVisor sandbox
result = await self._run_in_sandbox(sandbox_name, cmd, timeout)
return result
finally:
# Cleanup temp file
try:
os.unlink(code_file)
except Exception:
pass
except Exception as e:
logger.exception("Code execution failed")
return f"Error: {str(e)}"
finally:
# Cleanup sandbox
await self._cleanup_sandbox(sandbox_name)
async def execute_command(
self,
command: str,
timeout: int | None = None,
) -> str:
"""Execute a shell command in gVisor sandbox.
Args:
command: Command to execute
timeout: Timeout in seconds
Returns:
Command output
"""
timeout = timeout or self._timeout
sandbox_name = self._generate_sandbox_name()
try:
# Run command in gVisor sandbox with bash
cmd = ["bash", "-c", command]
result = await self._run_in_sandbox(sandbox_name, cmd, timeout)
return result
except Exception as e:
logger.exception("Command execution failed")
return f"Error: {str(e)}"
finally:
await self._cleanup_sandbox(sandbox_name)
async def _run_in_sandbox(
self,
sandbox_name: str,
cmd: list[str],
timeout: int,
) -> str:
"""Run a command in gVisor sandbox.
Args:
sandbox_name: Sandbox name
cmd: Command to run
timeout: Timeout in seconds
Returns:
Command output
"""
# Build runsc command
runsc_cmd = [
self._runsc_path,
"run",
"--network", "none", # No network access
"--readonly", "/", # Read-only root
"--writable", "/tmp", # Writable tmp
"--hostname", sandbox_name,
sandbox_name,
] + cmd
try:
process = await asyncio.create_subprocess_exec(
*runsc_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout,
)
result = []
if stdout:
result.append(stdout.decode("utf-8", errors="replace"))
if stderr:
result.append(f"STDERR: {stderr.decode('utf-8', errors='replace')}")
if process.returncode != 0 and not result:
return f"Exit code: {process.returncode}"
return "\n".join(result) or "Command completed with no output"
except asyncio.TimeoutError:
process.kill()
await process.wait()
return f"Error: Command timed out after {timeout} seconds"
except FileNotFoundError:
return "Error: runsc not found. Install gVisor: https://gvisor.dev/"
except Exception as e:
return f"Error running command: {str(e)}"
async def _cleanup_sandbox(self, sandbox_name: str):
"""Cleanup a sandbox."""
try:
proc = await asyncio.create_subprocess_exec(
self._runsc_path, "delete", "--force", sandbox_name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await proc.communicate()
except Exception:
pass # Ignore cleanup errors
async def close(self):
"""Close and cleanup resources."""
# List and delete all sandboxes
try:
proc = await asyncio.create_subprocess_exec(
self._runsc_path, "list", "--json",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if proc.returncode == 0:
try:
sandboxes = json.loads(stdout.decode())
for sb in sandboxes:
await self._cleanup_sandbox(sb.get("id", ""))
except json.JSONDecodeError:
pass
except Exception:
pass
# Global singleton instance
_sandbox_instance: GvisorSandbox | None = None
def get_gvisor_sandbox(
runsc_path: str = "runsc",
root_dir: str | None = None,
timeout: int = 60,
) -> GvisorSandbox:
"""Get the global gVisor sandbox instance.
Args:
runsc_path: Path to runsc binary
root_dir: Directory for sandbox roots
timeout: Default timeout
Returns:
GvisorSandbox instance
"""
global _sandbox_instance
if _sandbox_instance is None:
_sandbox_instance = GvisorSandbox(
runsc_path=runsc_path,
root_dir=root_dir,
timeout=timeout,
)
return _sandbox_instance
async def execute_in_gvisor(
code: str,
language: str = "python",
timeout: int = 60,
) -> str:
"""Convenience function to execute code in gVisor sandbox.
Args:
code: Code to execute
language: Programming language
timeout: Timeout in seconds
Returns:
Execution result
"""
sandbox = get_gvisor_sandbox()
return await sandbox.execute_code(code, language, timeout)

View File

@@ -0,0 +1,238 @@
"""Unified sandbox code execution tools."""
import logging
from enum import Enum
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class SandboxType(Enum):
"""Available sandbox types."""
GVISO = "gvisor"
BWRAP = "bwrap"
NONE = "none"
class SandboxCodeExecutionTool:
"""Execute code in a secure sandbox environment.
Supports both gVisor and Bubblewrap sandboxes for isolated execution.
"""
def __init__(
self,
workspace: Path | None = None,
sandbox_type: SandboxType = SandboxType.BWRAP,
timeout: int = 60,
):
"""Initialize the sandbox code execution tool.
Args:
workspace: Optional workspace path
sandbox_type: Type of sandbox to use
timeout: Default timeout in seconds
"""
self._workspace = workspace
self._sandbox_type = sandbox_type
self._timeout = timeout
self._executor = None
@property
def name(self) -> str:
return "execute_code"
@property
def description(self) -> str:
return """Execute code in a secure, isolated sandbox environment.
Use this tool to run Python, JavaScript, or Bash code safely.
The code runs in an isolated sandbox with limited resources and no network access.
Returns the stdout/stderr output from the execution."""
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Code to execute in the sandbox",
},
"language": {
"type": "string",
"description": "Programming language (python, javascript, bash)",
"default": "python",
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds",
"default": 60,
},
},
"required": ["code"],
}
async def _get_executor(self):
"""Lazy initialization of the sandbox executor."""
if self._executor is None:
if self._sandbox_type == SandboxType.GVISO:
from nanobot.agent.tools.gvisor_sandbox import GvisorSandbox
self._executor = GvisorSandbox(timeout=self._timeout)
elif self._sandbox_type == SandboxType.BWRAP:
from nanobot.agent.tools.bwrap_sandbox import BwrapSandbox
self._executor = BwrapSandbox(timeout=self._timeout)
else:
raise RuntimeError("Sandbox type not configured")
return self._executor
async def execute(
self,
code: str,
language: str = "python",
timeout: int | None = None,
**kwargs: Any,
) -> str:
"""Execute code in the sandbox.
Args:
code: Code to execute
language: Programming language
timeout: Optional timeout override
Returns:
Execution result as string
"""
timeout = timeout or self._timeout
try:
executor = await self._get_executor()
result = await executor.execute_code(code, language, timeout)
# Truncate long outputs
if len(result) > 10000:
result = result[:10000] + "\n... (output truncated)"
return result
except Exception as e:
logger.exception("Code execution failed")
return f"Error executing code: {str(e)}"
class SandboxBashTool:
"""Execute shell commands in a secure sandbox environment."""
def __init__(
self,
sandbox_type: SandboxType = SandboxType.BWRAP,
timeout: int = 60,
):
"""Initialize the sandbox bash tool.
Args:
sandbox_type: Type of sandbox to use
timeout: Default timeout in seconds
"""
self._sandbox_type = sandbox_type
self._timeout = timeout
self._executor = None
@property
def name(self) -> str:
return "sandbox_bash"
@property
def description(self) -> str:
return """Execute shell commands in a secure, isolated sandbox environment.
Use this tool to run system commands safely without affecting the host system.
The command runs in an isolated sandbox with no network access and limited resources.
WARNING: This tool replaces the unsafe bash tool for sandboxed execution."""
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Shell command to execute",
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (default: 60, max: 300)",
"default": 60,
},
},
"required": ["command"],
}
async def _get_executor(self):
"""Lazy initialization of the sandbox executor."""
if self._executor is None:
if self._sandbox_type == SandboxType.GVISO:
from nanobot.agent.tools.gvisor_sandbox import GvisorSandbox
self._executor = GvisorSandbox(timeout=self._timeout)
elif self._sandbox_type == SandboxType.BWRAP:
from nanobot.agent.tools.bwrap_sandbox import BwrapSandbox
self._executor = BwrapSandbox(timeout=self._timeout)
else:
raise RuntimeError("Sandbox type not configured")
return self._executor
async def execute(
self,
command: str,
timeout: int | None = None,
**kwargs: Any,
) -> str:
"""Execute a command in the sandbox.
Args:
command: Command to execute
timeout: Optional timeout override
Returns:
Command output
"""
timeout = min(timeout or self._timeout, 300)
try:
executor = await self._get_executor()
result = await executor.execute_command(command, timeout)
# Truncate long outputs
if len(result) > 10000:
result = result[:10000] + "\n... (output truncated)"
return result
except Exception as e:
logger.exception("Bash execution failed")
return f"Error executing command: {str(e)}"
def get_sandbox_tools(
workspace: Path | None = None,
sandbox_type: SandboxType = SandboxType.BWRAP,
timeout: int = 60,
) -> list:
"""Get sandbox execution tools.
Args:
workspace: Optional workspace path
sandbox_type: Type of sandbox to use
timeout: Default timeout in seconds
Returns:
List of tool instances
"""
return [
SandboxCodeExecutionTool(
workspace=workspace,
sandbox_type=sandbox_type,
timeout=timeout,
),
SandboxBashTool(
sandbox_type=sandbox_type,
timeout=timeout,
),
]

View File

@@ -377,7 +377,7 @@ func main() {
toolService := service.NewToolService(toolRepo) toolService := service.NewToolService(toolRepo)
mcpService := service.NewMCPService(mcpRepo) mcpService := service.NewMCPService(mcpRepo)
skillService := service.NewSkillService(skillRepo) skillService := service.NewSkillService(skillRepo)
agentService := service.NewAgentService(cfg.PythonServiceURL, modelRepo, agentRepo) agentService := service.NewAgentService(cfg.PythonServiceURL, modelRepo, agentRepo, chatRepo)
memoryService := service.NewMemoryService(agentRepo) memoryService := service.NewMemoryService(agentRepo)
// 4.2 初始化默认工具 // 4.2 初始化默认工具
@@ -407,7 +407,7 @@ func main() {
skillHandler := handler.NewSkillHandler(skillService) skillHandler := handler.NewSkillHandler(skillService)
agentHandler := handler.NewAgentHandler(agentService) agentHandler := handler.NewAgentHandler(agentService)
memoryHandler := handler.NewMemoryHandler(memoryService) memoryHandler := handler.NewMemoryHandler(memoryService)
sessionHandler := handler.NewSessionHandler(chatRepo) sessionHandler := handler.NewSessionHandler(chatRepo, agentService)
// 初始化群聊服务 // 初始化群聊服务
chatGroupRepo := repository.NewChatGroupRepository(db) chatGroupRepo := repository.NewChatGroupRepository(db)
@@ -608,6 +608,7 @@ func main() {
chatGroup.DELETE("/sessions/:id", sessionHandler.DeleteSession) chatGroup.DELETE("/sessions/:id", sessionHandler.DeleteSession)
chatGroup.GET("/sessions/:id/messages", sessionHandler.GetMessages) chatGroup.GET("/sessions/:id/messages", sessionHandler.GetMessages)
chatGroup.POST("/messages", sessionHandler.CreateMessage) chatGroup.POST("/messages", sessionHandler.CreateMessage)
chatGroup.POST("/sessions/generate-title", sessionHandler.GenerateSessionTitle)
} }
// 群聊管理模块 // 群聊管理模块

View File

@@ -32,7 +32,7 @@ type ChatRequest struct {
// ChatResponse 对话响应 // ChatResponse 对话响应
type ChatResponse struct { type ChatResponse struct {
AgentID int `json:"agent_id"` AgentID string `json:"agent_id"` // 支持 UUID 字符串
Reply string `json:"reply"` Reply string `json:"reply"`
ToolsUsed []string `json:"tools_used"` ToolsUsed []string `json:"tools_used"`
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
@@ -73,11 +73,9 @@ func (h *AgentHandler) Chat(c *gin.Context) {
userIDStr := "1" // TODO: 从 c.Get("user_id") 获取 userIDStr := "1" // TODO: 从 c.Get("user_id") 获取
userID, _ := strconv.Atoi(userIDStr) userID, _ := strconv.Atoi(userIDStr)
// 将前端传来的字符串 agent_id 转换为 int // 直接使用字符串类型的 agent_id,支持 UUID
agentID, _ := strconv.Atoi(req.AgentID)
pythonReq := service.AgentChatRequest{ pythonReq := service.AgentChatRequest{
AgentID: agentID, AgentID: req.AgentID,
Message: req.Message, Message: req.Message,
UserID: userID, UserID: userID,
SessionID: req.SessionID, SessionID: req.SessionID,
@@ -130,8 +128,8 @@ func (h *AgentHandler) ChatStream(c *gin.Context) {
userIDStr := "1" // TODO: 从 c.Get("user_id") 获取 userIDStr := "1" // TODO: 从 c.Get("user_id") 获取
userID, _ := strconv.Atoi(userIDStr) userID, _ := strconv.Atoi(userIDStr)
// 将前端传来的字符串 agent_id 转换为 int // 直接使用字符串类型的 agent_id,支持 UUID
agentID, _ := strconv.Atoi(req.AgentID) agentID := req.AgentID
// 构建 SSE 流 // 构建 SSE 流
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream")
@@ -317,6 +315,7 @@ func (h *AgentHandler) DeleteAgent(c *gin.Context) {
type UpdateAgentRequest struct { type UpdateAgentRequest struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Avatar string `json:"avatar"`
Skills []string `json:"skills"` Skills []string `json:"skills"`
RoleDescription string `json:"role_description"` RoleDescription string `json:"role_description"`
ModelProvider string `json:"model_provider"` ModelProvider string `json:"model_provider"`
@@ -345,7 +344,7 @@ func (h *AgentHandler) UpdateAgent(c *gin.Context) {
return return
} }
err := h.agentService.UpdateAgent(agentID, req.Name, req.Description, req.Skills, req.RoleDescription, req.ModelProvider, req.ModelName) err := h.agentService.UpdateAgent(agentID, req.Name, req.Description, req.Avatar, req.Skills, req.RoleDescription, req.ModelProvider, req.ModelName)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return

View File

@@ -31,12 +31,18 @@ func (h *ChatGroupHandler) CreateGroup(c *gin.Context) {
return return
} }
// 从上下文获取用户ID // 从上下文获取用户ID如果存在则覆盖请求中的user_id
userID, exists := c.Get("user_id") userID, exists := c.Get("user_id")
if exists { if exists {
req.UserID = userID.(string) req.UserID = userID.(string)
} }
// 验证user_id
if req.UserID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
return
}
group, err := h.groupService.CreateGroup(req) group, err := h.groupService.CreateGroup(req)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})

View File

@@ -1,8 +1,10 @@
package handler package handler
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"x-agents/server/internal/model" "x-agents/server/internal/model"
"x-agents/server/internal/repository" "x-agents/server/internal/repository"
@@ -94,11 +96,12 @@ func (h *ChatHandler) CreateAgent(c *gin.Context) {
// SessionHandler 处理会话管理 // SessionHandler 处理会话管理
type SessionHandler struct { type SessionHandler struct {
chatRepo *repository.ChatRepository chatRepo *repository.ChatRepository
agentService *service.AgentService
} }
func NewSessionHandler(chatRepo *repository.ChatRepository) *SessionHandler { func NewSessionHandler(chatRepo *repository.ChatRepository, agentService *service.AgentService) *SessionHandler {
return &SessionHandler{chatRepo: chatRepo} return &SessionHandler{chatRepo: chatRepo, agentService: agentService}
} }
// CreateSession 创建会话 // CreateSession 创建会话
@@ -226,6 +229,16 @@ func (h *SessionHandler) CreateMessage(c *gin.Context) {
return return
} }
// Debug: 打印请求内容
fmt.Printf("[CreateMessage] Request: session_id=%s, role=%s, content_len=%d\n",
req.SessionID, req.Role, len(req.Content))
// 验证 content 不为空
if req.Content == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Content cannot be empty"})
return
}
// 检查会话是否存在 // 检查会话是否存在
_, err := h.chatRepo.GetSessionByID(req.SessionID) _, err := h.chatRepo.GetSessionByID(req.SessionID)
if err != nil { if err != nil {
@@ -250,3 +263,65 @@ func (h *SessionHandler) CreateMessage(c *gin.Context) {
c.JSON(http.StatusOK, message) c.JSON(http.StatusOK, message)
} }
// GenerateSessionTitleRequest 生成会话标题请求
type GenerateSessionTitleRequest struct {
SessionID string `json:"session_id" binding:"required"`
}
// GenerateSessionTitle 生成会话标题
func (h *SessionHandler) GenerateSessionTitle(c *gin.Context) {
var req GenerateSessionTitleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 获取会话的所有消息
messages, _, err := h.chatRepo.GetMessagesBySessionID(req.SessionID, 100, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get messages"})
return
}
if len(messages) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Not enough messages to generate title"})
return
}
// 获取会话信息
session, err := h.chatRepo.GetSessionByID(req.SessionID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
return
}
// 简单方式从用户的第一条消息中提取前15个字符作为标题
var title string
for _, msg := range messages {
if msg.Role == "user" {
// 清理内容,去除换行和多余空格
content := strings.ReplaceAll(msg.Content, "\n", " ")
content = strings.TrimSpace(content)
// 限制长度
if len(content) > 15 {
title = content[:15] + "..."
} else {
title = content
}
break
}
}
if title == "" {
title = "新会话"
}
fmt.Printf("[GenerateSessionTitle] Generated title: %s\n", title)
// 更新会话标题
session.Title = title
h.chatRepo.UpdateSession(session)
c.JSON(http.StatusOK, gin.H{"title": title})
}

View File

@@ -19,6 +19,7 @@ type Agent struct {
Name string `json:"name" gorm:"size:100;not null"` Name string `json:"name" gorm:"size:100;not null"`
Description string `json:"description" gorm:"type:text"` Description string `json:"description" gorm:"type:text"`
OwnerID string `json:"owner_id" gorm:"size:50;not null;index"` OwnerID string `json:"owner_id" gorm:"size:50;not null;index"`
Avatar string `json:"avatar" gorm:"size:50"` // 头像 (emoji)
// 技能列表(JSON数组) // 技能列表(JSON数组)
Skills []string `json:"skills" gorm:"type:text;serializer:json"` Skills []string `json:"skills" gorm:"type:text;serializer:json"`

View File

@@ -52,7 +52,7 @@ type UpdateSessionRequest struct {
type CreateMessageRequest struct { type CreateMessageRequest struct {
SessionID string `json:"session_id" binding:"required"` SessionID string `json:"session_id" binding:"required"`
Role string `json:"role" binding:"required"` // user/assistant Role string `json:"role" binding:"required"` // user/assistant
Content string `json:"content" binding:"required"` Content string `json:"content"`
TokensUsed int `json:"tokens_used"` TokensUsed int `json:"tokens_used"`
DurationMs int `json:"duration_ms"` DurationMs int `json:"duration_ms"`
Metadata string `json:"metadata"` Metadata string `json:"metadata"`

View File

@@ -56,6 +56,21 @@ func (r *ChatRepository) DeleteSession(id string) error {
return r.db.Delete(&model.ChatSession{}, "id = ?", id).Error return r.db.Delete(&model.ChatSession{}, "id = ?", id).Error
} }
// DeleteSessionsByAgentID 删除智能体的所有会话
func (r *ChatRepository) DeleteSessionsByAgentID(agentID string) error {
// 先查询该智能体的所有会话
var sessions []model.ChatSession
if err := r.db.Where("agent_id = ?", agentID).Find(&sessions).Error; err != nil {
return err
}
// 删除每个会话下的所有消息
for _, session := range sessions {
r.db.Where("session_id = ?", session.ID).Delete(&model.ChatMessage{})
}
// 再删除所有会话
return r.db.Where("agent_id = ?", agentID).Delete(&model.ChatSession{}).Error
}
// Message CRUD // Message CRUD
// CreateMessage 创建消息 // CreateMessage 创建消息

View File

@@ -18,7 +18,7 @@ import (
// AgentChatRequest Python Agent 对话请求 // AgentChatRequest Python Agent 对话请求
type AgentChatRequest struct { type AgentChatRequest struct {
AgentID int `json:"agent_id"` AgentID string `json:"agent_id"` // 支持 UUID 字符串
Message string `json:"message"` Message string `json:"message"`
UserID int `json:"user_id"` UserID int `json:"user_id"`
SessionID string `json:"session_id,omitempty"` SessionID string `json:"session_id,omitempty"`
@@ -32,7 +32,7 @@ type AgentChatRequest struct {
// AgentChatResponse Python Agent 对话响应 // AgentChatResponse Python Agent 对话响应
type AgentChatResponse struct { type AgentChatResponse struct {
AgentID int `json:"agent_id"` AgentID string `json:"agent_id"` // 支持 UUID 字符串
Response string `json:"response"` Response string `json:"response"`
ToolCalls []interface{} `json:"tool_calls"` ToolCalls []interface{} `json:"tool_calls"`
TokensUsed int `json:"tokens_used"` TokensUsed int `json:"tokens_used"`
@@ -66,10 +66,11 @@ type AgentService struct {
client *http.Client client *http.Client
modelRepo *repository.ModelRepository modelRepo *repository.ModelRepository
agentRepo *repository.AgentRepository agentRepo *repository.AgentRepository
chatRepo *repository.ChatRepository
} }
// NewAgentService 创建 Agent 服务 // NewAgentService 创建 Agent 服务
func NewAgentService(pythonURL string, modelRepo *repository.ModelRepository, agentRepo *repository.AgentRepository) *AgentService { func NewAgentService(pythonURL string, modelRepo *repository.ModelRepository, agentRepo *repository.AgentRepository, chatRepo *repository.ChatRepository) *AgentService {
return &AgentService{ return &AgentService{
pythonURL: pythonURL, pythonURL: pythonURL,
client: &http.Client{ client: &http.Client{
@@ -77,6 +78,7 @@ func NewAgentService(pythonURL string, modelRepo *repository.ModelRepository, ag
}, },
modelRepo: modelRepo, modelRepo: modelRepo,
agentRepo: agentRepo, agentRepo: agentRepo,
chatRepo: chatRepo,
} }
} }
@@ -195,16 +197,19 @@ func (s *AgentService) TeamChat(req TeamChatRequest) (*TeamChatResponse, error)
} }
// ChatStream 流式对话 // ChatStream 流式对话
func (s *AgentService) ChatStream(c interface{}, agentID int, message, sessionID, modelID string, userID int) error { func (s *AgentService) ChatStream(c interface{}, agentID string, message, sessionID, modelID string, userID int) error {
// 获取 gin.Context // 获取 gin.Context
ginCtx, ok := c.(*gin.Context) ginCtx, ok := c.(*gin.Context)
if !ok { if !ok {
return fmt.Errorf("invalid context type") return fmt.Errorf("invalid context type")
} }
log.Printf("[ChatStream] Request: agentID=%s, message=%s, sessionID=%s, modelID=%s, userID=%d",
agentID, message, sessionID, modelID, userID)
// 初始化请求体 // 初始化请求体
reqBody := map[string]interface{}{ reqBody := map[string]interface{}{
"agent_id": agentID, "agent_id": agentID, // 传递字符串类型的 agent_id支持 UUID
"message": message, "message": message,
"user_id": userID, "user_id": userID,
"session_id": sessionID, "session_id": sessionID,
@@ -267,8 +272,10 @@ func (s *AgentService) ChatStream(c interface{}, agentID int, message, sessionID
for { for {
n, err := resp.Body.Read(buf) n, err := resp.Body.Read(buf)
if n > 0 { if n > 0 {
log.Printf("[ChatStream] Received %d bytes from Python", n)
_, writeErr := ginCtx.Writer.Write(buf[:n]) _, writeErr := ginCtx.Writer.Write(buf[:n])
if writeErr != nil { if writeErr != nil {
log.Printf("[ChatStream] Write error: %v", writeErr)
break break
} }
// 强制刷新到客户端 // 强制刷新到客户端
@@ -277,6 +284,7 @@ func (s *AgentService) ChatStream(c interface{}, agentID int, message, sessionID
} }
} }
if err != nil { if err != nil {
log.Printf("[ChatStream] Done reading from Python, err: %v", err)
break break
} }
} }
@@ -300,9 +308,10 @@ type CreateAgentRequest struct {
// CreateAgentResponse 创建智能体响应 // CreateAgentResponse 创建智能体响应
type CreateAgentResponse struct { type CreateAgentResponse struct {
AgentID int `json:"agent_id"` AgentID int `json:"agent_id"` // 保留兼容性
Name string `json:"name"` AgentIDStr string `json:"agent_id_str"` // 返回实际的 UUID
Message string `json:"message"` Name string `json:"name"`
Message string `json:"message"`
} }
// CreateAgent 创建智能体 // CreateAgent 创建智能体
@@ -329,6 +338,7 @@ func (s *AgentService) CreateAgent(req CreateAgentRequest, userID int) (*CreateA
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
OwnerID: fmt.Sprintf("%d", userID), OwnerID: fmt.Sprintf("%d", userID),
Avatar: req.Avatar,
Skills: skills, Skills: skills,
RoleDescription: req.Prompt, RoleDescription: req.Prompt,
ModelProvider: req.ModelProvider, ModelProvider: req.ModelProvider,
@@ -347,13 +357,11 @@ func (s *AgentService) CreateAgent(req CreateAgentRequest, userID int) (*CreateA
log.Printf("[AgentService] Agent created in database: %s (ID: %s)", agent.Name, agent.ID) log.Printf("[AgentService] Agent created in database: %s (ID: %s)", agent.Name, agent.ID)
// 解析 agent ID 为整数返回 // 返回数据库中实际的 Agent ID (UUID字符串)
agentIDInt := int(time.Now().Unix()) % 100000
return &CreateAgentResponse{ return &CreateAgentResponse{
AgentID: agentIDInt, AgentIDStr: agent.ID,
Name: agent.Name, Name: agent.Name,
Message: "Agent created successfully", Message: "Agent created successfully",
}, nil }, nil
} }
@@ -429,6 +437,14 @@ func (s *AgentService) DeleteAgent(agentID string) error {
return fmt.Errorf("agent not found: %w", err) return fmt.Errorf("agent not found: %w", err)
} }
// 先删除该智能体的所有会话和消息
if s.chatRepo != nil {
if err := s.chatRepo.DeleteSessionsByAgentID(agentID); err != nil {
log.Printf("[AgentService] DeleteAgent: failed to delete sessions: %v", err)
// 继续尝试删除 agent不因为 session 删除失败而中止
}
}
if err := s.agentRepo.Delete(agentID); err != nil { if err := s.agentRepo.Delete(agentID); err != nil {
return fmt.Errorf("failed to delete agent: %w", err) return fmt.Errorf("failed to delete agent: %w", err)
} }
@@ -438,7 +454,7 @@ func (s *AgentService) DeleteAgent(agentID string) error {
} }
// UpdateAgent 更新智能体 // UpdateAgent 更新智能体
func (s *AgentService) UpdateAgent(agentID, name, description string, skills []string, roleDescription, modelProvider, modelName string) error { func (s *AgentService) UpdateAgent(agentID, name, description, avatar string, skills []string, roleDescription, modelProvider, modelName string) error {
if s.agentRepo == nil { if s.agentRepo == nil {
return fmt.Errorf("agent repository not initialized") return fmt.Errorf("agent repository not initialized")
} }
@@ -458,6 +474,9 @@ func (s *AgentService) UpdateAgent(agentID, name, description string, skills []s
if description != "" { if description != "" {
agent.Description = description agent.Description = description
} }
if avatar != "" {
agent.Avatar = avatar
}
if skills != nil { if skills != nil {
agent.Skills = skills agent.Skills = skills
} }

View File

@@ -18,7 +18,7 @@ html.dark {
} }
body { body {
font-family: var(--font-system, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif); font-family: var(--font-system, 'PingFang SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
font-size: var(--font-size-sm, 14px); font-size: var(--font-size-sm, 14px);
line-height: 1.5; line-height: 1.5;
color: #f3f4f6; color: #f3f4f6;

View File

@@ -43,8 +43,8 @@
--spacing-xl: 24px; --spacing-xl: 24px;
--spacing-2xl: 32px; --spacing-2xl: 32px;
/* 字体-family: -apple */ /* 字体 - 添加中文字体支持 */
--font-system: BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; --font-system: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
--font-size-xs: 12px; --font-size-xs: 12px;
--font-size-sm: 14px; --font-size-sm: 14px;
--font-size-md: 16px; --font-size-md: 16px;

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElMessageBox } from 'element-plus'
import type { Agent } from '@/views/chat/chat' import type { Agent } from '@/views/chat/chat'
const props = defineProps<{ const props = defineProps<{
@@ -14,12 +15,32 @@ const emit = defineEmits<{
(e: 'toggleSelect', agent: Agent): void (e: 'toggleSelect', agent: Agent): void
(e: 'confirm'): void (e: 'confirm'): void
(e: 'update:groupChatName', value: string): void (e: 'update:groupChatName', value: string): void
(e: 'delete', agent: Agent): void
}>() }>()
// 点击智能体 - 只是选择,不直接确认 // 点击智能体 - 只是选择,不直接确认
const handleAgentClick = (agent: Agent) => { const handleAgentClick = (agent: Agent) => {
emit('toggleSelect', agent) emit('toggleSelect', agent)
} }
// 删除智能体
const handleDeleteAgent = async (agent: Agent, event: Event) => {
event.stopPropagation()
try {
await ElMessageBox.confirm(
`确定要删除智能体 "${agent.name}" 吗?删除后无法恢复。`,
'删除确认',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
}
)
emit('delete', agent)
} catch {
// 用户取消删除
}
}
</script> </script>
<template> <template>
@@ -29,10 +50,10 @@ const handleAgentClick = (agent: Agent) => {
<div class="bg-dark-800 rounded-xl w-full max-w-md border border-dark-600 shadow-2xl" @click.stop> <div class="bg-dark-800 rounded-xl w-full max-w-md border border-dark-600 shadow-2xl" @click.stop>
<div class="p-4 border-b border-dark-600"> <div class="p-4 border-b border-dark-600">
<h3 class="text-lg font-semibold text-white"> <h3 class="text-lg font-semibold text-white">
{{ selectMode === 'single' ? '选择智能体' : '选择群聊成员' }} {{ selectMode === 'single' ? '选择会话' : '选择群聊成员' }}
</h3> </h3>
<p class="text-sm text-gray-400 mt-1"> <p class="text-sm text-gray-400 mt-1">
{{ selectMode === 'single' ? '选择一个智能体开始对话' : '选择多个智能体创建群聊' }} {{ selectMode === 'single' ? '选择一个智能体开始对话' : '选择多个智能体创建群聊' }}
</p> </p>
</div> </div>
@@ -53,7 +74,7 @@ const handleAgentClick = (agent: Agent) => {
v-for="agent in chatAgents" v-for="agent in chatAgents"
:key="agent.id" :key="agent.id"
@click="handleAgentClick(agent)" @click="handleAgentClick(agent)"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200" class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 group"
:class="selectedAgents.some(a => a.id === agent.id) :class="selectedAgents.some(a => a.id === agent.id)
? 'bg-orange-500/20 border border-orange-500/50' ? 'bg-orange-500/20 border border-orange-500/50'
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'" : 'bg-dark-700 hover:bg-dark-600 border border-transparent'"

View File

@@ -63,7 +63,12 @@ const copyMessage = async () => {
: 'bg-[#1e1e28] text-gray-100 rounded-bl-sm'" : 'bg-[#1e1e28] text-gray-100 rounded-bl-sm'"
> >
<span v-html="getMessageContent(message.content, message.role === 'user')"></span> <span v-html="getMessageContent(message.content, message.role === 'user')"></span>
<span v-if="message.isStreaming" class="inline-block w-0.5 h-4 ml-0.5 bg-orange-300 cursor-blink align-middle"></span> <!-- 等待提示 - 三个点动画 -->
<span v-if="message.isStreaming" class="inline-flex items-center ml-1 align-middle">
<span class="w-1.5 h-1.5 mx-0.5 bg-orange-400 rounded-full animate-bounce" style="animation-delay: 0ms;"></span>
<span class="w-1.5 h-1.5 mx-0.5 bg-orange-400 rounded-full animate-bounce" style="animation-delay: 150ms;"></span>
<span class="w-1.5 h-1.5 mx-0.5 bg-orange-400 rounded-full animate-bounce" style="animation-delay: 300ms;"></span>
</span>
<!-- 复制按钮 --> <!-- 复制按钮 -->
<Transition name="fade"> <Transition name="fade">

View File

@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { ElMessageBox } from 'element-plus'
import type { Agent, ChatSession, GroupChat } from '@/views/chat/chat' import type { Agent, ChatSession, GroupChat } from '@/views/chat/chat'
defineProps<{ const props = defineProps<{
collapsed: boolean collapsed: boolean
chatAgents: Agent[] chatAgents: Agent[]
selectedAgent: Agent | null selectedAgent: Agent | null
chatSessions: ChatSession[] chatSessions: ChatSession[]
groupChats: GroupChat[] groupChats?: GroupChat[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -14,8 +16,16 @@ const emit = defineEmits<{
(e: 'selectAgent', agent: Agent): void (e: 'selectAgent', agent: Agent): void
(e: 'selectSession', session: ChatSession): void (e: 'selectSession', session: ChatSession): void
(e: 'selectGroup', group: GroupChat): void (e: 'selectGroup', group: GroupChat): void
(e: 'deleteSession', session: ChatSession): void
}>() }>()
// 根据 agent_id 获取智能体名称
const getAgentName = (agentId: number | string | undefined) => {
if (!agentId) return '未知智能体'
const agent = props.chatAgents.find(a => a.id === agentId)
return agent?.name || '未知智能体'
}
const formatRelativeTime = (date: Date) => { const formatRelativeTime = (date: Date) => {
const now = new Date() const now = new Date()
const diff = now.getTime() - date.getTime() const diff = now.getTime() - date.getTime()
@@ -27,6 +37,25 @@ const formatRelativeTime = (date: Date) => {
if (days < 7) return `${days}天前` if (days < 7) return `${days}天前`
return date.toLocaleDateString('zh-CN') return date.toLocaleDateString('zh-CN')
} }
// 删除会话
const handleDeleteSession = async (session: ChatSession, event: Event) => {
event.stopPropagation()
try {
await ElMessageBox.confirm(
`确定要删除与 "${session.title}" 的对话吗?`,
'删除确认',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
}
)
emit('deleteSession', session)
} catch {
// 用户取消删除
}
}
</script> </script>
<template> <template>
@@ -69,47 +98,40 @@ const formatRelativeTime = (date: Date) => {
</div> </div>
</div> </div>
<!-- AI 助手选择 --> <!-- 会话列表 -->
<div class="px-3 pb-3">
<div class="text-xs text-white/40 uppercase tracking-wider mb-2 px-1">选择 AI 助手</div>
<div class="space-y-1">
<button
v-for="agent in chatAgents"
:key="agent.id"
@click="emit('selectAgent', agent)"
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200"
:class="selectedAgent?.id === agent.id
? 'bg-orange-500/15 text-orange-400'
: 'text-white/60 hover:bg-white/5 hover:text-white'"
>
<span class="text-base">{{ agent.avatar }}</span>
<span class="text-sm truncate">{{ agent.name }}</span>
<span
v-if="agent.status === 'online'"
class="w-1.5 h-1.5 rounded-full bg-emerald-400 ml-auto"
></span>
</button>
</div>
</div>
<!-- 群聊列表 -->
<div class="flex-1 overflow-y-auto px-3 pb-3"> <div class="flex-1 overflow-y-auto px-3 pb-3">
<div class="text-xs text-white/40 uppercase tracking-wider mb-2 px-1">群聊</div> <div class="text-xs text-white/40 uppercase tracking-wider mb-2 px-1">会话</div>
<div class="space-y-1"> <div class="space-y-1">
<button <button
v-for="group in groupChats" v-for="session in chatSessions"
:key="group.id" :key="session.id"
@click="emit('selectGroup', group)" @click="emit('selectSession', session)"
class="w-full text-left px-3 py-2.5 rounded-lg hover:bg-white/5 transition-all duration-200 group" class="w-full text-left px-3 py-2.5 rounded-lg hover:bg-white/5 transition-all duration-200 group"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<svg class="w-4 h-4 text-white/30 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-white/30 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
</svg> </svg>
<span class="text-sm text-white/70 group-hover:text-white truncate">{{ group.name }}</span> <span class="text-sm text-white/70 group-hover:text-white truncate flex-1">{{ session.title || '新会话' }}</span>
<!-- 删除按钮 -->
<span
@click="handleDeleteSession(session, $event)"
class="hidden group-hover:flex w-6 h-6 items-center justify-center rounded-md text-white/30 hover:text-red-400 hover:bg-red-500/20 transition-colors"
title="删除会话"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</span>
</div>
<div class="flex items-center gap-2 mt-1 pl-6">
<span class="text-xs text-orange-500/70">{{ getAgentName(session.agent_id) }}</span>
<span class="text-xs text-white/30">{{ formatRelativeTime(session.timestamp) }}</span>
</div> </div>
<div class="text-xs text-white/30 mt-1 pl-6">{{ group.members.length }} members</div>
</button> </button>
<div v-if="!chatSessions || chatSessions.length === 0" class="text-xs text-white/30 text-center py-4">
暂无会话记录
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onUnmounted } from 'vue'
import { Play, Pause, Edit, Trash2 } from 'lucide-vue-next' import { Play, Pause, Edit, Trash2 } from 'lucide-vue-next'
import { useAgents } from './agents/useAgents' import { useAgents } from './agents/useAgents'
import './agents/agents.css' import './agents/agents.css'
@@ -57,8 +58,13 @@ const {
getSkillsDisplayText, getSkillsDisplayText,
toggleSkillSelection, toggleSkillSelection,
selectAllSkills, selectAllSkills,
statusClass statusClass,
cleanup
} = useAgents() } = useAgents()
onUnmounted(() => {
cleanup()
})
</script> </script>
<template> <template>
@@ -162,18 +168,18 @@ const {
class="btn-icon" class="btn-icon"
:title="agent.status === 'active' ? 'Deactivate' : 'Activate'" :title="agent.status === 'active' ? 'Deactivate' : 'Activate'"
> >
<Pause v-if="agent.status === 'active'" class="w-4 h-4 text-gray-500 hover:text-yellow-400 transition-colors" /> <Pause v-if="agent.status === 'active'" class="w-4 h-4 text-gray-400 hover:text-yellow-400 transition-colors" />
<Play v-else class="w-4 h-4 text-gray-500 hover:text-green-400 transition-colors" /> <Play v-else class="w-4 h-4 text-gray-400 hover:text-green-400 transition-colors" />
</button> </button>
<button @click="openEdit(agent)" class="btn-icon" title="Edit"> <button @click="openEdit(agent)" class="btn-icon" title="Edit">
<Edit class="w-4 h-4 text-gray-500 hover:text-white transition-colors" /> <Edit class="w-4 h-4 text-gray-400 hover:text-white transition-colors" />
</button> </button>
<button <button
@click.stop="deleteAgent(agent.id)" @click.stop="deleteAgent(agent.id)"
class="btn-icon" class="btn-icon"
title="Delete" title="Delete"
> >
<Trash2 class="w-4 h-4 text-gray-500 hover:text-red-400 transition-colors" /> <Trash2 class="w-4 h-4 text-gray-400 hover:text-red-500 transition-colors" />
</button> </button>
</div> </div>
</td> </td>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue' import { ref, nextTick, watch, onMounted, onUnmounted } from 'vue'
import { useChat } from './chat/chat' import { useChat } from './chat/chat'
import ChatHeader from '@/components/chat/ChatHeader.vue' import ChatHeader from '@/components/chat/ChatHeader.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue' import ChatMessage from '@/components/chat/ChatMessage.vue'
@@ -45,19 +45,126 @@ const {
const messagesContainer = ref<HTMLElement | null>(null) const messagesContainer = ref<HTMLElement | null>(null)
// Mock 流式响应(用于测试前端流式效果) // 构建 API 请求体
const mockStreamResponse = async (content: string, messageIndex: number) => { const buildRequestBody = (userContent: string) => {
const chars = content.split('') const requestBody: any = {
for (let i = 0; i < chars.length; i++) { agent_id: String(selectedAgent.value?.id || 1),
messages.value[messageIndex].content += chars[i] message: userContent,
await nextTick() }
scrollToBottom()
await new Promise(resolve => setTimeout(resolve, 15)) if (selectedModel.value) {
requestBody.model_id = selectedModel.value.id
}
if (currentSessionId.value) {
requestBody.session_id = currentSessionId.value
}
return requestBody
}
// 解析流式响应数据
const parseStreamData = (rawData: string): string => {
if (!rawData || rawData === '[DONE]') return ''
try {
const parsed = JSON.parse(rawData)
if (typeof parsed === 'string') {
return parsed
}
return parsed.content || parsed.delta?.content || ''
} catch {
return ''
}
}
// 处理流式响应
const handleStreamResponse = async (response: Response) => {
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
const aiMessageIndex = messages.value.length - 1
while (true) {
const { done, value } = await reader.read()
if (done) break
const decoded = decoder.decode(value, { stream: true })
buffer += decoded
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const content = parseStreamData(line.slice(6).trim())
if (content) {
messages.value[aiMessageIndex].content += content
await nextTick()
scrollToBottom()
}
}
}
}
// 处理剩余buffer
if (buffer.startsWith('data: ')) {
const content = parseStreamData(buffer.slice(6).trim())
if (content) {
messages.value[aiMessageIndex].content += content
}
}
messages.value[aiMessageIndex].isStreaming = false
isLoading.value = false
scrollToBottom()
// 保存 AI 消息
await saveMessage('assistant', messages.value[aiMessageIndex].content)
// 第二轮对话结束后生成标题
if (messages.value.length === 5 && currentSessionId.value) {
generateSessionTitle()
}
}
// 处理消息发送错误
const handleMessageError = (error: any) => {
const errorIndex = messages.value.findIndex(m => m.isStreaming)
if (errorIndex > -1) {
messages.value[errorIndex].content = `Error: ${error.message || 'Failed to send message'}`
messages.value[errorIndex].isStreaming = false
} }
messages.value[messageIndex].isStreaming = false
isLoading.value = false isLoading.value = false
} }
// 重置输入框
const resetInputHeight = () => {
nextTick(() => {
const textarea = document.querySelector('.chat-input-textarea') as HTMLTextAreaElement
if (textarea) {
textarea.style.height = 'auto'
}
})
}
// 创建用户消息对象
const createUserMessage = (content: string) => ({
id: Date.now(),
role: 'user' as const,
content,
timestamp: new Date()
})
// 创建 AI 消息对象
const createAssistantMessage = () => ({
id: Date.now() + 1,
role: 'assistant' as const,
content: '',
timestamp: new Date(),
isStreaming: true
})
// 滚动到底部 // 滚动到底部
const scrollToBottom = () => { const scrollToBottom = () => {
if (messagesContainer.value) { if (messagesContainer.value) {
@@ -65,6 +172,11 @@ const scrollToBottom = () => {
} }
} }
// 监听消息变化,自动滚动到底部
watch(messages, () => {
nextTick(() => scrollToBottom())
}, { deep: true })
// 切换模型下拉框 // 切换模型下拉框
const toggleModelDropdown = () => { const toggleModelDropdown = () => {
showModelDropdown.value = !showModelDropdown.value showModelDropdown.value = !showModelDropdown.value
@@ -76,133 +188,91 @@ const handleSelectModel = (model: any) => {
showModelDropdown.value = false showModelDropdown.value = false
} }
// 删除会话
const handleDeleteSession = async (session: any) => {
try {
const response = await fetch(`/api/chat/sessions/${session.id}`, {
method: 'DELETE',
})
if (response.ok) {
const index = chatSessions.value.findIndex((s: any) => s.id === session.id)
if (index > -1) {
chatSessions.value.splice(index, 1)
}
if (currentSessionId.value === session.id) {
currentSessionId.value = null
messages.value = []
}
}
} catch {
// ignore
}
}
// 生成会话标题
const generateSessionTitle = async () => {
if (!currentSessionId.value) return
try {
const response = await fetch('/api/chat/sessions/generate-title', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: currentSessionId.value })
})
if (response.ok) {
const data = await response.json()
const sessionIndex = chatSessions.value.findIndex((s: any) => s.id === currentSessionId.value)
if (sessionIndex > -1) {
chatSessions.value[sessionIndex].title = data.title
}
}
} catch {
// ignore
}
}
// 发送消息 // 发送消息
const sendMessage = async () => { const sendMessage = async () => {
if (!inputMessage.value.trim() || isLoading.value) return if (!inputMessage.value.trim() || isLoading.value) return
const userContent = inputMessage.value.trim() const userContent = inputMessage.value.trim()
inputMessage.value = '' inputMessage.value = ''
resetInputHeight()
// 重置输入框高度
nextTick(() => {
const textarea = document.querySelector('.chat-input-textarea') as HTMLTextAreaElement
if (textarea) {
textarea.style.height = 'auto'
}
})
// 如果没有会话,创建一个新会话
if (!currentSessionId.value) { if (!currentSessionId.value) {
await createSession() const session = await createSession()
if (!session) return
} }
const userMessage = { const userMessage = createUserMessage(userContent)
id: Date.now(),
role: 'user' as const,
content: userContent,
timestamp: new Date()
}
messages.value.push(userMessage) messages.value.push(userMessage)
// 保存用户消息到后端
await saveMessage('user', userContent) await saveMessage('user', userContent)
const aiMessage = { const aiMessage = createAssistantMessage()
id: Date.now() + 1,
role: 'assistant' as const,
content: '',
timestamp: new Date(),
isStreaming: true
}
messages.value.push(aiMessage) messages.value.push(aiMessage)
nextTick(() => scrollToBottom()) nextTick(() => scrollToBottom())
isLoading.value = true isLoading.value = true
try { try {
const requestBody: any = { const response = await fetch('/api/agent/chat/stream', {
agent_id: String(selectedAgent.value?.id || 1),
message: userContent,
}
if (selectedModel.value) {
requestBody.model_id = selectedModel.value.id
}
// 传入 session_id
if (currentSessionId.value) {
requestBody.session_id = currentSessionId.value
}
const response = await fetch(`/api/agent/chat/stream`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json', body: JSON.stringify(buildRequestBody(userContent)),
},
body: JSON.stringify(requestBody),
}) })
if (!response.ok) { if (!response.ok) {
throw new Error(`Request failed: ${response.status}`) throw new Error(`Request failed: ${response.status}`)
} }
// 真正的流式处理:边读取边显示 await handleStreamResponse(response)
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const aiMessageIndex = messages.value.length - 1
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (data && data !== '[DONE]') {
// 直接累加内容并显示(真正的流式)
messages.value[aiMessageIndex].content += data
await nextTick()
scrollToBottom()
}
}
}
}
// 处理剩余buffer中的数据
if (buffer.startsWith('data: ')) {
const data = buffer.slice(6).trim()
if (data && data !== '[DONE]') {
messages.value[aiMessageIndex].content += data
}
}
messages.value[aiMessageIndex].isStreaming = false
isLoading.value = false
scrollToBottom()
// 保存 AI 消息到后端
await saveMessage('assistant', messages.value[aiMessageIndex].content)
} catch (error: any) { } catch (error: any) {
console.error('[Stream] 错误:', error) handleMessageError(error)
const errorIndex = messages.value.findIndex(m => m.isStreaming)
if (errorIndex > -1) {
messages.value[errorIndex].content = `Error: ${error.message || 'Failed to send message'}`
messages.value[errorIndex].isStreaming = false
}
isLoading.value = false
} }
} }
// 初始化 // 初始化
onMounted(() => { onMounted(() => {
console.log('[Chat] Component mounted, calling init()')
init() init()
}) })
@@ -269,6 +339,7 @@ onUnmounted(() => {
@select-agent="selectAgent" @select-agent="selectAgent"
@select-session="selectSession" @select-session="selectSession"
@select-group="selectGroup" @select-group="selectGroup"
@delete-session="handleDeleteSession"
/> />
<!-- 智能体选择弹窗 --> <!-- 智能体选择弹窗 -->

View File

@@ -1,70 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'
import { Play, Pause, Edit, Trash2, Plus, Search, Clock } from 'lucide-vue-next' import { Play, Pause, Edit, Trash2, Plus, Search, Clock } from 'lucide-vue-next'
import { usePlan } from './plan/usePlan'
import './plan/plan.css'
// Mock scheduled tasks data const {
const tasks = ref([ filterStatus,
{ searchQuery,
id: 1, filteredTasks,
name: 'Human-like Heartbeat', } = usePlan()
status: 'running',
triggerType: 'Interval 30 minutes',
nextRun: '2026/03/10 16:26',
lastRun: '2026/03/10 15:56',
notifyChannel: '-',
executionCount: 13,
description: 'Check if proactive messages need to be sent (greetings/reminders/follow-ups)',
tags: ['System Task', 'Agent Task', 'Interval']
},
{
id: 2,
name: 'Memory Organization',
status: 'running',
triggerType: 'Interval 3 hours',
nextRun: '2026/03/10 18:35',
lastRun: '2026/03/10 15:35',
notifyChannel: '-',
executionCount: 2,
description: 'Execute memory organization: organize chat history, extract key memories, refresh MEMORY.md',
tags: ['System Task', 'Agent Task', 'Interval']
},
{
id: 3,
name: 'System Self-Check',
status: 'running',
triggerType: 'Daily 04:00',
nextRun: '2026/03/11 04:00',
lastRun: 'Never',
notifyChannel: '-',
executionCount: 0,
description: 'Execute system self-check: analyze ERROR logs, try to fix tool issues, generate report',
tags: ['System Task', 'Agent Task', 'Daily']
},
])
const filterStatus = ref('all') // running, stopped, all
const searchQuery = ref('')
const filteredTasks = computed(() => {
let result = tasks.value
if (filterStatus.value !== 'all') {
result = result.filter(t => t.status === filterStatus.value)
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(t =>
t.name.toLowerCase().includes(query) ||
t.description.toLowerCase().includes(query)
)
}
return result
})
const getTaskCount = (status: string) => {
if (status === 'running') return tasks.value.filter(t => t.status === 'running').length
if (status === 'stopped') return tasks.value.filter(t => t.status === 'stopped').length
return tasks.value.length
}
</script> </script>
<template> <template>
@@ -170,99 +113,3 @@ const getTaskCount = (status: string) => {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.plan-page {
background-color: #0f1419;
}
.search-input {
background-color: #1f2937;
border: 1px solid #374151;
border-radius: 8px;
padding: 10px 12px 10px 36px;
color: white;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: #f97316;
}
.search-input::placeholder {
color: #6b7280;
}
.table-row {
border-top: 1px solid #2a2a3a;
transition: background-color 0.2s;
}
.table-row:hover {
background-color: rgba(255, 255, 255, 0.02);
}
.task-tag {
background-color: #374151;
color: #d1d5db;
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
}
.ml-13 {
margin-left: 3.25rem;
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
padding: 6px;
border-radius: 4px;
transition: background-color 0.2s;
}
.btn-icon:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* 空状态样式 */
.empty-box {
min-height: 340px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.empty-icon {
width: 100px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2937, #111827);
border-radius: 24px;
margin-bottom: 20px;
}
.empty-icon i {
font-size: 40px;
color: #6b7280;
}
.empty-text {
color: #d1d5db;
font-size: 1.25rem;
font-weight: 500;
margin-bottom: 8px;
}
.empty-tip {
color: #6b7280;
font-size: 0.875rem;
}
</style>

View File

@@ -278,7 +278,7 @@ async function createAgent() {
const skillsLabels = newAgent.value.selectedSkills.map(id => getSkillLabel(id)).join(', ') const skillsLabels = newAgent.value.selectedSkills.map(id => getSkillLabel(id)).join(', ')
agents.value.unshift({ agents.value.unshift({
id: result.agent_id, id: result.agent_id_str || result.agent_id,
name: newAgent.value.name, name: newAgent.value.name,
avatar: newAgent.value.avatar, avatar: newAgent.value.avatar,
description: newAgent.value.description, description: newAgent.value.description,
@@ -341,6 +341,7 @@ async function saveEdit() {
body: JSON.stringify({ body: JSON.stringify({
name: editingAgent.value.name, name: editingAgent.value.name,
description: editingAgent.value.description, description: editingAgent.value.description,
avatar: editingAgent.value.avatar,
skills: skills, skills: skills,
role_description: editingAgent.value.prompt, role_description: editingAgent.value.prompt,
model_provider: selectedModel?.provider || '', model_provider: selectedModel?.provider || '',
@@ -353,6 +354,7 @@ async function saveEdit() {
if (agent) { if (agent) {
agent.name = editingAgent.value.name agent.name = editingAgent.value.name
agent.description = editingAgent.value.description agent.description = editingAgent.value.description
agent.avatar = editingAgent.value.avatar
agent.skills = editingAgent.value.skillsMode === 'all' ? '*' : editingAgent.value.selectedSkills.join(', ') agent.skills = editingAgent.value.skillsMode === 'all' ? '*' : editingAgent.value.selectedSkills.join(', ')
agent.model = selectedModel?.name || '' agent.model = selectedModel?.name || ''
} }

View File

@@ -20,7 +20,7 @@ export interface ChatMessage {
} }
export interface Agent { export interface Agent {
id: number id: string | number
name: string name: string
avatar: string avatar: string
description: string description: string
@@ -40,7 +40,7 @@ export interface ChatSession {
} }
export interface GroupChat { export interface GroupChat {
id: number id: string | number
name: string name: string
members: string[] members: string[]
lastMessage: string lastMessage: string
@@ -82,7 +82,6 @@ export const renderMarkdown = (content: string): string => {
const processed = preprocessContent(content) const processed = preprocessContent(content)
return marked.parse(processed) as string return marked.parse(processed) as string
} catch (e) { } catch (e) {
console.error('Markdown parse error:', e)
return content return content
} }
} }
@@ -150,24 +149,21 @@ export function useChat() {
try { try {
const response = await fetch(`/model/list`) const response = await fetch(`/model/list`)
const data = await response.json() const data = await response.json()
console.log('[Chat] Raw models:', data.list)
if (data.list) { if (data.list) {
// 只过滤出 active 的 chat 模型 (status: 1=active, 0=inactive) // 只过滤出 active 的 chat 模型 (status: 1=active, 0=inactive)
const activeChatModels = data.list.filter((m: ChatModel) => const activeChatModels = data.list.filter((m: ChatModel) =>
m.model_type === 'chat' && m.status === 1 m.model_type === 'chat' && m.status === 1
) )
console.log('[Chat] Filtered chat models:', activeChatModels)
chatModels.value = activeChatModels chatModels.value = activeChatModels
// 默认选中第一个 active 的 chat 模型 // 默认选中第一个 active 的 chat 模型
if (chatModels.value.length > 0) { if (chatModels.value.length > 0) {
selectedModel.value = chatModels.value[0] selectedModel.value = chatModels.value[0]
console.log('[Chat] Selected model:', selectedModel.value)
} }
} }
} catch (error) { } catch {
console.error('Failed to fetch models:', error) // 静默处理
} finally { } finally {
modelsLoading.value = false modelsLoading.value = false
} }
@@ -178,7 +174,6 @@ export function useChat() {
try { try {
const response = await fetch('/api/agent/list') const response = await fetch('/api/agent/list')
const data = await response.json() const data = await response.json()
console.log('[Chat] Agents:', data)
if (data.agents) { if (data.agents) {
chatAgents.value = data.agents.map((agent: any) => ({ chatAgents.value = data.agents.map((agent: any) => ({
@@ -186,9 +181,9 @@ export function useChat() {
name: agent.name, name: agent.name,
avatar: agent.avatar || '🧠', avatar: agent.avatar || '🧠',
description: agent.description || '', description: agent.description || '',
accentColor: agent.accent_color || '#f97316', accentColor: '#f97316',
gradient: 'from-orange-500/20 to-amber-500/20', gradient: 'from-orange-500/20 to-amber-500/20',
status: agent.status === 'active' ? 'online' : 'offline' status: agent.is_active ? 'online' : 'offline'
})) }))
// 默认选中第一个智能体 // 默认选中第一个智能体
@@ -196,8 +191,8 @@ export function useChat() {
selectedAgent.value = chatAgents.value[0] selectedAgent.value = chatAgents.value[0]
} }
} }
} catch (error) { } catch {
console.error('Failed to fetch agents:', error) // 静默处理
} }
} }
@@ -207,7 +202,6 @@ export function useChat() {
const userId = localStorage.getItem('user_id') || 'default-user' const userId = localStorage.getItem('user_id') || 'default-user'
const response = await fetch(`/api/chat/sessions?user_id=${userId}&limit=50`) const response = await fetch(`/api/chat/sessions?user_id=${userId}&limit=50`)
const data = await response.json() const data = await response.json()
console.log('[Chat] Sessions:', data)
if (data.list) { if (data.list) {
chatSessions.value = data.list.map((s: any) => ({ chatSessions.value = data.list.map((s: any) => ({
@@ -219,9 +213,13 @@ export function useChat() {
timestamp: new Date(s.created_at || Date.now()), timestamp: new Date(s.created_at || Date.now()),
status: s.status status: s.status
})) }))
// 自动选择最近的会话并加载消息
// 页面加载时不自动选择会话,显示空页面
// 用户点击"新建聊天"或选择智能体时才创建会话
} }
} catch (error) { } catch {
console.error('Failed to fetch sessions:', error) // 静默处理
} }
} }
@@ -231,7 +229,6 @@ export function useChat() {
const userId = localStorage.getItem('user_id') || 'default-user' const userId = localStorage.getItem('user_id') || 'default-user'
const response = await fetch(`/api/chat/groups?user_id=${userId}`) const response = await fetch(`/api/chat/groups?user_id=${userId}`)
const data = await response.json() const data = await response.json()
console.log('[Chat] Groups:', data)
if (data.list) { if (data.list) {
groupChats.value = data.list.map((g: any) => ({ groupChats.value = data.list.map((g: any) => ({
@@ -242,13 +239,13 @@ export function useChat() {
timestamp: new Date(g.created_at || Date.now()) timestamp: new Date(g.created_at || Date.now())
})) }))
} }
} catch (error) { } catch {
console.error('Failed to fetch groups:', error) // 静默处理
} }
} }
// 创建群聊 // 创建群聊
const createGroup = async (name: string, agentIds: number[]) => { const createGroup = async (name: string, agentIds: (string | number)[]) => {
try { try {
const userId = localStorage.getItem('user_id') || 'default-user' const userId = localStorage.getItem('user_id') || 'default-user'
const response = await fetch('/api/chat/groups', { const response = await fetch('/api/chat/groups', {
@@ -260,8 +257,12 @@ export function useChat() {
agent_ids: JSON.stringify(agentIds) agent_ids: JSON.stringify(agentIds)
}) })
}) })
if (!response.ok) {
console.error('Create group failed:', response.status, await response.text())
return null
}
const group = await response.json() const group = await response.json()
console.log('[Chat] Created group:', group)
// 添加到群聊列表 // 添加到群聊列表
groupChats.value.unshift({ groupChats.value.unshift({
@@ -273,8 +274,7 @@ export function useChat() {
}) })
return group return group
} catch (error) { } catch {
console.error('Failed to create group:', error)
return null return null
} }
} }
@@ -293,8 +293,12 @@ export function useChat() {
model_id: selectedModel.value?.id model_id: selectedModel.value?.id
}) })
}) })
if (!response.ok) {
return null
}
const session = await response.json() const session = await response.json()
console.log('[Chat] Created session:', session)
// 添加到会话列表 // 添加到会话列表
chatSessions.value.unshift({ chatSessions.value.unshift({
@@ -307,8 +311,7 @@ export function useChat() {
currentSessionId.value = session.id currentSessionId.value = session.id
return session return session
} catch (error) { } catch {
console.error('Failed to create session:', error)
return null return null
} }
} }
@@ -318,7 +321,6 @@ export function useChat() {
try { try {
const response = await fetch(`/api/chat/sessions/${sessionId}/messages?limit=100`) const response = await fetch(`/api/chat/sessions/${sessionId}/messages?limit=100`)
const data = await response.json() const data = await response.json()
console.log('[Chat] Messages:', data)
if (data.list) { if (data.list) {
messages.value = data.list.map((m: any) => ({ messages.value = data.list.map((m: any) => ({
@@ -328,27 +330,37 @@ export function useChat() {
timestamp: new Date(m.created_at) timestamp: new Date(m.created_at)
})) }))
} }
} catch (error) { } catch {
console.error('Failed to fetch messages:', error) // 静默处理
} }
} }
// 保存消息到后端 // 保存消息到后端
const saveMessage = async (role: 'user' | 'assistant', content: string) => { const saveMessage = async (role: 'user' | 'assistant', content: string) => {
if (!currentSessionId.value) return const sessionId = currentSessionId.value
if (!sessionId || typeof sessionId !== 'string' || sessionId.trim() === '') {
return
}
// 检查内容是否有效
if (!content || typeof content !== 'string' || content.trim() === '') {
return
}
const payload = {
session_id: sessionId,
role: role,
content: content
}
try { try {
await fetch('/api/chat/messages', { await fetch('/api/chat/messages', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(payload)
session_id: currentSessionId.value,
role: role,
content: content
})
}) })
} catch (error) { } catch {
console.error('Failed to save message:', error) // 静默处理
} }
} }
@@ -360,8 +372,8 @@ export function useChat() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }) body: JSON.stringify({ title })
}) })
} catch (error) { } catch {
console.error('Failed to update session:', error) // 静默处理
} }
} }
@@ -376,8 +388,8 @@ export function useChat() {
currentSessionId.value = null currentSessionId.value = null
messages.value = [] messages.value = []
} }
} catch (error) { } catch {
console.error('Failed to delete session:', error) // 静默处理
} }
} }
@@ -452,19 +464,47 @@ export function useChat() {
showAgentSelector.value = false showAgentSelector.value = false
} }
// 选择助手 // 选择助手 - 如果是同一智能体则不创建新会话
const selectAgent = (agent: Agent) => { const selectAgent = async (agent: Agent) => {
// 如果选择的是同一智能体,不创建新会话,直接返回
if (selectedAgent.value?.id === agent.id) {
return
}
selectedAgent.value = agent selectedAgent.value = agent
// 创建新会话
const session = await createSession(`${agent.name} 的对话`)
if (session) {
currentSessionId.value = session.id
}
messages.value = [ messages.value = [
{ id: 1, role: 'assistant', content: `你好!我是 ${agent.name}。有什么我可以帮助你的吗?`, timestamp: new Date() } { id: Date.now(), role: 'assistant', content: `你好!我是 ${agent.name},你的 AI 助手。有什么我可以帮助你的吗?`, timestamp: new Date() }
] ]
// 保存助手欢迎消息
if (currentSessionId.value) {
await saveMessage('assistant', messages.value[0].content)
}
} }
// 选择群聊 // 选择群聊
const selectGroup = (group: GroupChat) => { const selectGroup = async (group: GroupChat) => {
// 创建新会话
const session = await createSession(group.name)
if (session) {
currentSessionId.value = session.id
}
messages.value = [ messages.value = [
{ id: 1, role: 'assistant', content: `你好!欢迎进入群聊 "${group.name}"${group.members.length} 位智能体已加入。`, timestamp: new Date() } { id: Date.now(), role: 'assistant', content: `你好!欢迎进入群聊 "${group.name}"${group.members.length} 位智能体已加入。`, timestamp: new Date() }
] ]
// 保存助手欢迎消息
if (currentSessionId.value) {
await saveMessage('assistant', messages.value[0].content)
}
} }
// 选择历史对话 // 选择历史对话

View File

@@ -0,0 +1,92 @@
.plan-page {
background-color: #0f1419;
}
.search-input {
background-color: #1f2937;
border: 1px solid #374151;
border-radius: 8px;
padding: 10px 12px 10px 36px;
color: white;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: #f97316;
}
.search-input::placeholder {
color: #6b7280;
}
.table-row {
border-top: 1px solid #2a2a3a;
transition: background-color 0.2s;
}
.table-row:hover {
background-color: rgba(255, 255, 255, 0.02);
}
.task-tag {
background-color: #374151;
color: #d1d5db;
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
}
.ml-13 {
margin-left: 3.25rem;
}
.btn-icon {
padding: 6px;
border-radius: 6px;
transition: all 0.2s;
}
.btn-icon:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.empty-box {
min-height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.empty-icon {
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2937, #111827);
border-radius: 20px;
margin-bottom: 16px;
color: #6b7280;
}
.empty-text {
font-size: 16px;
font-weight: 500;
color: white;
margin-bottom: 4px;
}
.empty-tip {
font-size: 14px;
color: #6b7280;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

View File

@@ -0,0 +1,87 @@
import { ref, computed } from 'vue'
export interface PlanTask {
id: number
name: string
status: 'running' | 'stopped'
triggerType: string
nextRun: string
lastRun: string
notifyChannel: string
executionCount: number
description: string
tags: string[]
}
export function usePlan() {
const tasks = ref<PlanTask[]>([
{
id: 1,
name: 'Human-like Heartbeat',
status: 'running',
triggerType: 'Interval 30 minutes',
nextRun: '2026/03/10 16:26',
lastRun: '2026/03/10 15:56',
notifyChannel: '-',
executionCount: 13,
description: 'Check if proactive messages need to be sent (greetings/reminders/follow-ups)',
tags: ['System Task', 'Agent Task', 'Interval']
},
{
id: 2,
name: 'Memory Organization',
status: 'running',
triggerType: 'Interval 3 hours',
nextRun: '2026/03/10 18:35',
lastRun: '2026/03/10 15:35',
notifyChannel: '-',
executionCount: 2,
description: 'Execute memory organization: organize chat history, extract key memories, refresh MEMORY.md',
tags: ['System Task', 'Agent Task', 'Interval']
},
{
id: 3,
name: 'System Self-Check',
status: 'running',
triggerType: 'Daily 04:00',
nextRun: '2026/03/11 04:00',
lastRun: 'Never',
notifyChannel: '-',
executionCount: 0,
description: 'Execute system self-check: analyze ERROR logs, try to fix tool issues, generate report',
tags: ['System Task', 'Agent Task', 'Daily']
},
])
const filterStatus = ref('all')
const searchQuery = ref('')
const filteredTasks = computed(() => {
let result = tasks.value
if (filterStatus.value !== 'all') {
result = result.filter(t => t.status === filterStatus.value)
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(t =>
t.name.toLowerCase().includes(query) ||
t.description.toLowerCase().includes(query)
)
}
return result
})
const getTaskCount = (status: string) => {
if (status === 'running') return tasks.value.filter(t => t.status === 'running').length
if (status === 'stopped') return tasks.value.filter(t => t.status === 'stopped').length
return tasks.value.length
}
return {
tasks,
filterStatus,
searchQuery,
filteredTasks,
getTaskCount,
}
}