Compare commits

..

13 Commits

Author SHA1 Message Date
3a4876ab00 fix: 修复Python模块导入错误并优化Chat功能
- 修复 core/agents/api 模块导入问题
- 优化 ChatInput 组件交互体验
- 增强 agent_handler 和 agent_service 功能
- 调整 Chat 页面样式和布局

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:27:07 +08:00
52a9d02342 fix: 右侧边栏仅在有会话时显示 2026-03-15 21:48:39 +08:00
b8944813cf feat: Chat 页面新增群聊功能入口 2026-03-15 21:47:45 +08:00
d9484f16c7 refactor: 简化 Chat 页面移除推荐智能体模块
- 移除 selectAgentAndCreateSession 方法
- 移除推荐智能体卡片区域
- 精简页面代码

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:44:20 +08:00
0e0f988264 feat: 增强 Agent 意图识别和上下文管理
- 新增 intent_router.py 意图路由模块
- 优化 context.py 上下文管理
- 增强 loop.py Agent 运行循环
- 更新 memory.py 记忆模块
- 修复 builtin.py 工具函数

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:44:00 +08:00
d72c6a3f25 feat: 优化 Chat 页面和聊天样式
- 新增 chat.css 聊天样式文件
- 优化 Chat.vue 页面交互
- 更新 chat.ts 聊天逻辑

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:43:37 +08:00
1e8e0533fd fix: 修复 AgentLoop 消息保存逻辑
- 同时保存 assistant 消息的 content 和 tool_calls
- 修复多轮工具调用场景下的消息丢失问题

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:52:10 +08:00
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
38 changed files with 2867 additions and 435 deletions

View File

@@ -25,3 +25,10 @@ WORKSPACE=./workspace
# Agent settings
MAX_ITERATIONS=10
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

@@ -36,6 +36,22 @@ Your workspace is at: {workspace_path}
- Be helpful and concise
- Think step by step when needed
- Ask for clarification when the request is ambiguous
## Tool Usage Guidelines
**IMPORTANT**: Only use tools when explicitly requested by the user:
**Use tools for**:
- Searching the web for current information
- Executing code or commands
- Reading or writing files
- Performing calculations
**DO NOT use tools for**:
- Simple questions and greetings (e.g., "介绍一下武汉", "你好", "什么是AI")
- General knowledge that you already know
- Conversational responses
For simple informational questions, respond directly from your knowledge without calling any tools.
"""
def build_messages(

View File

@@ -0,0 +1,278 @@
"""Intent recognition system for routing user requests."""
import json
import logging
from enum import Enum
from typing import Any
logger = logging.getLogger(__name__)
class IntentType(Enum):
"""Types of user intents."""
SIMPLE = "simple" # Simple Q&A, no tools needed
TOOL = "tool" # Needs tools (search, code, files, etc.)
SKILL = "skill" # Needs specific domain skill
TEAM = "team" # Needs multi-agent collaboration
UNKNOWN = "unknown" # Cannot determine
# Intent recognition prompt template
INTENT_PROMPT = """Analyze the user's message and classify their intent.
Intent Types:
- simple: General knowledge questions, greetings, casual conversation, simple Q&A
Examples: "你好", "介绍一下武汉", "什么是AI", "今天天气怎么样"
- tool: Requires external tools - web search, code execution, file operations, calculations
Examples: "搜索最新的AI新闻", "帮我运行这段代码", "读取文件内容", "计算这个表达式"
- skill: Requires specific domain skill (coding, design, analysis, etc.)
Examples: "用Python写一个排序算法", "分析这段代码的性能", "创建一个网页"
- team: Requires multiple agents working together
Examples: "让设计agent和开发agent一起完成这个任务", "创建一个团队来完成这个项目"
Guidelines:
- For greetings and simple questions, prefer "simple"
- Only use "tool" when user explicitly asks for search, execution, or file operations
- "introduce Wuhan" in Chinese is general knowledge - prefer "simple" unless user specifically asks for latest/current information
- If ambiguous, prefer "simple" to avoid unnecessary tool calls
User message: {message}
Respond with only the intent type (simple/tool/skill/team), no explanation:"""
class IntentRecognizer:
"""Recognizes user intent to route requests appropriately."""
def __init__(self, llm_provider=None):
"""Initialize intent recognizer.
Args:
llm_provider: LLM provider for intent recognition
"""
self._llm_provider = llm_provider
self._cache = {} # Simple cache for recent intents
def recognize(
self,
message: str,
available_tools: list[str] | None = None,
available_skills: list[str] | None = None,
) -> IntentType:
"""Recognize user intent.
Args:
message: User message
available_tools: List of available tool names
available_skills: List of available skill names
Returns:
Recognized intent type
"""
# Simple heuristics for common cases (fast path)
intent = self._heuristic_recognition(message)
if intent != IntentType.UNKNOWN:
logger.info(f"Intent recognized (heuristic): {intent.value} for message: {message[:50]}...")
return intent
# Use LLM for complex cases
if self._llm_provider:
return self._llm_recognition(message)
# Default to simple if no LLM
return IntentType.SIMPLE
def _heuristic_recognition(self, message: str) -> IntentType:
"""Fast heuristic-based intent recognition.
Args:
message: User message
Returns:
Recognized intent or UNKNOWN
"""
if not message:
return IntentType.UNKNOWN
message_lower = message.lower().strip()
# Greetings
greetings = ["你好", "hello", "hi", "", "您好", "hey"]
if any(g in message_lower for g in greetings) and len(message_lower) < 20:
return IntentType.SIMPLE
# Simple questions patterns
simple_patterns = [
"什么是", "什么叫", "什么是",
"介绍一下", "请介绍",
"解释一下", "解释",
"怎么样", "好不好",
"是什么意思",
"who are", "what is", "what's",
"tell me about",
]
# Check for simple patterns that don't require tools
for pattern in simple_patterns:
if pattern in message_lower:
# But exclude if explicitly asking for current/latest/real-time
if any(kw in message_lower for kw in ["最新", "现在", "current", "latest", "实时"]):
return IntentType.UNKNOWN # Might need web search
return IntentType.SIMPLE
# Explicit tool request patterns
tool_patterns = [
"搜索", "查找", "search",
"执行", "运行", "run",
"计算", "calculate",
"帮我写代码", "write code",
"读取", "读取", "read file",
"创建文件", "write file",
]
for pattern in tool_patterns:
if pattern in message_lower:
return IntentType.TOOL
# Skill patterns
skill_patterns = [
"用python", "用java", "用js",
"写一个算法", "实现",
"创建一个", "开发",
"分析", "优化",
]
for pattern in skill_patterns:
if pattern in message_lower:
return IntentType.SKILL
# Team patterns
team_patterns = [
"团队", "协作", "多个agent",
"team", "collaborate", "一起",
]
for pattern in team_patterns:
if pattern in message_lower:
return IntentType.TEAM
return IntentType.UNKNOWN
def _llm_recognition(self, message: str) -> IntentType:
"""LLM-based intent recognition.
Args:
message: User message
Returns:
Recognized intent type
"""
try:
prompt = INTENT_PROMPT.format(message=message)
# Use the LLM to classify intent
response = self._llm_provider.chat(
messages=[{"role": "user", "content": prompt}],
max_tokens=50,
)
content = response.content.strip().lower()
# Parse the response
if "simple" in content:
return IntentType.SIMPLE
elif "tool" in content:
return IntentType.TOOL
elif "skill" in content:
return IntentType.SKILL
elif "team" in content:
return IntentType.TEAM
else:
logger.warning(f"Unexpected intent response: {content}")
return IntentType.SIMPLE # Default to simple
except Exception as e:
logger.error(f"LLM intent recognition failed: {e}")
return IntentType.SIMPLE # Default to simple on error
class IntentRouter:
"""Routes requests based on recognized intent."""
def __init__(
self,
intent_recognizer: IntentRecognizer | None = None,
use_llm_recognition: bool = True,
):
"""Initialize intent router.
Args:
intent_recognizer: Intent recognizer instance
use_llm_recognition: Whether to use LLM for complex cases
"""
self._recognizer = intent_recognizer
self._use_llm = use_llm_recognition
def route(
self,
message: str,
available_tools: list[str] | None = None,
available_skills: list[str] | None = None,
) -> dict[str, Any]:
"""Route the user message based on intent.
Args:
message: User message
available_tools: List of available tool names
available_skills: List of available skill names
Returns:
Routing decision with intent type and suggested action
"""
# Recognize intent
intent = self._recognizer.recognize(
message,
available_tools,
available_skills,
)
# Build routing decision
decision = {
"intent": intent.value,
"action": self._get_action(intent),
"message": message,
}
logger.info(f"Routed message to {intent.value}: {message[:50]}...")
return decision
def _get_action(self, intent: IntentType) -> str:
"""Get the action to take based on intent.
Args:
intent: Recognized intent type
Returns:
Action name
"""
return {
IntentType.SIMPLE: "direct_response",
IntentType.TOOL: "execute_tools",
IntentType.SKILL: "execute_skill",
IntentType.TEAM: "team_collaboration",
IntentType.UNKNOWN: "direct_response", # Default to direct response
}.get(intent, "direct_response")
def create_intent_router(llm_provider=None) -> IntentRouter:
"""Create an intent router with default settings.
Args:
llm_provider: LLM provider for intent recognition
Returns:
Configured IntentRouter instance
"""
recognizer = IntentRecognizer(llm_provider=llm_provider)
return IntentRouter(intent_recognizer=recognizer)

View File

@@ -10,6 +10,7 @@ from typing import Any, Callable, Awaitable, AsyncGenerator
from agents.agent.context import ContextBuilder
from agents.agent.memory import AgentMemory
from agents.agent.intent_router import IntentRouter, create_intent_router, IntentType
from agents.llm import LLMProvider, LLMResponse, ProviderFactory
from agents.tools import ToolRegistry
@@ -28,6 +29,7 @@ class AgentLoop:
workspace: Path | None = None,
max_iterations: int = 10,
tools: ToolRegistry | None = None,
enable_intent_routing: bool = True,
):
"""Initialize the agent loop.
@@ -37,16 +39,24 @@ class AgentLoop:
workspace: Workspace directory for memory and configs
max_iterations: Maximum tool call iterations
tools: Tool registry (creates default if None)
enable_intent_routing: Enable intent recognition and routing
"""
self.provider = provider
self.model = model
self.workspace = workspace or Path.cwd()
self.max_iterations = max_iterations
self.tools = tools
self.enable_intent_routing = enable_intent_routing
self.context = ContextBuilder(self.workspace)
self.memory = AgentMemory(self.workspace)
# Initialize intent router
if enable_intent_routing:
self.intent_router = create_intent_router(llm_provider=provider)
else:
self.intent_router = None
async def chat(
self,
message: str,
@@ -79,6 +89,51 @@ class AgentLoop:
"""
history = history or []
# Intent recognition and routing
intent_decision = None
if self.intent_router and not history: # Only for first message in conversation
try:
tool_names = self.tools.tool_names if self.tools else []
intent_decision = self.intent_router.route(
message=message,
available_tools=tool_names,
)
logger.info(f"Intent recognized: {intent_decision['intent']} -> {intent_decision['action']}")
# For simple intent, respond directly without tool loop
if intent_decision["intent"] == IntentType.SIMPLE.value:
# Build messages for direct response
messages = self.context.build_messages(
history=history,
current_message=message,
)
# Call LLM without tools
response = await self.provider.chat_with_retry(
messages=messages,
tools=None, # No tools for simple requests
model=self.model,
)
content = self._strip_think(response.content) or "好的,让我来回答这个问题。"
# Save to history
self._save_history(session_key, messages, len(history))
return content
except Exception as e:
logger.warning(f"Intent routing failed: {e}, continuing with normal flow")
# 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:
# Merge any split assistant messages
loaded_history = self._merge_history_messages(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
if api_key or model_provider:
logger.info(f"Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}")
@@ -142,6 +197,52 @@ class AgentLoop:
Agent response content
"""
history = history or []
# Intent recognition and routing
intent_decision = None
if self.intent_router and not history: # Only for first message in conversation
try:
tool_names = self.tools.tool_names if self.tools else []
intent_decision = self.intent_router.route(
message=message,
available_tools=tool_names,
)
logger.info(f"Intent recognized: {intent_decision['intent']} -> {intent_decision['action']}")
# For simple intent, respond directly without tool loop
if intent_decision["intent"] == IntentType.SIMPLE.value:
# Build messages for direct response
messages = self.context.build_messages(
history=history,
current_message=message,
)
# Call LLM without tools
response = await self.provider.chat_with_retry(
messages=messages,
tools=None, # No tools for simple requests
model=self.model,
)
content = self._strip_think(response.content) or "好的,让我来回答这个问题。"
# Save to history
self._save_history(session_key, messages, len(history))
return content
except Exception as e:
logger.warning(f"Intent routing failed: {e}, continuing with normal flow")
# 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:
# Merge any split assistant messages
loaded_history = self._merge_history_messages(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
model = model or self.model
@@ -191,6 +292,18 @@ class AgentLoop:
"""
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
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}")
@@ -244,6 +357,19 @@ class AgentLoop:
Response content chunks
"""
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
model = model or self.model
@@ -284,6 +410,28 @@ class AgentLoop:
tool_defs = self.tools.get_definitions() if self.tools else []
# Intent recognition - determine if tools are needed before first LLM call
user_message = ""
for msg in messages:
if msg.get("role") == "user":
user_message = msg.get("content", "")
break
# Apply intent recognition on first iteration
if self.enable_intent_routing and self.intent_router and user_message:
available_tools = [t.get("function", {}).get("name", "") for t in tool_defs] if tool_defs else []
routing_decision = self.intent_router.route(
user_message,
available_tools=available_tools,
)
intent = routing_decision.get("intent", "simple")
logger.info(f"Intent recognized: {intent} for message: {user_message[:50]}...")
# If simple intent, don't pass tools to reduce unnecessary tool calls
if intent == "simple":
tool_defs = []
logger.info("Simple intent detected - disabling tool definitions for this request")
while iteration < self.max_iterations:
iteration += 1
@@ -373,6 +521,28 @@ class AgentLoop:
model = model or self.model
tool_defs = self.tools.get_definitions() if self.tools else []
# Intent recognition - determine if tools are needed before first LLM call
user_message = ""
for msg in initial_messages:
if msg.get("role") == "user":
user_message = msg.get("content", "")
break
# Apply intent recognition
if self.enable_intent_routing and self.intent_router and user_message:
available_tools = [t.get("function", {}).get("name", "") for t in tool_defs] if tool_defs else []
routing_decision = self.intent_router.route(
user_message,
available_tools=available_tools,
)
intent = routing_decision.get("intent", "simple")
logger.info(f"[stream] Intent recognized: {intent} for message: {user_message[:50]}...")
# If simple intent, don't pass tools to reduce unnecessary tool calls
if intent == "simple":
tool_defs = []
logger.info("[stream] Simple intent detected - disabling tool definitions")
# First call to check for tool calls
response = await provider.chat_with_retry(
messages=initial_messages,
@@ -440,6 +610,55 @@ class AgentLoop:
return f'{tc.name}("{val[:40]}...")' if len(val) > 40 else f'{tc.name}("{val}")'
return ", ".join(_fmt(tc) for tc in tool_calls)
@staticmethod
def _merge_history_messages(messages: list[dict]) -> list[dict]:
"""Merge adjacent assistant messages that have content and tool_calls separately.
When saving/loading history, assistant messages with both content and tool_calls
might be split into multiple entries. This method merges them back together.
Args:
messages: List of message dictionaries
Returns:
Merged list of messages
"""
if not messages:
return messages
merged = []
i = 0
while i < len(messages):
current = messages[i].copy()
# If current is an assistant message with tool_calls, check if next is
# an assistant message with content (or vice versa)
if current.get("role") == "assistant" and current.get("tool_calls"):
# Look ahead for another assistant message to merge with
j = i + 1
while j < len(messages):
next_msg = messages[j]
if next_msg.get("role") == "assistant":
# Merge content
if next_msg.get("content") and not current.get("content"):
current["content"] = next_msg.get("content")
# Merge tool_calls (should already be in current)
if next_msg.get("tool_calls") and not current.get("tool_calls"):
current["tool_calls"] = next_msg.get("tool_calls")
j += 1
else:
break
# If we merged multiple messages, skip them
if j > i + 1:
logger.debug(f"Merged {j - i} assistant messages")
i = j
else:
merged.append(current)
i += 1
return merged
def _save_history(
self,
session_key: str,
@@ -459,5 +678,27 @@ class AgentLoop:
if role == "user" and content:
self.memory.add_to_history("user", str(content)[:1000], session_key)
elif role == "assistant" and content:
self.memory.add_to_history("assistant", str(content)[:1000], session_key)
elif role == "assistant":
# Build a combined message with content and tool_calls
msg_data = {}
if content:
msg_data["content"] = str(content)[:1000]
if m.get("tool_calls"):
msg_data["tool_calls"] = m.get("tool_calls", [])
# Save as a single JSON message with all data
if msg_data:
msg_str = json.dumps(msg_data)
self.memory.add_to_history("assistant", msg_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,38 @@ class AgentMemory:
except:
pass
# Check if content contains tool_calls or tool_result markers, or is JSON
# 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:
# Check if it's a JSON object (new format with content + tool_calls)
try:
data = json.loads(content)
if isinstance(data, dict):
# New JSON format: might have content and/or tool_calls
if "content" in data:
entry_lines.append(f"内容: {data['content']}")
if "tool_calls" in data:
entry_lines.append(f"工具调用: {json.dumps(data['tool_calls'])}")
else:
entry_lines.append(f"内容: {content}")
except (json.JSONDecodeError, TypeError):
# Not JSON, treat as regular content
entry_lines.append(f"内容: {content}")
entry = "\n".join(entry_lines) + "\n\n"
with open(session_file, "a", encoding="utf-8") as f:
if header:
@@ -610,6 +640,31 @@ class AgentMemory:
current_message["timestamp"] = line.split(":", 1)[1].strip()
continue
# Parse "工具调用: xxx" - for tool_calls
if line.startswith("工具调用:") and current_message is not None:
tool_calls_json = line.split(":", 1)[1].strip()
try:
# Set role if not already set
if not current_message.get("role"):
current_message["role"] = "assistant"
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["role"] = "tool" # Set role to tool
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"
if line.startswith("内容:") and current_message is not None:
current_message["content"] = line.split(":", 1)[1].strip()
@@ -617,7 +672,7 @@ class AgentMemory:
# Content line
if current_message:
if current_message["content"]:
if current_message.get("content"):
current_message["content"] += "\n" + line
else:
current_message["content"] = line

View File

@@ -1,5 +1,5 @@
"""X-Agents API Module."""
from agents.api.routes import router
from .routes import router
__all__ = ["router"]

View File

@@ -20,7 +20,7 @@ class ChatRequest(BaseModel):
Fields aligned with server/internal/service/agent_service.go::AgentChatRequest
"""
agent_id: int
agent_id: str # 支持 UUID 字符串
message: str
user_id: int = 0
session_id: str | None = None
@@ -37,7 +37,7 @@ class ChatResponse(BaseModel):
Fields aligned with server/internal/service/agent_service.go::AgentChatResponse
"""
agent_id: int
agent_id: str # 支持 UUID 字符串
response: str
tool_calls: list = []
tokens_used: int = 0
@@ -209,7 +209,10 @@ async def chat_stream(request: ChatRequest):
Yields:
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:
logger.error("[chat_stream] 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())}"
@@ -217,6 +220,8 @@ async def chat_stream(request: ChatRequest):
async def generate() -> AsyncGenerator[str, None]:
"""Generate streaming response."""
try:
logger.info(f"[chat_stream] Starting stream for session: {session_id}")
# Prepare kwargs for agent.chat()
kwargs = {
"message": request.message,
@@ -225,28 +230,38 @@ async def chat_stream(request: ChatRequest):
if request.model_id:
kwargs["model_id"] = request.model_id
logger.info(f"[chat_stream] Using model_id: {request.model_id}")
if request.model_name:
kwargs["model_name"] = request.model_name
logger.info(f"[chat_stream] Using model_name: {request.model_name}")
if request.model_provider:
kwargs["model_provider"] = request.model_provider
logger.info(f"[chat_stream] Using model_provider: {request.model_provider}")
if 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:
kwargs["base_url"] = request.base_url
logger.info(f"[chat_stream] Using base_url: {request.base_url}")
if request.use_xbot:
kwargs["use_xbot"] = request.use_xbot
logger.info(f"[chat_stream] Using use_xbot: {request.use_xbot}")
# Process with streaming
chunk_count = 0
async for chunk in _agent.chat_stream(**kwargs):
# SSE format: "data: <json>\n\n"
yield f"data: {json.dumps(chunk)}\n\n"
chunk_count += 1
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
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:
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

26
core/agents/api/server.py Normal file
View File

@@ -0,0 +1,26 @@
"""X-Agents API Server."""
import sys
sys.path.insert(0, 'D:/Code/Project/X-Agents/core')
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .routes import router
app = FastAPI(title="X-Agents API")
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include the router
app.include_router(router)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)

View File

@@ -17,3 +17,7 @@ chromadb>=0.4.0
# Utilities
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
def create_default_registry() -> ToolRegistry:
def create_default_registry(use_sandbox: bool = False) -> ToolRegistry:
"""Create a tool registry with default tools.
Args:
use_sandbox: Whether to use sandbox for shell execution
Returns:
Tool registry with built-in tools
"""
registry = ToolRegistry()
# Register built-in tools
for tool in get_builtin_tools():
for tool in get_builtin_tools(use_sandbox=use_sandbox):
registry.register(tool)
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__ = [
"Tool",
"ToolRegistry",
@@ -48,4 +84,16 @@ __all__ = [
"CalculatorTool",
"GetTimeTool",
"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 json
import os
import re
from pathlib import Path
from typing import Any
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):
"""Read file contents."""
@@ -263,7 +275,7 @@ class WebSearchTool(Tool):
@property
def description(self) -> str:
return "Search the web for information using a search engine."
return "Search the web for current information, real-time data, or information that is not in your training data. **Only use this when the user explicitly asks for** latest news, current events, real-time information, or specifically requests a web search. **DO NOT use for simple questions** like '介绍一下武汉', '什么是AI' - answer from your knowledge instead."
@property
def parameters(self) -> dict[str, Any]:
@@ -361,8 +373,18 @@ class GetTimeTool(Tool):
class BashTool(Tool):
"""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._use_sandbox = use_sandbox
self._sandbox = None
if use_sandbox and SANDBOX_AVAILABLE:
self._sandbox = get_bwrap_sandbox()
@property
def name(self) -> str:
@@ -370,11 +392,13 @@ class BashTool(Tool):
@property
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."
@property
def parameters(self) -> dict[str, Any]:
return {
params = {
"type": "object",
"properties": {
"command": {"type": "string", "description": "Command to execute"},
@@ -386,8 +410,17 @@ class BashTool(Tool):
},
"required": ["command"],
}
return params
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:
process = await asyncio.create_subprocess_shell(
command,
@@ -410,11 +443,12 @@ class BashTool(Tool):
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.
Args:
workspace: Optional workspace path for file operations
use_sandbox: Whether to use sandbox for shell execution (recommended for untrusted code)
Returns:
List of Tool instances
@@ -427,5 +461,5 @@ def get_builtin_tools(workspace: Path | None = None) -> list[Tool]:
WebSearchTool(),
CalculatorTool(),
GetTimeTool(),
BashTool(workspace),
BashTool(workspace, use_sandbox=use_sandbox),
]

View File

@@ -14,22 +14,24 @@ logger = logging.getLogger(__name__)
class ToolManager:
"""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.
Args:
workspace: Optional workspace path
use_sandbox: Whether to use sandbox for shell execution (recommended for untrusted code)
"""
self.workspace = workspace
self.use_sandbox = use_sandbox
self.registry = ToolRegistry()
self._load_builtin_tools()
def _load_builtin_tools(self) -> None:
"""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:
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:
"""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)
mcpService := service.NewMCPService(mcpRepo)
skillService := service.NewSkillService(skillRepo)
agentService := service.NewAgentService(cfg.PythonServiceURL, modelRepo, agentRepo)
agentService := service.NewAgentService(cfg.PythonServiceURL, modelRepo, agentRepo, chatRepo)
memoryService := service.NewMemoryService(agentRepo)
// 4.2 初始化默认工具
@@ -387,7 +387,14 @@ func main() {
log.Println("Default tools initialized")
}
// 4.3 初始化 skills已禁用自动加载如需启用请调用 /skill/sync 接口)
// 4.3 初始化团队成员智能体
if err := agentService.InitTeamMembers(); err != nil {
log.Printf("Warning: Failed to init team members: %v", err)
} else {
log.Println("Team members initialized")
}
// 4.4 初始化 skills已禁用自动加载如需启用请调用 /skill/sync 接口)
// if err := skillService.InitSkills(); err != nil {
// log.Printf("Warning: Failed to init skills: %v", err)
// } else {
@@ -405,9 +412,9 @@ func main() {
toolHandler := handler.NewToolHandler(toolService)
mcpHandler := handler.NewMCPHandler(mcpService)
skillHandler := handler.NewSkillHandler(skillService)
agentHandler := handler.NewAgentHandler(agentService)
agentHandler := handler.NewAgentHandler(agentService, agentRepo)
memoryHandler := handler.NewMemoryHandler(memoryService)
sessionHandler := handler.NewSessionHandler(chatRepo)
sessionHandler := handler.NewSessionHandler(chatRepo, agentService)
// 初始化群聊服务
chatGroupRepo := repository.NewChatGroupRepository(db)
@@ -590,6 +597,7 @@ func main() {
{
agentGroup.GET("/list", agentHandler.ListAgents)
agentGroup.POST("/create", agentHandler.CreateAgent)
agentGroup.POST("/init-team", agentHandler.InitTeamMembers)
agentGroup.PUT("/:id/status", agentHandler.UpdateAgentStatus)
agentGroup.PUT("/:id", agentHandler.UpdateAgent)
agentGroup.DELETE("/:id", agentHandler.DeleteAgent)
@@ -608,6 +616,7 @@ func main() {
chatGroup.DELETE("/sessions/:id", sessionHandler.DeleteSession)
chatGroup.GET("/sessions/:id/messages", sessionHandler.GetMessages)
chatGroup.POST("/messages", sessionHandler.CreateMessage)
chatGroup.POST("/sessions/generate-title", sessionHandler.GenerateSessionTitle)
}
// 群聊管理模块

View File

@@ -1,5 +1,5 @@
# 本地开发配置
port: "8082"
port: "8080"
jwt_secret: "dev-secret-key"
# 数据库配置 (类型: mysql 或 sqlite)

View File

@@ -1,10 +1,15 @@
package handler
import (
"log"
"net/http"
"strconv"
"strings"
"x-agents/server/internal/model"
"x-agents/server/internal/repository"
"x-agents/server/internal/service"
"x-agents/server/internal/utils"
"github.com/gin-gonic/gin"
)
@@ -12,27 +17,30 @@ import (
// AgentHandler Agent 处理器
type AgentHandler struct {
agentService *service.AgentService
agentRepo *repository.AgentRepository
}
// NewAgentHandler 创建 Agent 处理器
func NewAgentHandler(agentService *service.AgentService) *AgentHandler {
func NewAgentHandler(agentService *service.AgentService, agentRepo *repository.AgentRepository) *AgentHandler {
return &AgentHandler{
agentService: agentService,
agentRepo: agentRepo,
}
}
// ChatRequest 对话请求
type ChatRequest struct {
AgentID string `json:"agent_id" binding:"required"` // 字符串类型
Message string `json:"message" binding:"required"`
SessionID string `json:"session_id"`
ModelID string `json:"model_id"`
UseXBot bool `json:"use_xbot"`
AgentID string `json:"agent_id"` // 字符串类型,支持 UUID可为空当使用 mentioned_agent_ids 时)
Message string `json:"message" binding:"required"`
SessionID string `json:"session_id"`
ModelID string `json:"model_id"`
UseXBot bool `json:"use_xbot"`
MentionedAgentIDs []string `json:"mentioned_agent_ids"` // @ 提及的智能体 ID 列表
}
// ChatResponse 对话响应
type ChatResponse struct {
AgentID int `json:"agent_id"`
AgentID string `json:"agent_id"` // 支持 UUID 字符串
Reply string `json:"reply"`
ToolsUsed []string `json:"tools_used"`
SessionID string `json:"session_id"`
@@ -73,11 +81,9 @@ func (h *AgentHandler) Chat(c *gin.Context) {
userIDStr := "1" // TODO: 从 c.Get("user_id") 获取
userID, _ := strconv.Atoi(userIDStr)
// 将前端传来的字符串 agent_id 转换为 int
agentID, _ := strconv.Atoi(req.AgentID)
// 直接使用字符串类型的 agent_id,支持 UUID
pythonReq := service.AgentChatRequest{
AgentID: agentID,
AgentID: req.AgentID,
Message: req.Message,
UserID: userID,
SessionID: req.SessionID,
@@ -130,8 +136,31 @@ func (h *AgentHandler) ChatStream(c *gin.Context) {
userIDStr := "1" // TODO: 从 c.Get("user_id") 获取
userID, _ := strconv.Atoi(userIDStr)
// 将前端传来的字符串 agent_id 转换为 int
agentID, _ := strconv.Atoi(req.AgentID)
// 直接使用字符串类型的 agent_id,支持 UUID
agentID := req.AgentID
// 优先使用前端传递的 mentioned_agent_ids
if len(req.MentionedAgentIDs) > 0 {
// 如果有多个 @ 提及,使用第一个
mentionedAgentID := req.MentionedAgentIDs[0]
log.Printf("[ChatStream] Using mentioned_agent_ids: %v", req.MentionedAgentIDs)
agentID = mentionedAgentID
// 清理消息,移除 @ 提及
mentionParser := utils.NewMentionParser()
req.Message = mentionParser.RemoveMentions(req.Message)
} else if agentID == "" {
// 兼容:解析消息中的 @ 提及(备用方案)
mentionParser := utils.NewMentionParser()
mentions := mentionParser.ParseMentions(req.Message)
if len(mentions) > 0 {
mentionedAgent := h.findAgentByName(mentions[0])
if mentionedAgent != nil {
log.Printf("[ChatStream] Detected @mention: %s, routing to agent: %s", mentions[0], mentionedAgent.ID)
agentID = mentionedAgent.ID
}
req.Message = mentionParser.RemoveMentions(req.Message)
}
}
// 构建 SSE 流
c.Header("Content-Type", "text/event-stream")
@@ -146,6 +175,37 @@ func (h *AgentHandler) ChatStream(c *gin.Context) {
}
}
// findAgentByName 根据用户名查找智能体
func (h *AgentHandler) findAgentByName(name string) *model.Agent {
log.Printf("[findAgentByName] Searching for agent: %s, agentRepo: %v", name, h.agentRepo)
if h.agentRepo == nil {
log.Printf("[findAgentByName] ERROR: agentRepo is nil!")
return nil
}
// 先尝试精确匹配
agents, err := h.agentRepo.FindAll()
if err != nil {
return nil
}
for _, agent := range agents {
if agent.Name == name {
return &agent
}
}
// 再尝试模糊匹配(忽略大小写)
for _, agent := range agents {
if strings.Contains(strings.ToLower(agent.Name), strings.ToLower(name)) {
return &agent
}
}
return nil
}
// TeamChatRequest 多智能体群聊请求
type TeamChatRequest struct {
SupervisorAgentID int `json:"supervisor_agent_id" binding:"required"`
@@ -238,6 +298,30 @@ func (h *AgentHandler) CreateAgent(c *gin.Context) {
c.JSON(http.StatusOK, result)
}
// InitTeamMembersResponse 初始化团队成员响应
type InitTeamMembersResponse struct {
Message string `json:"message"`
Count int `json:"count"`
}
// InitTeamMembers 初始化团队成员智能体
// @Summary 初始化团队成员智能体
// @Tags 智能体管理
// @Produce json
// @Success 200 {object} InitTeamMembersResponse
// @Router /api/agent/init-team [post]
func (h *AgentHandler) InitTeamMembers(c *gin.Context) {
if err := h.agentService.InitTeamMembers(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, InitTeamMembersResponse{
Message: "Team members initialized successfully",
Count: 1, // 小荣
})
}
// ListAgents 获取智能体列表
// @Summary 获取智能体列表
// @Tags 智能体管理
@@ -317,6 +401,7 @@ func (h *AgentHandler) DeleteAgent(c *gin.Context) {
type UpdateAgentRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Avatar string `json:"avatar"`
Skills []string `json:"skills"`
RoleDescription string `json:"role_description"`
ModelProvider string `json:"model_provider"`
@@ -345,7 +430,7 @@ func (h *AgentHandler) UpdateAgent(c *gin.Context) {
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return

View File

@@ -31,12 +31,18 @@ func (h *ChatGroupHandler) CreateGroup(c *gin.Context) {
return
}
// 从上下文获取用户ID
// 从上下文获取用户ID如果存在则覆盖请求中的user_id
userID, exists := c.Get("user_id")
if exists {
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)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})

View File

@@ -1,8 +1,10 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"strings"
"x-agents/server/internal/model"
"x-agents/server/internal/repository"
@@ -94,11 +96,12 @@ func (h *ChatHandler) CreateAgent(c *gin.Context) {
// SessionHandler 处理会话管理
type SessionHandler struct {
chatRepo *repository.ChatRepository
chatRepo *repository.ChatRepository
agentService *service.AgentService
}
func NewSessionHandler(chatRepo *repository.ChatRepository) *SessionHandler {
return &SessionHandler{chatRepo: chatRepo}
func NewSessionHandler(chatRepo *repository.ChatRepository, agentService *service.AgentService) *SessionHandler {
return &SessionHandler{chatRepo: chatRepo, agentService: agentService}
}
// CreateSession 创建会话
@@ -226,6 +229,16 @@ func (h *SessionHandler) CreateMessage(c *gin.Context) {
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)
if err != nil {
@@ -250,3 +263,65 @@ func (h *SessionHandler) CreateMessage(c *gin.Context) {
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"`
Description string `json:"description" gorm:"type:text"`
OwnerID string `json:"owner_id" gorm:"size:50;not null;index"`
Avatar string `json:"avatar" gorm:"size:50"` // 头像 (emoji)
// 技能列表(JSON数组)
Skills []string `json:"skills" gorm:"type:text;serializer:json"`

View File

@@ -52,7 +52,7 @@ type UpdateSessionRequest struct {
type CreateMessageRequest struct {
SessionID string `json:"session_id" binding:"required"`
Role string `json:"role" binding:"required"` // user/assistant
Content string `json:"content" binding:"required"`
Content string `json:"content"`
TokensUsed int `json:"tokens_used"`
DurationMs int `json:"duration_ms"`
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
}
// 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
// CreateMessage 创建消息

View File

@@ -18,7 +18,7 @@ import (
// AgentChatRequest Python Agent 对话请求
type AgentChatRequest struct {
AgentID int `json:"agent_id"`
AgentID string `json:"agent_id"` // 支持 UUID 字符串
Message string `json:"message"`
UserID int `json:"user_id"`
SessionID string `json:"session_id,omitempty"`
@@ -32,7 +32,7 @@ type AgentChatRequest struct {
// AgentChatResponse Python Agent 对话响应
type AgentChatResponse struct {
AgentID int `json:"agent_id"`
AgentID string `json:"agent_id"` // 支持 UUID 字符串
Response string `json:"response"`
ToolCalls []interface{} `json:"tool_calls"`
TokensUsed int `json:"tokens_used"`
@@ -66,10 +66,11 @@ type AgentService struct {
client *http.Client
modelRepo *repository.ModelRepository
agentRepo *repository.AgentRepository
chatRepo *repository.ChatRepository
}
// 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{
pythonURL: pythonURL,
client: &http.Client{
@@ -77,6 +78,7 @@ func NewAgentService(pythonURL string, modelRepo *repository.ModelRepository, ag
},
modelRepo: modelRepo,
agentRepo: agentRepo,
chatRepo: chatRepo,
}
}
@@ -115,7 +117,7 @@ func (s *AgentService) Chat(req AgentChatRequest) (*AgentChatResponse, error) {
log.Printf("[AgentService] Sending to Python: model_id=%s, api_key=%s, base_url=%s, provider=%s, model=%s",
req.ModelID, apiKeyPreview, req.BaseURL, req.ModelProvider, req.ModelName)
url := fmt.Sprintf("%s/api/v1/agent/chat", s.pythonURL)
url := fmt.Sprintf("%s/agent/chat", s.pythonURL)
jsonData, err := json.Marshal(req)
if err != nil {
@@ -153,7 +155,7 @@ func (s *AgentService) Chat(req AgentChatRequest) (*AgentChatResponse, error) {
// TeamChat 多智能体群聊
func (s *AgentService) TeamChat(req TeamChatRequest) (*TeamChatResponse, error) {
url := fmt.Sprintf("%s/api/v1/agent/team/chat", s.pythonURL)
url := fmt.Sprintf("%s/agent/team/chat", s.pythonURL)
// 设置默认策略
if req.Strategy == "" {
@@ -195,16 +197,19 @@ func (s *AgentService) TeamChat(req TeamChatRequest) (*TeamChatResponse, error)
}
// 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
ginCtx, ok := c.(*gin.Context)
if !ok {
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{}{
"agent_id": agentID,
"agent_id": agentID, // 传递字符串类型的 agent_id支持 UUID
"message": message,
"user_id": userID,
"session_id": sessionID,
@@ -228,7 +233,7 @@ func (s *AgentService) ChatStream(c interface{}, agentID int, message, sessionID
log.Printf("[ChatStream] modelID is empty or modelRepo is nil: modelID=%s, modelRepo=%v", modelID, s.modelRepo != nil)
}
streamURL := fmt.Sprintf("%s/api/v1/agent/chat/stream", s.pythonURL)
streamURL := fmt.Sprintf("%s/agent/chat/stream", s.pythonURL)
jsonData, err := json.Marshal(reqBody)
if err != nil {
@@ -267,8 +272,10 @@ func (s *AgentService) ChatStream(c interface{}, agentID int, message, sessionID
for {
n, err := resp.Body.Read(buf)
if n > 0 {
log.Printf("[ChatStream] Received %d bytes from Python", n)
_, writeErr := ginCtx.Writer.Write(buf[:n])
if writeErr != nil {
log.Printf("[ChatStream] Write error: %v", writeErr)
break
}
// 强制刷新到客户端
@@ -277,6 +284,7 @@ func (s *AgentService) ChatStream(c interface{}, agentID int, message, sessionID
}
}
if err != nil {
log.Printf("[ChatStream] Done reading from Python, err: %v", err)
break
}
}
@@ -300,9 +308,10 @@ type CreateAgentRequest struct {
// CreateAgentResponse 创建智能体响应
type CreateAgentResponse struct {
AgentID int `json:"agent_id"`
Name string `json:"name"`
Message string `json:"message"`
AgentID int `json:"agent_id"` // 保留兼容性
AgentIDStr string `json:"agent_id_str"` // 返回实际的 UUID
Name string `json:"name"`
Message string `json:"message"`
}
// CreateAgent 创建智能体
@@ -329,6 +338,7 @@ func (s *AgentService) CreateAgent(req CreateAgentRequest, userID int) (*CreateA
Name: req.Name,
Description: req.Description,
OwnerID: fmt.Sprintf("%d", userID),
Avatar: req.Avatar,
Skills: skills,
RoleDescription: req.Prompt,
ModelProvider: req.ModelProvider,
@@ -347,16 +357,97 @@ func (s *AgentService) CreateAgent(req CreateAgentRequest, userID int) (*CreateA
log.Printf("[AgentService] Agent created in database: %s (ID: %s)", agent.Name, agent.ID)
// 解析 agent ID 为整数返回
agentIDInt := int(time.Now().Unix()) % 100000
// 返回数据库中实际的 Agent ID (UUID字符串)
return &CreateAgentResponse{
AgentID: agentIDInt,
Name: agent.Name,
Message: "Agent created successfully",
AgentIDStr: agent.ID,
Name: agent.Name,
Message: "Agent created successfully",
}, nil
}
// TeamMemberInitRequest 团队成员初始化请求
type TeamMemberInitRequest struct {
Name string
Description string
Avatar string
Skills []string
RoleDescription string
}
// InitTeamMembers 初始化团队成员智能体
func (s *AgentService) InitTeamMembers() error {
if s.agentRepo == nil {
log.Printf("[AgentService] InitTeamMembers: agentRepo is nil!")
return fmt.Errorf("agent repository not initialized")
}
// 骚人开发组团队成员配置
teamMembers := []TeamMemberInitRequest{
{
Name: "小荣",
Description: "前端开发工程师 - 骚人开发组成员",
Avatar: "👨‍💻",
Skills: []string{"Vue 3", "TypeScript", "Element Plus", "Tailwind CSS"},
RoleDescription: `你叫小荣,是骚人开发组的前端开发工程师。你细心认真,善于沟通。
技能专长:
- Vue 3 框架开发
- TypeScript 类型系统
- Element Plus 组件库
- Tailwind CSS 样式框架
性格特点:
- 细心认真,注重代码质量
- 善于与团队成员沟通协作
- 积极解决前端技术难题`,
},
}
// 检查是否已存在同名智能体
for _, member := range teamMembers {
existingAgents, err := s.agentRepo.FindAll()
if err != nil {
log.Printf("[AgentService] InitTeamMembers: failed to list agents: %v", err)
continue
}
exists := false
for _, a := range existingAgents {
if a.Name == member.Name {
exists = true
log.Printf("[AgentService] InitTeamMembers: agent %s already exists, skipping", member.Name)
break
}
}
if !exists {
// 创建智能体
agent := &model.Agent{
ID: uuid.New().String(),
Name: member.Name,
Description: member.Description,
OwnerID: "1", // 系统管理员
Avatar: member.Avatar,
Skills: member.Skills,
RoleDescription: member.RoleDescription,
ModelProvider: "anthropic",
ModelName: "claude-sonnet-4-20250514",
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.agentRepo.Create(agent); err != nil {
log.Printf("[AgentService] InitTeamMembers: failed to create agent %s: %v", member.Name, err)
continue
}
log.Printf("[AgentService] InitTeamMembers: created agent %s (ID: %s)", member.Name, agent.ID)
}
}
return nil
}
// ListAgentsResponse 获取智能体列表响应
type ListAgentsResponse struct {
Agents []interface{} `json:"agents"`
@@ -429,6 +520,14 @@ func (s *AgentService) DeleteAgent(agentID string) error {
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 {
return fmt.Errorf("failed to delete agent: %w", err)
}
@@ -438,7 +537,7 @@ func (s *AgentService) DeleteAgent(agentID string) error {
}
// 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 {
return fmt.Errorf("agent repository not initialized")
}
@@ -458,6 +557,9 @@ func (s *AgentService) UpdateAgent(agentID, name, description string, skills []s
if description != "" {
agent.Description = description
}
if avatar != "" {
agent.Avatar = avatar
}
if skills != nil {
agent.Skills = skills
}

View File

@@ -18,7 +18,7 @@ html.dark {
}
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);
line-height: 1.5;
color: #f3f4f6;

View File

@@ -43,8 +43,8 @@
--spacing-xl: 24px;
--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-sm: 14px;
--font-size-md: 16px;

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ElMessageBox } from 'element-plus'
import type { Agent } from '@/views/chat/chat'
const props = defineProps<{
@@ -14,12 +15,32 @@ const emit = defineEmits<{
(e: 'toggleSelect', agent: Agent): void
(e: 'confirm'): void
(e: 'update:groupChatName', value: string): void
(e: 'delete', agent: Agent): void
}>()
// 点击智能体 - 只是选择,不直接确认
const handleAgentClick = (agent: 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>
<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="p-4 border-b border-dark-600">
<h3 class="text-lg font-semibold text-white">
{{ selectMode === 'single' ? '选择智能体' : '选择群聊成员' }}
{{ selectMode === 'single' ? '选择会话' : '选择群聊成员' }}
</h3>
<p class="text-sm text-gray-400 mt-1">
{{ selectMode === 'single' ? '选择一个智能体开始对话' : '选择多个智能体创建群聊' }}
{{ selectMode === 'single' ? '选择一个智能体开始对话' : '选择多个智能体创建群聊' }}
</p>
</div>
@@ -53,7 +74,7 @@ const handleAgentClick = (agent: Agent) => {
v-for="agent in chatAgents"
:key="agent.id"
@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)
? 'bg-orange-500/20 border border-orange-500/50'
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'"

View File

@@ -1,15 +1,137 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
const props = defineProps<{
modelValue: string
loading: boolean
agents?: { id: string | number; name: string; avatar: string }[]
mentionedAgents?: { id: string | number; name: string; avatar: string }[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'send'): void
(e: 'triggerMention'): void
(e: 'removeMention', agentId: string | number): void
}>()
const showMentionPopup = ref(false)
const lastAtPosition = ref(-1)
const selectedIndex = ref(0)
// 过滤后的智能体列表(排除已提及的)
const filteredAgents = computed(() => {
if (!props.agents) return []
const mentionedIds = props.mentionedAgents?.map(a => a.id) || []
return props.agents.filter(a => !mentionedIds.includes(a.id))
})
// 解析消息中的 @ 提及
const parseMentions = (text: string) => {
const mentions: { id: string | number; name: string; avatar: string }[] = []
const regex = /@(\S+)/g
let match
while ((match = regex.exec(text)) !== null) {
const name = match[1]
const agent = props.agents?.find(a => a.name === name)
if (agent && !mentions.find(m => m.id === agent.id)) {
mentions.push(agent)
}
}
return mentions
}
// 监听输入
const handleInput = (e: Event) => {
const target = e.target as HTMLTextAreaElement
const value = target.value
const cursorPos = target.selectionStart
emit('update:modelValue', value)
autoResize(e)
// 检测是否输入了 @
const textBeforeCursor = value.slice(0, cursorPos)
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
if (lastAtIndex !== -1) {
// 检查 @ 后面是否有空格或是否在单词中间
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1)
if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
showMentionPopup.value = true
lastAtPosition.value = lastAtIndex
selectedIndex.value = 0
emit('triggerMention')
} else {
showMentionPopup.value = false
}
} else {
showMentionPopup.value = false
}
}
// 选择智能体
const selectAgent = (agent: { id: string | number; name: string; avatar: string }) => {
if (!props.modelValue || lastAtPosition.value === -1) return
// 获取光标位置前的文本和后的文本
const beforeAt = props.modelValue.slice(0, lastAtPosition.value)
const afterCursor = props.modelValue.slice((document.querySelector('.chat-input-textarea') as HTMLTextAreaElement)?.selectionStart || 0)
// 替换 @xxx 为 @智能体名
const newValue = beforeAt + '@' + agent.name + ' ' + afterCursor
emit('update:modelValue', newValue)
showMentionPopup.value = false
lastAtPosition.value = -1
selectedIndex.value = 0
// 聚焦输入框
setTimeout(() => {
const textarea = document.querySelector('.chat-input-textarea') as HTMLTextAreaElement
if (textarea) {
textarea.focus()
}
}, 50)
}
// 移除提及
const removeMention = (agentId: string | number) => {
emit('removeMention', agentId)
}
const handleKeydown = (e: KeyboardEvent) => {
// @ 提及弹窗打开时处理方向键
if (showMentionPopup.value && filteredAgents.value.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
selectedIndex.value = (selectedIndex.value + 1) % filteredAgents.value.length
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
selectedIndex.value = selectedIndex.value === 0
? filteredAgents.value.length - 1
: selectedIndex.value - 1
return
}
if (e.key === 'Enter') {
e.preventDefault()
const agent = filteredAgents.value[selectedIndex.value]
if (agent) {
selectAgent(agent)
}
return
}
if (e.key === 'Escape') {
e.preventDefault()
showMentionPopup.value = false
return
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
emit('send')
@@ -26,6 +148,26 @@ const autoResize = (e: Event) => {
<template>
<div class="p-5 border-t border-white/[0.06] bg-[#0c0c0f]/60 backdrop-blur-xl">
<div class="max-w-3xl mx-auto">
<!-- 已提及的智能体显示 -->
<div v-if="mentionedAgents && mentionedAgents.length > 0" class="flex flex-wrap gap-2 mb-3">
<div
v-for="agent in mentionedAgents"
:key="agent.id"
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-orange-500/20 border border-orange-500/30 rounded-lg text-sm"
>
<span>{{ agent.avatar }}</span>
<span class="text-orange-400">@{{ agent.name }}</span>
<button
@click="removeMention(agent.id)"
class="ml-1 text-orange-400/60 hover:text-orange-400"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<div class="relative bg-[#12121a] rounded-2xl border border-white/[0.08] focus-within:border-orange-500/40 focus-within:shadow-[0_0_30px_rgba(249,115,22,0.08)] transition-all duration-300">
<!-- 附件按钮 -->
<button class="absolute left-4 top-1/2 -translate-y-1/2 text-white/25 hover:text-orange-400 transition-colors p-1">
@@ -37,13 +179,33 @@ const autoResize = (e: Event) => {
<!-- 输入框 -->
<textarea
:value="modelValue"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value); autoResize($event)"
@input="handleInput"
@keydown="handleKeydown"
placeholder="发送消息..."
placeholder="输入 @ 提及智能体..."
rows="1"
class="chat-input-textarea w-full bg-transparent text-white placeholder-white/25 py-4 pl-12 pr-28 resize-none focus:outline-none text-[15px]"
></textarea>
<!-- @ 提及弹窗 -->
<div
v-if="showMentionPopup && filteredAgents.length > 0"
class="absolute left-0 bottom-full mb-2 w-64 bg-[#1a1a24] border border-white/10 rounded-xl shadow-xl overflow-hidden z-50"
>
<div class="p-2">
<div class="text-xs text-white/40 px-2 py-1">选择智能体</div>
<div
v-for="(agent, index) in filteredAgents"
:key="agent.id"
@click="selectAgent(agent)"
class="flex items-center gap-2 px-2 py-2 rounded-lg cursor-pointer transition-colors"
:class="index === selectedIndex ? 'bg-orange-500/20' : 'hover:bg-white/5'"
>
<span class="text-lg">{{ agent.avatar }}</span>
<span class="text-white text-sm">{{ agent.name }}</span>
</div>
</div>
</div>
<!-- 发送按钮 -->
<button
@click="emit('send')"

View File

@@ -63,7 +63,12 @@ const copyMessage = async () => {
: 'bg-[#1e1e28] text-gray-100 rounded-bl-sm'"
>
<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">

View File

@@ -1,12 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessageBox } from 'element-plus'
import type { Agent, ChatSession, GroupChat } from '@/views/chat/chat'
defineProps<{
const props = defineProps<{
collapsed: boolean
chatAgents: Agent[]
selectedAgent: Agent | null
chatSessions: ChatSession[]
groupChats: GroupChat[]
groupChats?: GroupChat[]
}>()
const emit = defineEmits<{
@@ -14,8 +16,16 @@ const emit = defineEmits<{
(e: 'selectAgent', agent: Agent): void
(e: 'selectSession', session: ChatSession): 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 now = new Date()
const diff = now.getTime() - date.getTime()
@@ -27,6 +37,25 @@ const formatRelativeTime = (date: Date) => {
if (days < 7) return `${days}天前`
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>
<template>
@@ -69,47 +98,40 @@ const formatRelativeTime = (date: Date) => {
</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="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">
<button
v-for="group in groupChats"
:key="group.id"
@click="emit('selectGroup', group)"
v-for="session in chatSessions"
:key="session.id"
@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"
>
<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">
<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>
<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 class="text-xs text-white/30 mt-1 pl-6">{{ group.members.length }} members</div>
</button>
<div v-if="!chatSessions || chatSessions.length === 0" class="text-xs text-white/30 text-center py-4">
暂无会话记录
</div>
</div>
</div>
</div>

View File

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

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { ref, nextTick, watch, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useChat } from './chat/chat'
import ChatHeader from '@/components/chat/ChatHeader.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue'
@@ -45,19 +46,181 @@ const {
const messagesContainer = ref<HTMLElement | null>(null)
// Mock 流式响应(用于测试前端流式效果)
const mockStreamResponse = async (content: string, messageIndex: number) => {
const chars = content.split('')
for (let i = 0; i < chars.length; i++) {
messages.value[messageIndex].content += chars[i]
await nextTick()
scrollToBottom()
await new Promise(resolve => setTimeout(resolve, 15))
// @ 提及的智能体
const mentionedAgents = ref<{ id: string | number; name: string; avatar: string }[]>([])
// 触发 @ 提及
const onTriggerMention = () => {
// 可以在这里打开智能体选择弹窗,或显示提示
}
// 移除 @ 提及
const onRemoveMention = (agentId: string | number) => {
const index = mentionedAgents.value.findIndex(a => a.id === agentId)
if (index > -1) {
mentionedAgents.value.splice(index, 1)
}
// 从输入框中移除 @ 提及
const agent = chatAgents.value.find(a => a.id === agentId)
if (agent) {
inputMessage.value = inputMessage.value.replace(`@${agent.name}`, '')
}
}
// 构建 API 请求体
const buildRequestBody = (userContent: string) => {
const requestBody: any = {
agent_id: String(selectedAgent.value?.id || 1),
message: userContent,
}
if (selectedModel.value) {
requestBody.model_id = selectedModel.value.id
}
if (currentSessionId.value) {
requestBody.session_id = currentSessionId.value
}
// 添加 @ 提及的智能体 ID
if (mentionedAgents.value.length > 0) {
requestBody.mentioned_agent_ids = mentionedAgents.value.map(a => String(a.id))
}
return requestBody
}
// 解析流式响应数据
// 支持格式: data: "content" (JSON字符串) 或 data: {"content": "xxx"} (JSON对象)
const parseStreamData = (rawData: string): string => {
console.log('[Chat] parseStreamData 原始数据:', rawData)
if (!rawData || rawData === '[DONE]') return ''
try {
const parsed = JSON.parse(rawData)
console.log('[Chat] parseStreamData 解析结果:', parsed, '类型:', typeof parsed)
// 如果解析结果是字符串JSON字符串形式直接返回
if (typeof parsed === 'string') {
return parsed
}
// 如果是对象,尝试获取 content 或 delta.content
if (parsed && typeof parsed === 'object') {
// 兼容多种格式: content, delta.content, text, message.content
return parsed.content || parsed.delta?.content || parsed.text || parsed.message?.content || ''
}
return ''
} catch (e) {
console.error('[Chat] parseStreamData 解析错误:', e)
// 解析失败时,尝试直接返回原始数据(可能是未转义的纯文本)
if (rawData && rawData.length > 0) {
// 尝试移除首尾空格和引号
const trimmed = rawData.trim()
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1)
}
return trimmed
}
return ''
}
}
// 处理流式响应
const handleStreamResponse = async (response: Response) => {
console.log('[Chat] handleStreamResponse 开始处理流式响应, status:', response.status)
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 dataPart = line.slice(6).trim()
console.log('[Chat] 流式数据行:', line)
console.log('[Chat] 流式数据部分:', dataPart)
const content = parseStreamData(dataPart)
console.log('[Chat] 解析后内容:', content)
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
}
// 重置输入框
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 = () => {
if (messagesContainer.value) {
@@ -65,6 +228,11 @@ const scrollToBottom = () => {
}
}
// 监听消息变化,自动滚动到底部
watch(messages, () => {
nextTick(() => scrollToBottom())
}, { deep: true })
// 切换模型下拉框
const toggleModelDropdown = () => {
showModelDropdown.value = !showModelDropdown.value
@@ -76,133 +244,98 @@ const handleSelectModel = (model: any) => {
showModelDropdown.value = false
}
// 打开群聊选择器
const openGroupChat = () => {
openAgentSelector('group')
}
// 删除会话
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 () => {
if (!inputMessage.value.trim() || isLoading.value) return
// 如果没有会话,提示用户先选择智能体
if (!currentSessionId.value) {
ElMessage.warning('请先选择或创建一个会话')
return
}
const userContent = inputMessage.value.trim()
inputMessage.value = ''
mentionedAgents.value = []
resetInputHeight()
// 重置输入框高度
nextTick(() => {
const textarea = document.querySelector('.chat-input-textarea') as HTMLTextAreaElement
if (textarea) {
textarea.style.height = 'auto'
}
})
// 如果没有会话,创建一个新会话
if (!currentSessionId.value) {
await createSession()
}
const userMessage = {
id: Date.now(),
role: 'user' as const,
content: userContent,
timestamp: new Date()
}
const userMessage = createUserMessage(userContent)
messages.value.push(userMessage)
// 保存用户消息到后端
await saveMessage('user', userContent)
const aiMessage = {
id: Date.now() + 1,
role: 'assistant' as const,
content: '',
timestamp: new Date(),
isStreaming: true
}
const aiMessage = createAssistantMessage()
messages.value.push(aiMessage)
nextTick(() => scrollToBottom())
isLoading.value = true
try {
const requestBody: any = {
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`, {
const response = await fetch('/api/agent/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildRequestBody(userContent)),
})
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
// 真正的流式处理:边读取边显示
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)
await handleStreamResponse(response)
} catch (error: any) {
console.error('[Stream] 错误:', 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
handleMessageError(error)
}
}
// 初始化
onMounted(() => {
console.log('[Chat] Component mounted, calling init()')
init()
})
@@ -217,6 +350,7 @@ onUnmounted(() => {
<div class="flex-1 flex flex-col bg-[#09090b]">
<!-- 顶部栏 -->
<ChatHeader
v-if="currentSessionId"
:selected-agent="selectedAgent"
:chat-models="chatModels"
:selected-model="selectedModel"
@@ -231,8 +365,26 @@ onUnmounted(() => {
<!-- 消息区域 -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto py-4">
<!-- 空状态欢迎提示 -->
<div v-if="messages.length === 0" class="h-full flex items-center justify-center">
<!-- 无会话时显示引导界面 -->
<div v-if="!currentSessionId" class="h-full flex items-center justify-center empty-chat">
<div class="text-center" style="position: relative; z-index: 1;">
<div class="empty-logo">🧠</div>
<h2 class="empty-title">欢迎使用 X-Agents</h2>
<p class="empty-desc">与智能 AI 助手对话获取专业解答与创意灵感</p>
<div class="flex gap-4 justify-center">
<button @click="newChat" class="empty-btn">
<i class="fa-solid fa-plus mr-2"></i>
开始新对话
</button>
<button @click="openGroupChat" class="empty-btn empty-btn-secondary">
<i class="fa-solid fa-users mr-2"></i>
开始群聊
</button>
</div>
</div>
</div>
<!-- 有会话但无消息时显示欢迎提示 -->
<div v-else-if="messages.length === 0" class="h-full flex items-center justify-center">
<div class="text-center">
<div class="text-5xl mb-4">{{ selectedAgent?.avatar || '🧠' }}</div>
<h2 class="text-xl font-semibold text-white mb-2"> {{ selectedAgent?.name || 'AI' }} 开始对话</h2>
@@ -250,16 +402,22 @@ onUnmounted(() => {
</div>
</div>
<!-- 输入区域 -->
<!-- 输入区域 - 仅在有会话时显示 -->
<ChatInput
v-if="currentSessionId"
v-model="inputMessage"
:loading="isLoading"
:agents="chatAgents"
:mentioned-agents="mentionedAgents"
@send="sendMessage"
@trigger-mention="onTriggerMention"
@remove-mention="onRemoveMention"
/>
</div>
<!-- 右侧边栏 -->
<!-- 右侧边栏 - 仅在有会话时显示 -->
<ChatSidebar
v-if="currentSessionId"
:collapsed="sidebarCollapsed"
:chat-agents="chatAgents"
:selected-agent="selectedAgent"
@@ -269,6 +427,7 @@ onUnmounted(() => {
@select-agent="selectAgent"
@select-session="selectSession"
@select-group="selectGroup"
@delete-session="handleDeleteSession"
/>
<!-- 智能体选择弹窗 -->

View File

@@ -1,70 +1,13 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
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 tasks = ref([
{
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') // 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
}
const {
filterStatus,
searchQuery,
filteredTasks,
} = usePlan()
</script>
<template>
@@ -170,99 +113,3 @@ const getTaskCount = (status: string) => {
</div>
</div>
</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(', ')
agents.value.unshift({
id: result.agent_id,
id: result.agent_id_str || result.agent_id,
name: newAgent.value.name,
avatar: newAgent.value.avatar,
description: newAgent.value.description,
@@ -341,6 +341,7 @@ async function saveEdit() {
body: JSON.stringify({
name: editingAgent.value.name,
description: editingAgent.value.description,
avatar: editingAgent.value.avatar,
skills: skills,
role_description: editingAgent.value.prompt,
model_provider: selectedModel?.provider || '',
@@ -353,6 +354,7 @@ async function saveEdit() {
if (agent) {
agent.name = editingAgent.value.name
agent.description = editingAgent.value.description
agent.avatar = editingAgent.value.avatar
agent.skills = editingAgent.value.skillsMode === 'all' ? '*' : editingAgent.value.selectedSkills.join(', ')
agent.model = selectedModel?.name || ''
}

View File

@@ -49,3 +49,159 @@
.agent-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
/* 空会话页面样式 */
.empty-chat {
position: relative;
overflow: hidden;
}
.empty-chat::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle at 30% 30%, rgba(249, 115, 22, 0.08) 0%, transparent 50%),
radial-gradient(circle at 70% 70%, rgba(239, 68, 68, 0.06) 0%, transparent 50%);
animation: bgFloat 20s ease-in-out infinite;
pointer-events: none;
}
@keyframes bgFloat {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
50% { transform: translate(-2%, -2%) rotate(1deg); }
}
.empty-logo {
width: 100px;
height: 100px;
margin: 0 auto 24px;
background: linear-gradient(135deg, rgba(249, 115, 22, 0.2), rgba(239, 68, 68, 0.1));
border-radius: 28px;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
animation: logoFloat 3s ease-in-out infinite;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
@keyframes logoFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.empty-title {
font-size: 28px;
font-weight: 600;
background: linear-gradient(135deg, #fff 0%, rgba(255, 255, 255, 0.7) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 12px;
}
.empty-desc {
color: rgba(255, 255, 255, 0.5);
font-size: 15px;
margin-bottom: 32px;
max-width: 360px;
}
.empty-btn {
padding: 14px 32px;
font-size: 15px;
font-weight: 500;
border-radius: 12px;
background: linear-gradient(135deg, #f97316 0%, #ef4444 100%);
color: white;
border: none;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 8px 24px rgba(249, 115, 22, 0.3);
}
.empty-btn:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(249, 115, 22, 0.4);
}
.empty-btn:active {
transform: translateY(0);
}
.empty-btn-secondary {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: none;
}
.empty-btn-secondary:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: none;
}
/* 推荐智能体卡片 */
.recommend-section {
margin-top: 48px;
}
.recommend-title {
font-size: 13px;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 1.5px;
margin-bottom: 16px;
}
.recommend-cards {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
.recommend-card {
width: 160px;
padding: 20px 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
}
.recommend-card:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(249, 115, 22, 0.3);
transform: translateY(-4px);
}
.recommend-avatar {
width: 52px;
height: 52px;
margin: 0 auto 12px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
}
.recommend-name {
font-size: 14px;
font-weight: 500;
color: white;
margin-bottom: 4px;
}
.recommend-desc {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
line-height: 1.4;
}

View File

@@ -20,7 +20,7 @@ export interface ChatMessage {
}
export interface Agent {
id: number
id: string | number
name: string
avatar: string
description: string
@@ -40,7 +40,7 @@ export interface ChatSession {
}
export interface GroupChat {
id: number
id: string | number
name: string
members: string[]
lastMessage: string
@@ -82,7 +82,6 @@ export const renderMarkdown = (content: string): string => {
const processed = preprocessContent(content)
return marked.parse(processed) as string
} catch (e) {
console.error('Markdown parse error:', e)
return content
}
}
@@ -150,24 +149,21 @@ export function useChat() {
try {
const response = await fetch(`/model/list`)
const data = await response.json()
console.log('[Chat] Raw models:', data.list)
if (data.list) {
// 只过滤出 active 的 chat 模型 (status: 1=active, 0=inactive)
const activeChatModels = data.list.filter((m: ChatModel) =>
m.model_type === 'chat' && m.status === 1
)
console.log('[Chat] Filtered chat models:', activeChatModels)
chatModels.value = activeChatModels
// 默认选中第一个 active 的 chat 模型
if (chatModels.value.length > 0) {
selectedModel.value = chatModels.value[0]
console.log('[Chat] Selected model:', selectedModel.value)
}
}
} catch (error) {
console.error('Failed to fetch models:', error)
} catch {
// 静默处理
} finally {
modelsLoading.value = false
}
@@ -178,7 +174,6 @@ export function useChat() {
try {
const response = await fetch('/api/agent/list')
const data = await response.json()
console.log('[Chat] Agents:', data)
if (data.agents) {
chatAgents.value = data.agents.map((agent: any) => ({
@@ -186,9 +181,9 @@ export function useChat() {
name: agent.name,
avatar: agent.avatar || '🧠',
description: agent.description || '',
accentColor: agent.accent_color || '#f97316',
accentColor: '#f97316',
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]
}
}
} catch (error) {
console.error('Failed to fetch agents:', error)
} catch {
// 静默处理
}
}
@@ -207,7 +202,6 @@ export function useChat() {
const userId = localStorage.getItem('user_id') || 'default-user'
const response = await fetch(`/api/chat/sessions?user_id=${userId}&limit=50`)
const data = await response.json()
console.log('[Chat] Sessions:', data)
if (data.list) {
chatSessions.value = data.list.map((s: any) => ({
@@ -219,9 +213,13 @@ export function useChat() {
timestamp: new Date(s.created_at || Date.now()),
status: s.status
}))
// 自动选择最近的会话并加载消息
// 页面加载时不自动选择会话,显示空页面
// 用户点击"新建聊天"或选择智能体时才创建会话
}
} catch (error) {
console.error('Failed to fetch sessions:', error)
} catch {
// 静默处理
}
}
@@ -231,7 +229,6 @@ export function useChat() {
const userId = localStorage.getItem('user_id') || 'default-user'
const response = await fetch(`/api/chat/groups?user_id=${userId}`)
const data = await response.json()
console.log('[Chat] Groups:', data)
if (data.list) {
groupChats.value = data.list.map((g: any) => ({
@@ -242,13 +239,13 @@ export function useChat() {
timestamp: new Date(g.created_at || Date.now())
}))
}
} catch (error) {
console.error('Failed to fetch groups:', error)
} catch {
// 静默处理
}
}
// 创建群聊
const createGroup = async (name: string, agentIds: number[]) => {
const createGroup = async (name: string, agentIds: (string | number)[]) => {
try {
const userId = localStorage.getItem('user_id') || 'default-user'
const response = await fetch('/api/chat/groups', {
@@ -260,8 +257,12 @@ export function useChat() {
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()
console.log('[Chat] Created group:', group)
// 添加到群聊列表
groupChats.value.unshift({
@@ -273,8 +274,7 @@ export function useChat() {
})
return group
} catch (error) {
console.error('Failed to create group:', error)
} catch {
return null
}
}
@@ -293,8 +293,12 @@ export function useChat() {
model_id: selectedModel.value?.id
})
})
if (!response.ok) {
return null
}
const session = await response.json()
console.log('[Chat] Created session:', session)
// 添加到会话列表
chatSessions.value.unshift({
@@ -305,10 +309,9 @@ export function useChat() {
timestamp: new Date()
})
currentSessionId.value = session.id
saveSessionId(session.id)
return session
} catch (error) {
console.error('Failed to create session:', error)
} catch {
return null
}
}
@@ -318,7 +321,6 @@ export function useChat() {
try {
const response = await fetch(`/api/chat/sessions/${sessionId}/messages?limit=100`)
const data = await response.json()
console.log('[Chat] Messages:', data)
if (data.list) {
messages.value = data.list.map((m: any) => ({
@@ -328,27 +330,37 @@ export function useChat() {
timestamp: new Date(m.created_at)
}))
}
} catch (error) {
console.error('Failed to fetch messages:', error)
} catch {
// 静默处理
}
}
// 保存消息到后端
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 {
await fetch('/api/chat/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: currentSessionId.value,
role: role,
content: content
})
body: JSON.stringify(payload)
})
} catch (error) {
console.error('Failed to save message:', error)
} catch {
// 静默处理
}
}
@@ -360,8 +372,8 @@ export function useChat() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
})
} catch (error) {
console.error('Failed to update session:', error)
} catch {
// 静默处理
}
}
@@ -376,8 +388,8 @@ export function useChat() {
currentSessionId.value = null
messages.value = []
}
} catch (error) {
console.error('Failed to delete session:', error)
} catch {
// 静默处理
}
}
@@ -433,7 +445,24 @@ export function useChat() {
// 调用后端 API 创建群聊
const group = await createGroup(name, agentIds)
if (!group) {
if (group) {
// 创建成功,刷新群聊列表
await fetchGroups()
// 创建群聊会话
const session = await createSession(name)
if (session) {
saveSessionId(session.id)
// 显示群聊欢迎消息
messages.value = [
{ id: Date.now(), role: 'assistant', content: `你好!欢迎进入群聊 "${name}"${selectedAgents.value.length} 位智能体已加入。`, timestamp: new Date() }
]
// 保存欢迎消息
await saveMessage('assistant', messages.value[0].content)
}
} else {
// 如果 API 调用失败,使用本地数据
groupChats.value.unshift({
id: Date.now(),
@@ -452,34 +481,82 @@ export function useChat() {
showAgentSelector.value = false
}
// 选择助手
const selectAgent = (agent: Agent) => {
// 选择助手 - 如果是同一智能体则不创建新会话
const selectAgent = async (agent: Agent) => {
// 如果选择的是同一智能体,不创建新会话,直接返回
if (selectedAgent.value?.id === agent.id) {
return
}
selectedAgent.value = agent
// 创建新会话
const session = await createSession(`${agent.name} 的对话`)
if (session) {
currentSessionId.value = session.id
}
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 = [
{ 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)
}
}
// 选择历史对话
// 保存会话 ID 到 localStorage
const saveSessionId = (sessionId: string) => {
localStorage.setItem('current_session_id', sessionId)
currentSessionId.value = sessionId
}
// 从 localStorage 恢复会话
const restoreSession = async () => {
const savedSessionId = localStorage.getItem('current_session_id')
if (!savedSessionId) return
const session = chatSessions.value.find(s => s.id === savedSessionId)
if (session) {
await selectSession(session)
}
}
const selectSession = async (session: ChatSession) => {
const agent = chatAgents.value.find(a => a.id === session.agent_id)
if (agent) {
selectedAgent.value = agent
}
currentSessionId.value = session.id
saveSessionId(session.id)
await fetchSessionMessages(session.id)
}
// 新建聊天 - 先打开智能体选择器
const newChat = () => {
// 清除当前会话 ID新建会话时会重新设置
currentSessionId.value = null
localStorage.removeItem('current_session_id')
// 打开智能体选择器,让用户选择智能体
openAgentSelector('single')
}
@@ -521,12 +598,15 @@ export function useChat() {
}
// 初始化
const init = () => {
const init = async () => {
fetchModels()
fetchAgents()
fetchSessions()
await fetchSessions()
fetchGroups()
document.addEventListener('click', handleClickOutside)
// 恢复之前选中的会话
await restoreSession()
}
// 清理

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,
}
}