Compare commits
13 Commits
a35a88effc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a4876ab00 | |||
| 52a9d02342 | |||
| b8944813cf | |||
| d9484f16c7 | |||
| 0e0f988264 | |||
| d72c6a3f25 | |||
| 1e8e0533fd | |||
| 20f2ea8c38 | |||
| c9f19f43fb | |||
| 6b1258e9ca | |||
| 1afa88e812 | |||
| 31f0feafb5 | |||
| bce8b9240b |
@@ -25,3 +25,10 @@ WORKSPACE=./workspace
|
|||||||
# Agent settings
|
# Agent settings
|
||||||
MAX_ITERATIONS=10
|
MAX_ITERATIONS=10
|
||||||
TEMPERATURE=0.7
|
TEMPERATURE=0.7
|
||||||
|
|
||||||
|
# Sandbox Configuration (optional)
|
||||||
|
# Enable sandbox mode for secure code execution (bwrap/gvisor)
|
||||||
|
# SANDBOX_TYPE=bwrap # Options: bwrap, gvisor, none
|
||||||
|
# SANDBOX_TIMEOUT=60 # Default timeout in seconds
|
||||||
|
# GVISCOR_RUNSC_PATH=runsc # Path to gVisor runsc binary
|
||||||
|
# BWRAP_PATH=bwrap # Path to bwrap binary
|
||||||
|
|||||||
@@ -36,6 +36,22 @@ Your workspace is at: {workspace_path}
|
|||||||
- Be helpful and concise
|
- Be helpful and concise
|
||||||
- Think step by step when needed
|
- Think step by step when needed
|
||||||
- Ask for clarification when the request is ambiguous
|
- 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(
|
def build_messages(
|
||||||
|
|||||||
278
core/agents/agent/intent_router.py
Normal file
278
core/agents/agent/intent_router.py
Normal 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)
|
||||||
@@ -10,6 +10,7 @@ from typing import Any, Callable, Awaitable, AsyncGenerator
|
|||||||
|
|
||||||
from agents.agent.context import ContextBuilder
|
from agents.agent.context import ContextBuilder
|
||||||
from agents.agent.memory import AgentMemory
|
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.llm import LLMProvider, LLMResponse, ProviderFactory
|
||||||
from agents.tools import ToolRegistry
|
from agents.tools import ToolRegistry
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ class AgentLoop:
|
|||||||
workspace: Path | None = None,
|
workspace: Path | None = None,
|
||||||
max_iterations: int = 10,
|
max_iterations: int = 10,
|
||||||
tools: ToolRegistry | None = None,
|
tools: ToolRegistry | None = None,
|
||||||
|
enable_intent_routing: bool = True,
|
||||||
):
|
):
|
||||||
"""Initialize the agent loop.
|
"""Initialize the agent loop.
|
||||||
|
|
||||||
@@ -37,16 +39,24 @@ class AgentLoop:
|
|||||||
workspace: Workspace directory for memory and configs
|
workspace: Workspace directory for memory and configs
|
||||||
max_iterations: Maximum tool call iterations
|
max_iterations: Maximum tool call iterations
|
||||||
tools: Tool registry (creates default if None)
|
tools: Tool registry (creates default if None)
|
||||||
|
enable_intent_routing: Enable intent recognition and routing
|
||||||
"""
|
"""
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.model = model
|
self.model = model
|
||||||
self.workspace = workspace or Path.cwd()
|
self.workspace = workspace or Path.cwd()
|
||||||
self.max_iterations = max_iterations
|
self.max_iterations = max_iterations
|
||||||
self.tools = tools
|
self.tools = tools
|
||||||
|
self.enable_intent_routing = enable_intent_routing
|
||||||
|
|
||||||
self.context = ContextBuilder(self.workspace)
|
self.context = ContextBuilder(self.workspace)
|
||||||
self.memory = AgentMemory(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(
|
async def chat(
|
||||||
self,
|
self,
|
||||||
message: str,
|
message: str,
|
||||||
@@ -79,6 +89,51 @@ class AgentLoop:
|
|||||||
"""
|
"""
|
||||||
history = history or []
|
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
|
# Check if dynamic provider parameters are provided
|
||||||
if api_key or model_provider:
|
if api_key or model_provider:
|
||||||
logger.info(f"Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}")
|
logger.info(f"Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}")
|
||||||
@@ -142,6 +197,52 @@ class AgentLoop:
|
|||||||
Agent response content
|
Agent response content
|
||||||
"""
|
"""
|
||||||
history = history or []
|
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
|
provider = provider or self.provider
|
||||||
model = model or self.model
|
model = model or self.model
|
||||||
|
|
||||||
@@ -191,6 +292,18 @@ class AgentLoop:
|
|||||||
"""
|
"""
|
||||||
history = history or []
|
history = history or []
|
||||||
|
|
||||||
|
# Load history from session if session_key is provided
|
||||||
|
if session_key and session_key != "default":
|
||||||
|
loaded_history = self.memory.get_history(session_key, max_messages=20)
|
||||||
|
if loaded_history:
|
||||||
|
logger.info(f"[stream] Loaded {len(loaded_history)} messages from session history")
|
||||||
|
# Merge loaded history with provided history (loaded takes precedence if empty)
|
||||||
|
if not history:
|
||||||
|
history = loaded_history
|
||||||
|
else:
|
||||||
|
# Append loaded history before current messages
|
||||||
|
history = loaded_history + history
|
||||||
|
|
||||||
# Check if dynamic provider parameters are provided
|
# Check if dynamic provider parameters are provided
|
||||||
if api_key or model_provider:
|
if api_key or model_provider:
|
||||||
logger.info(f"[stream] Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}")
|
logger.info(f"[stream] Using dynamic provider: model_provider={model_provider}, model_name={model_name}, base_url={base_url}")
|
||||||
@@ -244,6 +357,19 @@ class AgentLoop:
|
|||||||
Response content chunks
|
Response content chunks
|
||||||
"""
|
"""
|
||||||
history = history or []
|
history = history or []
|
||||||
|
|
||||||
|
# Load history from session if session_key is provided
|
||||||
|
if session_key and session_key != "default":
|
||||||
|
loaded_history = self.memory.get_history(session_key, max_messages=20)
|
||||||
|
if loaded_history:
|
||||||
|
logger.info(f"[stream] Loaded {len(loaded_history)} messages from session history")
|
||||||
|
# Merge loaded history with provided history (loaded takes precedence if empty)
|
||||||
|
if not history:
|
||||||
|
history = loaded_history
|
||||||
|
else:
|
||||||
|
# Append loaded history before current messages
|
||||||
|
history = loaded_history + history
|
||||||
|
|
||||||
provider = provider or self.provider
|
provider = provider or self.provider
|
||||||
model = model or self.model
|
model = model or self.model
|
||||||
|
|
||||||
@@ -284,6 +410,28 @@ class AgentLoop:
|
|||||||
|
|
||||||
tool_defs = self.tools.get_definitions() if self.tools else []
|
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:
|
while iteration < self.max_iterations:
|
||||||
iteration += 1
|
iteration += 1
|
||||||
|
|
||||||
@@ -373,6 +521,28 @@ class AgentLoop:
|
|||||||
model = model or self.model
|
model = model or self.model
|
||||||
tool_defs = self.tools.get_definitions() if self.tools else []
|
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
|
# First call to check for tool calls
|
||||||
response = await provider.chat_with_retry(
|
response = await provider.chat_with_retry(
|
||||||
messages=initial_messages,
|
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 f'{tc.name}("{val[:40]}...")' if len(val) > 40 else f'{tc.name}("{val}")'
|
||||||
return ", ".join(_fmt(tc) for tc in tool_calls)
|
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(
|
def _save_history(
|
||||||
self,
|
self,
|
||||||
session_key: str,
|
session_key: str,
|
||||||
@@ -459,5 +678,27 @@ class AgentLoop:
|
|||||||
|
|
||||||
if role == "user" and content:
|
if role == "user" and content:
|
||||||
self.memory.add_to_history("user", str(content)[:1000], session_key)
|
self.memory.add_to_history("user", str(content)[:1000], session_key)
|
||||||
elif role == "assistant" and content:
|
elif role == "assistant":
|
||||||
self.memory.add_to_history("assistant", str(content)[:1000], session_key)
|
# 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)
|
||||||
|
|||||||
@@ -537,8 +537,38 @@ class AgentMemory:
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Check if content contains tool_calls or tool_result markers, or is JSON
|
||||||
# Format as Markdown (产品经理指定格式)
|
# Format as Markdown (产品经理指定格式)
|
||||||
entry = f"## 消息 {msg_count}\n角色: {role}\n时间: {display_timestamp}\n内容: {content}\n\n"
|
entry_lines = [
|
||||||
|
f"## 消息 {msg_count}",
|
||||||
|
f"角色: {role}",
|
||||||
|
f"时间: {display_timestamp}",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Handle tool_calls and tool_result content
|
||||||
|
if content.startswith("[tool_calls]"):
|
||||||
|
entry_lines.append(f"工具调用: {content[len('[tool_calls]'):]}")
|
||||||
|
entry_lines.append(f"内容: ")
|
||||||
|
elif content.startswith("[tool_result]"):
|
||||||
|
entry_lines.append(f"工具结果: {content[len('[tool_result]'):]}")
|
||||||
|
entry_lines.append(f"内容: ")
|
||||||
|
else:
|
||||||
|
# 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:
|
with open(session_file, "a", encoding="utf-8") as f:
|
||||||
if header:
|
if header:
|
||||||
@@ -610,6 +640,31 @@ class AgentMemory:
|
|||||||
current_message["timestamp"] = line.split(":", 1)[1].strip()
|
current_message["timestamp"] = line.split(":", 1)[1].strip()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Parse "工具调用: xxx" - for tool_calls
|
||||||
|
if line.startswith("工具调用:") and current_message is not None:
|
||||||
|
tool_calls_json = line.split(":", 1)[1].strip()
|
||||||
|
try:
|
||||||
|
# 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"
|
# Parse "内容: xxx"
|
||||||
if line.startswith("内容:") and current_message is not None:
|
if line.startswith("内容:") and current_message is not None:
|
||||||
current_message["content"] = line.split(":", 1)[1].strip()
|
current_message["content"] = line.split(":", 1)[1].strip()
|
||||||
@@ -617,7 +672,7 @@ class AgentMemory:
|
|||||||
|
|
||||||
# Content line
|
# Content line
|
||||||
if current_message:
|
if current_message:
|
||||||
if current_message["content"]:
|
if current_message.get("content"):
|
||||||
current_message["content"] += "\n" + line
|
current_message["content"] += "\n" + line
|
||||||
else:
|
else:
|
||||||
current_message["content"] = line
|
current_message["content"] = line
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""X-Agents API Module."""
|
"""X-Agents API Module."""
|
||||||
|
|
||||||
from agents.api.routes import router
|
from .routes import router
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class ChatRequest(BaseModel):
|
|||||||
|
|
||||||
Fields aligned with server/internal/service/agent_service.go::AgentChatRequest
|
Fields aligned with server/internal/service/agent_service.go::AgentChatRequest
|
||||||
"""
|
"""
|
||||||
agent_id: int
|
agent_id: str # 支持 UUID 字符串
|
||||||
message: str
|
message: str
|
||||||
user_id: int = 0
|
user_id: int = 0
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
@@ -37,7 +37,7 @@ class ChatResponse(BaseModel):
|
|||||||
|
|
||||||
Fields aligned with server/internal/service/agent_service.go::AgentChatResponse
|
Fields aligned with server/internal/service/agent_service.go::AgentChatResponse
|
||||||
"""
|
"""
|
||||||
agent_id: int
|
agent_id: str # 支持 UUID 字符串
|
||||||
response: str
|
response: str
|
||||||
tool_calls: list = []
|
tool_calls: list = []
|
||||||
tokens_used: int = 0
|
tokens_used: int = 0
|
||||||
@@ -209,7 +209,10 @@ async def chat_stream(request: ChatRequest):
|
|||||||
Yields:
|
Yields:
|
||||||
Streaming response chunks in SSE format
|
Streaming response chunks in SSE format
|
||||||
"""
|
"""
|
||||||
|
logger.info(f"[chat_stream] Received request: agent_id={request.agent_id}, message={request.message[:50]}...")
|
||||||
|
|
||||||
if _agent is None:
|
if _agent is None:
|
||||||
|
logger.error("[chat_stream] Agent not initialized!")
|
||||||
raise HTTPException(status_code=500, detail="Agent not initialized")
|
raise HTTPException(status_code=500, detail="Agent not initialized")
|
||||||
|
|
||||||
session_id = request.session_id or f"session_{request.agent_id}_{int(time.time())}"
|
session_id = request.session_id or f"session_{request.agent_id}_{int(time.time())}"
|
||||||
@@ -217,6 +220,8 @@ async def chat_stream(request: ChatRequest):
|
|||||||
async def generate() -> AsyncGenerator[str, None]:
|
async def generate() -> AsyncGenerator[str, None]:
|
||||||
"""Generate streaming response."""
|
"""Generate streaming response."""
|
||||||
try:
|
try:
|
||||||
|
logger.info(f"[chat_stream] Starting stream for session: {session_id}")
|
||||||
|
|
||||||
# Prepare kwargs for agent.chat()
|
# Prepare kwargs for agent.chat()
|
||||||
kwargs = {
|
kwargs = {
|
||||||
"message": request.message,
|
"message": request.message,
|
||||||
@@ -225,28 +230,38 @@ async def chat_stream(request: ChatRequest):
|
|||||||
|
|
||||||
if request.model_id:
|
if request.model_id:
|
||||||
kwargs["model_id"] = request.model_id
|
kwargs["model_id"] = request.model_id
|
||||||
|
logger.info(f"[chat_stream] Using model_id: {request.model_id}")
|
||||||
if request.model_name:
|
if request.model_name:
|
||||||
kwargs["model_name"] = request.model_name
|
kwargs["model_name"] = request.model_name
|
||||||
|
logger.info(f"[chat_stream] Using model_name: {request.model_name}")
|
||||||
if request.model_provider:
|
if request.model_provider:
|
||||||
kwargs["model_provider"] = request.model_provider
|
kwargs["model_provider"] = request.model_provider
|
||||||
|
logger.info(f"[chat_stream] Using model_provider: {request.model_provider}")
|
||||||
if request.api_key:
|
if request.api_key:
|
||||||
kwargs["api_key"] = request.api_key
|
kwargs["api_key"] = request.api_key
|
||||||
|
logger.info(f"[chat_stream] Using api_key: {request.api_key[:10]}...")
|
||||||
if request.base_url:
|
if request.base_url:
|
||||||
kwargs["base_url"] = request.base_url
|
kwargs["base_url"] = request.base_url
|
||||||
|
logger.info(f"[chat_stream] Using base_url: {request.base_url}")
|
||||||
if request.use_xbot:
|
if request.use_xbot:
|
||||||
kwargs["use_xbot"] = request.use_xbot
|
kwargs["use_xbot"] = request.use_xbot
|
||||||
|
logger.info(f"[chat_stream] Using use_xbot: {request.use_xbot}")
|
||||||
|
|
||||||
# Process with streaming
|
# Process with streaming
|
||||||
|
chunk_count = 0
|
||||||
async for chunk in _agent.chat_stream(**kwargs):
|
async for chunk in _agent.chat_stream(**kwargs):
|
||||||
# SSE format: "data: <json>\n\n"
|
chunk_count += 1
|
||||||
yield f"data: {json.dumps(chunk)}\n\n"
|
logger.info(f"[chat_stream] Yielding chunk {chunk_count}: {chunk}")
|
||||||
|
# SSE format: "data: <json>\n\n" - ensure_ascii=False to output UTF-8 characters directly
|
||||||
|
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
logger.info(f"[chat_stream] Stream complete, yielded {chunk_count} chunks")
|
||||||
# Send final message
|
# Send final message
|
||||||
yield f"data: {json.dumps({'done': True, 'session_id': session_id})}\n\n"
|
yield f"data: {json.dumps({'done': True, 'session_id': session_id}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error in streaming chat: {e}")
|
logger.exception(f"Error in streaming chat: {e}")
|
||||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
|||||||
26
core/agents/api/server.py
Normal file
26
core/agents/api/server.py
Normal 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)
|
||||||
@@ -17,3 +17,7 @@ chromadb>=0.4.0
|
|||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
|
|
||||||
|
# Sandbox isolation (optional)
|
||||||
|
# Install gVisor for enhanced sandbox: https://gvisor.dev/
|
||||||
|
# Or use bwrapfs which is available on most Linux systems
|
||||||
|
|||||||
@@ -21,19 +21,55 @@ from agents.tools.builtin import (
|
|||||||
from agents.tools.manager import ToolManager
|
from agents.tools.manager import ToolManager
|
||||||
|
|
||||||
|
|
||||||
def create_default_registry() -> ToolRegistry:
|
def create_default_registry(use_sandbox: bool = False) -> ToolRegistry:
|
||||||
"""Create a tool registry with default tools.
|
"""Create a tool registry with default tools.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
use_sandbox: Whether to use sandbox for shell execution
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tool registry with built-in tools
|
Tool registry with built-in tools
|
||||||
"""
|
"""
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
# Register built-in tools
|
# Register built-in tools
|
||||||
for tool in get_builtin_tools():
|
for tool in get_builtin_tools(use_sandbox=use_sandbox):
|
||||||
registry.register(tool)
|
registry.register(tool)
|
||||||
return registry
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
# Import sandbox tools from nanobot (optional)
|
||||||
|
try:
|
||||||
|
from nanobot.agent.tools.sandbox_execution import (
|
||||||
|
SandboxType,
|
||||||
|
SandboxCodeExecutionTool,
|
||||||
|
SandboxBashTool,
|
||||||
|
get_sandbox_tools,
|
||||||
|
)
|
||||||
|
from nanobot.agent.tools.bwrap_sandbox import (
|
||||||
|
BwrapSandbox,
|
||||||
|
get_bwrap_sandbox,
|
||||||
|
execute_in_bwrap,
|
||||||
|
)
|
||||||
|
from nanobot.agent.tools.gvisor_sandbox import (
|
||||||
|
GvisorSandbox,
|
||||||
|
get_gvisor_sandbox,
|
||||||
|
execute_in_gvisor,
|
||||||
|
)
|
||||||
|
SANDBOX_AVAILABLE = True
|
||||||
|
except ImportError as e:
|
||||||
|
SandboxType = None
|
||||||
|
SandboxCodeExecutionTool = None
|
||||||
|
SandboxBashTool = None
|
||||||
|
get_sandbox_tools = None
|
||||||
|
BwrapSandbox = None
|
||||||
|
get_bwrap_sandbox = None
|
||||||
|
execute_in_bwrap = None
|
||||||
|
GvisorSandbox = None
|
||||||
|
get_gvisor_sandbox = None
|
||||||
|
execute_in_gvisor = None
|
||||||
|
SANDBOX_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Tool",
|
"Tool",
|
||||||
"ToolRegistry",
|
"ToolRegistry",
|
||||||
@@ -48,4 +84,16 @@ __all__ = [
|
|||||||
"CalculatorTool",
|
"CalculatorTool",
|
||||||
"GetTimeTool",
|
"GetTimeTool",
|
||||||
"BashTool",
|
"BashTool",
|
||||||
|
# Sandbox tools
|
||||||
|
"SANDBOX_AVAILABLE",
|
||||||
|
"SandboxType",
|
||||||
|
"SandboxCodeExecutionTool",
|
||||||
|
"SandboxBashTool",
|
||||||
|
"get_sandbox_tools",
|
||||||
|
"BwrapSandbox",
|
||||||
|
"GvisorSandbox",
|
||||||
|
"get_bwrap_sandbox",
|
||||||
|
"get_gvisor_sandbox",
|
||||||
|
"execute_in_bwrap",
|
||||||
|
"execute_in_gvisor",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,12 +2,24 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
|
|
||||||
|
# Import sandbox (optional - graceful fallback if not available)
|
||||||
|
try:
|
||||||
|
from nanobot.agent.tools.bwrap_sandbox import BwrapSandbox, get_bwrap_sandbox
|
||||||
|
from nanobot.agent.tools.sandbox_execution import SandboxType
|
||||||
|
SANDBOX_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
BwrapSandbox = None
|
||||||
|
get_bwrap_sandbox = None
|
||||||
|
SandboxType = None
|
||||||
|
SANDBOX_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
class ReadFileTool(Tool):
|
class ReadFileTool(Tool):
|
||||||
"""Read file contents."""
|
"""Read file contents."""
|
||||||
@@ -263,7 +275,7 @@ class WebSearchTool(Tool):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
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
|
@property
|
||||||
def parameters(self) -> dict[str, Any]:
|
def parameters(self) -> dict[str, Any]:
|
||||||
@@ -361,8 +373,18 @@ class GetTimeTool(Tool):
|
|||||||
class BashTool(Tool):
|
class BashTool(Tool):
|
||||||
"""Execute bash commands."""
|
"""Execute bash commands."""
|
||||||
|
|
||||||
def __init__(self, workspace: Path | None = None):
|
def __init__(self, workspace: Path | None = None, use_sandbox: bool = False):
|
||||||
|
"""Initialize bash tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace: Workspace path
|
||||||
|
use_sandbox: Whether to use sandbox for execution (recommended for untrusted code)
|
||||||
|
"""
|
||||||
self._workspace = workspace
|
self._workspace = workspace
|
||||||
|
self._use_sandbox = use_sandbox
|
||||||
|
self._sandbox = None
|
||||||
|
if use_sandbox and SANDBOX_AVAILABLE:
|
||||||
|
self._sandbox = get_bwrap_sandbox()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@@ -370,11 +392,13 @@ class BashTool(Tool):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
|
if self._use_sandbox:
|
||||||
|
return "Execute a bash command in an isolated sandbox and return its output."
|
||||||
return "Execute a bash command and return its output."
|
return "Execute a bash command and return its output."
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parameters(self) -> dict[str, Any]:
|
def parameters(self) -> dict[str, Any]:
|
||||||
return {
|
params = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"command": {"type": "string", "description": "Command to execute"},
|
"command": {"type": "string", "description": "Command to execute"},
|
||||||
@@ -386,8 +410,17 @@ class BashTool(Tool):
|
|||||||
},
|
},
|
||||||
"required": ["command"],
|
"required": ["command"],
|
||||||
}
|
}
|
||||||
|
return params
|
||||||
|
|
||||||
async def execute(self, command: str, timeout: int = 30, **kwargs: Any) -> str:
|
async def execute(self, command: str, timeout: int = 30, **kwargs: Any) -> str:
|
||||||
|
# Use sandbox if enabled
|
||||||
|
if self._use_sandbox and self._sandbox:
|
||||||
|
try:
|
||||||
|
return await self._sandbox.execute_command(command, timeout)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error executing in sandbox: {str(e)}\nFalling back to direct execution."
|
||||||
|
|
||||||
|
# Direct execution (no sandbox)
|
||||||
try:
|
try:
|
||||||
process = await asyncio.create_subprocess_shell(
|
process = await asyncio.create_subprocess_shell(
|
||||||
command,
|
command,
|
||||||
@@ -410,11 +443,12 @@ class BashTool(Tool):
|
|||||||
return f"Error executing command: {str(e)}"
|
return f"Error executing command: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
def get_builtin_tools(workspace: Path | None = None) -> list[Tool]:
|
def get_builtin_tools(workspace: Path | None = None, use_sandbox: bool = False) -> list[Tool]:
|
||||||
"""Get list of all built-in tools.
|
"""Get list of all built-in tools.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
workspace: Optional workspace path for file operations
|
workspace: Optional workspace path for file operations
|
||||||
|
use_sandbox: Whether to use sandbox for shell execution (recommended for untrusted code)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of Tool instances
|
List of Tool instances
|
||||||
@@ -427,5 +461,5 @@ def get_builtin_tools(workspace: Path | None = None) -> list[Tool]:
|
|||||||
WebSearchTool(),
|
WebSearchTool(),
|
||||||
CalculatorTool(),
|
CalculatorTool(),
|
||||||
GetTimeTool(),
|
GetTimeTool(),
|
||||||
BashTool(workspace),
|
BashTool(workspace, use_sandbox=use_sandbox),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,22 +14,24 @@ logger = logging.getLogger(__name__)
|
|||||||
class ToolManager:
|
class ToolManager:
|
||||||
"""Manages tools for the agent."""
|
"""Manages tools for the agent."""
|
||||||
|
|
||||||
def __init__(self, workspace: Path | None = None):
|
def __init__(self, workspace: Path | None = None, use_sandbox: bool = False):
|
||||||
"""Initialize tool manager.
|
"""Initialize tool manager.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
workspace: Optional workspace path
|
workspace: Optional workspace path
|
||||||
|
use_sandbox: Whether to use sandbox for shell execution (recommended for untrusted code)
|
||||||
"""
|
"""
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
|
self.use_sandbox = use_sandbox
|
||||||
self.registry = ToolRegistry()
|
self.registry = ToolRegistry()
|
||||||
self._load_builtin_tools()
|
self._load_builtin_tools()
|
||||||
|
|
||||||
def _load_builtin_tools(self) -> None:
|
def _load_builtin_tools(self) -> None:
|
||||||
"""Load all built-in tools."""
|
"""Load all built-in tools."""
|
||||||
tools = get_builtin_tools(self.workspace)
|
tools = get_builtin_tools(self.workspace, use_sandbox=self.use_sandbox)
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
self.registry.register(tool)
|
self.registry.register(tool)
|
||||||
logger.info(f"Loaded {len(tools)} built-in tools")
|
logger.info(f"Loaded {len(tools)} built-in tools (sandbox: {self.use_sandbox})")
|
||||||
|
|
||||||
def register_tool(self, tool: Any) -> None:
|
def register_tool(self, tool: Any) -> None:
|
||||||
"""Register a custom tool.
|
"""Register a custom tool.
|
||||||
|
|||||||
252
core/nanobot/nanobot/agent/tools/bwrap_sandbox.py
Normal file
252
core/nanobot/nanobot/agent/tools/bwrap_sandbox.py
Normal 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)
|
||||||
284
core/nanobot/nanobot/agent/tools/gvisor_sandbox.py
Normal file
284
core/nanobot/nanobot/agent/tools/gvisor_sandbox.py
Normal 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)
|
||||||
238
core/nanobot/nanobot/agent/tools/sandbox_execution.py
Normal file
238
core/nanobot/nanobot/agent/tools/sandbox_execution.py
Normal 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,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -377,7 +377,7 @@ func main() {
|
|||||||
toolService := service.NewToolService(toolRepo)
|
toolService := service.NewToolService(toolRepo)
|
||||||
mcpService := service.NewMCPService(mcpRepo)
|
mcpService := service.NewMCPService(mcpRepo)
|
||||||
skillService := service.NewSkillService(skillRepo)
|
skillService := service.NewSkillService(skillRepo)
|
||||||
agentService := service.NewAgentService(cfg.PythonServiceURL, modelRepo, agentRepo)
|
agentService := service.NewAgentService(cfg.PythonServiceURL, modelRepo, agentRepo, chatRepo)
|
||||||
memoryService := service.NewMemoryService(agentRepo)
|
memoryService := service.NewMemoryService(agentRepo)
|
||||||
|
|
||||||
// 4.2 初始化默认工具
|
// 4.2 初始化默认工具
|
||||||
@@ -387,7 +387,14 @@ func main() {
|
|||||||
log.Println("Default tools initialized")
|
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 {
|
// if err := skillService.InitSkills(); err != nil {
|
||||||
// log.Printf("Warning: Failed to init skills: %v", err)
|
// log.Printf("Warning: Failed to init skills: %v", err)
|
||||||
// } else {
|
// } else {
|
||||||
@@ -405,9 +412,9 @@ func main() {
|
|||||||
toolHandler := handler.NewToolHandler(toolService)
|
toolHandler := handler.NewToolHandler(toolService)
|
||||||
mcpHandler := handler.NewMCPHandler(mcpService)
|
mcpHandler := handler.NewMCPHandler(mcpService)
|
||||||
skillHandler := handler.NewSkillHandler(skillService)
|
skillHandler := handler.NewSkillHandler(skillService)
|
||||||
agentHandler := handler.NewAgentHandler(agentService)
|
agentHandler := handler.NewAgentHandler(agentService, agentRepo)
|
||||||
memoryHandler := handler.NewMemoryHandler(memoryService)
|
memoryHandler := handler.NewMemoryHandler(memoryService)
|
||||||
sessionHandler := handler.NewSessionHandler(chatRepo)
|
sessionHandler := handler.NewSessionHandler(chatRepo, agentService)
|
||||||
|
|
||||||
// 初始化群聊服务
|
// 初始化群聊服务
|
||||||
chatGroupRepo := repository.NewChatGroupRepository(db)
|
chatGroupRepo := repository.NewChatGroupRepository(db)
|
||||||
@@ -590,6 +597,7 @@ func main() {
|
|||||||
{
|
{
|
||||||
agentGroup.GET("/list", agentHandler.ListAgents)
|
agentGroup.GET("/list", agentHandler.ListAgents)
|
||||||
agentGroup.POST("/create", agentHandler.CreateAgent)
|
agentGroup.POST("/create", agentHandler.CreateAgent)
|
||||||
|
agentGroup.POST("/init-team", agentHandler.InitTeamMembers)
|
||||||
agentGroup.PUT("/:id/status", agentHandler.UpdateAgentStatus)
|
agentGroup.PUT("/:id/status", agentHandler.UpdateAgentStatus)
|
||||||
agentGroup.PUT("/:id", agentHandler.UpdateAgent)
|
agentGroup.PUT("/:id", agentHandler.UpdateAgent)
|
||||||
agentGroup.DELETE("/:id", agentHandler.DeleteAgent)
|
agentGroup.DELETE("/:id", agentHandler.DeleteAgent)
|
||||||
@@ -608,6 +616,7 @@ func main() {
|
|||||||
chatGroup.DELETE("/sessions/:id", sessionHandler.DeleteSession)
|
chatGroup.DELETE("/sessions/:id", sessionHandler.DeleteSession)
|
||||||
chatGroup.GET("/sessions/:id/messages", sessionHandler.GetMessages)
|
chatGroup.GET("/sessions/:id/messages", sessionHandler.GetMessages)
|
||||||
chatGroup.POST("/messages", sessionHandler.CreateMessage)
|
chatGroup.POST("/messages", sessionHandler.CreateMessage)
|
||||||
|
chatGroup.POST("/sessions/generate-title", sessionHandler.GenerateSessionTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 群聊管理模块
|
// 群聊管理模块
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# 本地开发配置
|
# 本地开发配置
|
||||||
port: "8082"
|
port: "8080"
|
||||||
jwt_secret: "dev-secret-key"
|
jwt_secret: "dev-secret-key"
|
||||||
|
|
||||||
# 数据库配置 (类型: mysql 或 sqlite)
|
# 数据库配置 (类型: mysql 或 sqlite)
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"x-agents/server/internal/model"
|
||||||
|
"x-agents/server/internal/repository"
|
||||||
"x-agents/server/internal/service"
|
"x-agents/server/internal/service"
|
||||||
|
"x-agents/server/internal/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -12,27 +17,30 @@ import (
|
|||||||
// AgentHandler Agent 处理器
|
// AgentHandler Agent 处理器
|
||||||
type AgentHandler struct {
|
type AgentHandler struct {
|
||||||
agentService *service.AgentService
|
agentService *service.AgentService
|
||||||
|
agentRepo *repository.AgentRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentHandler 创建 Agent 处理器
|
// NewAgentHandler 创建 Agent 处理器
|
||||||
func NewAgentHandler(agentService *service.AgentService) *AgentHandler {
|
func NewAgentHandler(agentService *service.AgentService, agentRepo *repository.AgentRepository) *AgentHandler {
|
||||||
return &AgentHandler{
|
return &AgentHandler{
|
||||||
agentService: agentService,
|
agentService: agentService,
|
||||||
|
agentRepo: agentRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatRequest 对话请求
|
// ChatRequest 对话请求
|
||||||
type ChatRequest struct {
|
type ChatRequest struct {
|
||||||
AgentID string `json:"agent_id" binding:"required"` // 字符串类型
|
AgentID string `json:"agent_id"` // 字符串类型,支持 UUID,可为空(当使用 mentioned_agent_ids 时)
|
||||||
Message string `json:"message" binding:"required"`
|
Message string `json:"message" binding:"required"`
|
||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
ModelID string `json:"model_id"`
|
ModelID string `json:"model_id"`
|
||||||
UseXBot bool `json:"use_xbot"`
|
UseXBot bool `json:"use_xbot"`
|
||||||
|
MentionedAgentIDs []string `json:"mentioned_agent_ids"` // @ 提及的智能体 ID 列表
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatResponse 对话响应
|
// ChatResponse 对话响应
|
||||||
type ChatResponse struct {
|
type ChatResponse struct {
|
||||||
AgentID int `json:"agent_id"`
|
AgentID string `json:"agent_id"` // 支持 UUID 字符串
|
||||||
Reply string `json:"reply"`
|
Reply string `json:"reply"`
|
||||||
ToolsUsed []string `json:"tools_used"`
|
ToolsUsed []string `json:"tools_used"`
|
||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
@@ -73,11 +81,9 @@ func (h *AgentHandler) Chat(c *gin.Context) {
|
|||||||
userIDStr := "1" // TODO: 从 c.Get("user_id") 获取
|
userIDStr := "1" // TODO: 从 c.Get("user_id") 获取
|
||||||
userID, _ := strconv.Atoi(userIDStr)
|
userID, _ := strconv.Atoi(userIDStr)
|
||||||
|
|
||||||
// 将前端传来的字符串 agent_id 转换为 int
|
// 直接使用字符串类型的 agent_id,支持 UUID
|
||||||
agentID, _ := strconv.Atoi(req.AgentID)
|
|
||||||
|
|
||||||
pythonReq := service.AgentChatRequest{
|
pythonReq := service.AgentChatRequest{
|
||||||
AgentID: agentID,
|
AgentID: req.AgentID,
|
||||||
Message: req.Message,
|
Message: req.Message,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
SessionID: req.SessionID,
|
SessionID: req.SessionID,
|
||||||
@@ -130,8 +136,31 @@ func (h *AgentHandler) ChatStream(c *gin.Context) {
|
|||||||
userIDStr := "1" // TODO: 从 c.Get("user_id") 获取
|
userIDStr := "1" // TODO: 从 c.Get("user_id") 获取
|
||||||
userID, _ := strconv.Atoi(userIDStr)
|
userID, _ := strconv.Atoi(userIDStr)
|
||||||
|
|
||||||
// 将前端传来的字符串 agent_id 转换为 int
|
// 直接使用字符串类型的 agent_id,支持 UUID
|
||||||
agentID, _ := strconv.Atoi(req.AgentID)
|
agentID := req.AgentID
|
||||||
|
|
||||||
|
// 优先使用前端传递的 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 流
|
// 构建 SSE 流
|
||||||
c.Header("Content-Type", "text/event-stream")
|
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 多智能体群聊请求
|
// TeamChatRequest 多智能体群聊请求
|
||||||
type TeamChatRequest struct {
|
type TeamChatRequest struct {
|
||||||
SupervisorAgentID int `json:"supervisor_agent_id" binding:"required"`
|
SupervisorAgentID int `json:"supervisor_agent_id" binding:"required"`
|
||||||
@@ -238,6 +298,30 @@ func (h *AgentHandler) CreateAgent(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, result)
|
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 获取智能体列表
|
// ListAgents 获取智能体列表
|
||||||
// @Summary 获取智能体列表
|
// @Summary 获取智能体列表
|
||||||
// @Tags 智能体管理
|
// @Tags 智能体管理
|
||||||
@@ -317,6 +401,7 @@ func (h *AgentHandler) DeleteAgent(c *gin.Context) {
|
|||||||
type UpdateAgentRequest struct {
|
type UpdateAgentRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
Skills []string `json:"skills"`
|
Skills []string `json:"skills"`
|
||||||
RoleDescription string `json:"role_description"`
|
RoleDescription string `json:"role_description"`
|
||||||
ModelProvider string `json:"model_provider"`
|
ModelProvider string `json:"model_provider"`
|
||||||
@@ -345,7 +430,7 @@ func (h *AgentHandler) UpdateAgent(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.agentService.UpdateAgent(agentID, req.Name, req.Description, req.Skills, req.RoleDescription, req.ModelProvider, req.ModelName)
|
err := h.agentService.UpdateAgent(agentID, req.Name, req.Description, req.Avatar, req.Skills, req.RoleDescription, req.ModelProvider, req.ModelName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -31,12 +31,18 @@ func (h *ChatGroupHandler) CreateGroup(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从上下文获取用户ID
|
// 从上下文获取用户ID,如果存在则覆盖请求中的user_id
|
||||||
userID, exists := c.Get("user_id")
|
userID, exists := c.Get("user_id")
|
||||||
if exists {
|
if exists {
|
||||||
req.UserID = userID.(string)
|
req.UserID = userID.(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证user_id
|
||||||
|
if req.UserID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
group, err := h.groupService.CreateGroup(req)
|
group, err := h.groupService.CreateGroup(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"x-agents/server/internal/model"
|
"x-agents/server/internal/model"
|
||||||
"x-agents/server/internal/repository"
|
"x-agents/server/internal/repository"
|
||||||
@@ -94,11 +96,12 @@ func (h *ChatHandler) CreateAgent(c *gin.Context) {
|
|||||||
|
|
||||||
// SessionHandler 处理会话管理
|
// SessionHandler 处理会话管理
|
||||||
type SessionHandler struct {
|
type SessionHandler struct {
|
||||||
chatRepo *repository.ChatRepository
|
chatRepo *repository.ChatRepository
|
||||||
|
agentService *service.AgentService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSessionHandler(chatRepo *repository.ChatRepository) *SessionHandler {
|
func NewSessionHandler(chatRepo *repository.ChatRepository, agentService *service.AgentService) *SessionHandler {
|
||||||
return &SessionHandler{chatRepo: chatRepo}
|
return &SessionHandler{chatRepo: chatRepo, agentService: agentService}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSession 创建会话
|
// CreateSession 创建会话
|
||||||
@@ -226,6 +229,16 @@ func (h *SessionHandler) CreateMessage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debug: 打印请求内容
|
||||||
|
fmt.Printf("[CreateMessage] Request: session_id=%s, role=%s, content_len=%d\n",
|
||||||
|
req.SessionID, req.Role, len(req.Content))
|
||||||
|
|
||||||
|
// 验证 content 不为空
|
||||||
|
if req.Content == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Content cannot be empty"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 检查会话是否存在
|
// 检查会话是否存在
|
||||||
_, err := h.chatRepo.GetSessionByID(req.SessionID)
|
_, err := h.chatRepo.GetSessionByID(req.SessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -250,3 +263,65 @@ func (h *SessionHandler) CreateMessage(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, message)
|
c.JSON(http.StatusOK, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateSessionTitleRequest 生成会话标题请求
|
||||||
|
type GenerateSessionTitleRequest struct {
|
||||||
|
SessionID string `json:"session_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateSessionTitle 生成会话标题
|
||||||
|
func (h *SessionHandler) GenerateSessionTitle(c *gin.Context) {
|
||||||
|
var req GenerateSessionTitleRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取会话的所有消息
|
||||||
|
messages, _, err := h.chatRepo.GetMessagesBySessionID(req.SessionID, 100, 0)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get messages"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messages) < 2 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Not enough messages to generate title"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取会话信息
|
||||||
|
session, err := h.chatRepo.GetSessionByID(req.SessionID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单方式:从用户的第一条消息中提取前15个字符作为标题
|
||||||
|
var title string
|
||||||
|
for _, msg := range messages {
|
||||||
|
if msg.Role == "user" {
|
||||||
|
// 清理内容,去除换行和多余空格
|
||||||
|
content := strings.ReplaceAll(msg.Content, "\n", " ")
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
// 限制长度
|
||||||
|
if len(content) > 15 {
|
||||||
|
title = content[:15] + "..."
|
||||||
|
} else {
|
||||||
|
title = content
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if title == "" {
|
||||||
|
title = "新会话"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[GenerateSessionTitle] Generated title: %s\n", title)
|
||||||
|
|
||||||
|
// 更新会话标题
|
||||||
|
session.Title = title
|
||||||
|
h.chatRepo.UpdateSession(session)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"title": title})
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Agent struct {
|
|||||||
Name string `json:"name" gorm:"size:100;not null"`
|
Name string `json:"name" gorm:"size:100;not null"`
|
||||||
Description string `json:"description" gorm:"type:text"`
|
Description string `json:"description" gorm:"type:text"`
|
||||||
OwnerID string `json:"owner_id" gorm:"size:50;not null;index"`
|
OwnerID string `json:"owner_id" gorm:"size:50;not null;index"`
|
||||||
|
Avatar string `json:"avatar" gorm:"size:50"` // 头像 (emoji)
|
||||||
|
|
||||||
// 技能列表(JSON数组)
|
// 技能列表(JSON数组)
|
||||||
Skills []string `json:"skills" gorm:"type:text;serializer:json"`
|
Skills []string `json:"skills" gorm:"type:text;serializer:json"`
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ type UpdateSessionRequest struct {
|
|||||||
type CreateMessageRequest struct {
|
type CreateMessageRequest struct {
|
||||||
SessionID string `json:"session_id" binding:"required"`
|
SessionID string `json:"session_id" binding:"required"`
|
||||||
Role string `json:"role" binding:"required"` // user/assistant
|
Role string `json:"role" binding:"required"` // user/assistant
|
||||||
Content string `json:"content" binding:"required"`
|
Content string `json:"content"`
|
||||||
TokensUsed int `json:"tokens_used"`
|
TokensUsed int `json:"tokens_used"`
|
||||||
DurationMs int `json:"duration_ms"`
|
DurationMs int `json:"duration_ms"`
|
||||||
Metadata string `json:"metadata"`
|
Metadata string `json:"metadata"`
|
||||||
|
|||||||
@@ -56,6 +56,21 @@ func (r *ChatRepository) DeleteSession(id string) error {
|
|||||||
return r.db.Delete(&model.ChatSession{}, "id = ?", id).Error
|
return r.db.Delete(&model.ChatSession{}, "id = ?", id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteSessionsByAgentID 删除智能体的所有会话
|
||||||
|
func (r *ChatRepository) DeleteSessionsByAgentID(agentID string) error {
|
||||||
|
// 先查询该智能体的所有会话
|
||||||
|
var sessions []model.ChatSession
|
||||||
|
if err := r.db.Where("agent_id = ?", agentID).Find(&sessions).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 删除每个会话下的所有消息
|
||||||
|
for _, session := range sessions {
|
||||||
|
r.db.Where("session_id = ?", session.ID).Delete(&model.ChatMessage{})
|
||||||
|
}
|
||||||
|
// 再删除所有会话
|
||||||
|
return r.db.Where("agent_id = ?", agentID).Delete(&model.ChatSession{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
// Message CRUD
|
// Message CRUD
|
||||||
|
|
||||||
// CreateMessage 创建消息
|
// CreateMessage 创建消息
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
|
|
||||||
// AgentChatRequest Python Agent 对话请求
|
// AgentChatRequest Python Agent 对话请求
|
||||||
type AgentChatRequest struct {
|
type AgentChatRequest struct {
|
||||||
AgentID int `json:"agent_id"`
|
AgentID string `json:"agent_id"` // 支持 UUID 字符串
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
UserID int `json:"user_id"`
|
UserID int `json:"user_id"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
@@ -32,7 +32,7 @@ type AgentChatRequest struct {
|
|||||||
|
|
||||||
// AgentChatResponse Python Agent 对话响应
|
// AgentChatResponse Python Agent 对话响应
|
||||||
type AgentChatResponse struct {
|
type AgentChatResponse struct {
|
||||||
AgentID int `json:"agent_id"`
|
AgentID string `json:"agent_id"` // 支持 UUID 字符串
|
||||||
Response string `json:"response"`
|
Response string `json:"response"`
|
||||||
ToolCalls []interface{} `json:"tool_calls"`
|
ToolCalls []interface{} `json:"tool_calls"`
|
||||||
TokensUsed int `json:"tokens_used"`
|
TokensUsed int `json:"tokens_used"`
|
||||||
@@ -66,10 +66,11 @@ type AgentService struct {
|
|||||||
client *http.Client
|
client *http.Client
|
||||||
modelRepo *repository.ModelRepository
|
modelRepo *repository.ModelRepository
|
||||||
agentRepo *repository.AgentRepository
|
agentRepo *repository.AgentRepository
|
||||||
|
chatRepo *repository.ChatRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentService 创建 Agent 服务
|
// NewAgentService 创建 Agent 服务
|
||||||
func NewAgentService(pythonURL string, modelRepo *repository.ModelRepository, agentRepo *repository.AgentRepository) *AgentService {
|
func NewAgentService(pythonURL string, modelRepo *repository.ModelRepository, agentRepo *repository.AgentRepository, chatRepo *repository.ChatRepository) *AgentService {
|
||||||
return &AgentService{
|
return &AgentService{
|
||||||
pythonURL: pythonURL,
|
pythonURL: pythonURL,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
@@ -77,6 +78,7 @@ func NewAgentService(pythonURL string, modelRepo *repository.ModelRepository, ag
|
|||||||
},
|
},
|
||||||
modelRepo: modelRepo,
|
modelRepo: modelRepo,
|
||||||
agentRepo: agentRepo,
|
agentRepo: agentRepo,
|
||||||
|
chatRepo: chatRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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",
|
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)
|
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)
|
jsonData, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -153,7 +155,7 @@ func (s *AgentService) Chat(req AgentChatRequest) (*AgentChatResponse, error) {
|
|||||||
|
|
||||||
// TeamChat 多智能体群聊
|
// TeamChat 多智能体群聊
|
||||||
func (s *AgentService) TeamChat(req TeamChatRequest) (*TeamChatResponse, error) {
|
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 == "" {
|
if req.Strategy == "" {
|
||||||
@@ -195,16 +197,19 @@ func (s *AgentService) TeamChat(req TeamChatRequest) (*TeamChatResponse, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ChatStream 流式对话
|
// ChatStream 流式对话
|
||||||
func (s *AgentService) ChatStream(c interface{}, agentID int, message, sessionID, modelID string, userID int) error {
|
func (s *AgentService) ChatStream(c interface{}, agentID string, message, sessionID, modelID string, userID int) error {
|
||||||
// 获取 gin.Context
|
// 获取 gin.Context
|
||||||
ginCtx, ok := c.(*gin.Context)
|
ginCtx, ok := c.(*gin.Context)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("invalid context type")
|
return fmt.Errorf("invalid context type")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[ChatStream] Request: agentID=%s, message=%s, sessionID=%s, modelID=%s, userID=%d",
|
||||||
|
agentID, message, sessionID, modelID, userID)
|
||||||
|
|
||||||
// 初始化请求体
|
// 初始化请求体
|
||||||
reqBody := map[string]interface{}{
|
reqBody := map[string]interface{}{
|
||||||
"agent_id": agentID,
|
"agent_id": agentID, // 传递字符串类型的 agent_id,支持 UUID
|
||||||
"message": message,
|
"message": message,
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"session_id": sessionID,
|
"session_id": sessionID,
|
||||||
@@ -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)
|
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)
|
jsonData, err := json.Marshal(reqBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -267,8 +272,10 @@ func (s *AgentService) ChatStream(c interface{}, agentID int, message, sessionID
|
|||||||
for {
|
for {
|
||||||
n, err := resp.Body.Read(buf)
|
n, err := resp.Body.Read(buf)
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
|
log.Printf("[ChatStream] Received %d bytes from Python", n)
|
||||||
_, writeErr := ginCtx.Writer.Write(buf[:n])
|
_, writeErr := ginCtx.Writer.Write(buf[:n])
|
||||||
if writeErr != nil {
|
if writeErr != nil {
|
||||||
|
log.Printf("[ChatStream] Write error: %v", writeErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// 强制刷新到客户端
|
// 强制刷新到客户端
|
||||||
@@ -277,6 +284,7 @@ func (s *AgentService) ChatStream(c interface{}, agentID int, message, sessionID
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[ChatStream] Done reading from Python, err: %v", err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,9 +308,10 @@ type CreateAgentRequest struct {
|
|||||||
|
|
||||||
// CreateAgentResponse 创建智能体响应
|
// CreateAgentResponse 创建智能体响应
|
||||||
type CreateAgentResponse struct {
|
type CreateAgentResponse struct {
|
||||||
AgentID int `json:"agent_id"`
|
AgentID int `json:"agent_id"` // 保留兼容性
|
||||||
Name string `json:"name"`
|
AgentIDStr string `json:"agent_id_str"` // 返回实际的 UUID
|
||||||
Message string `json:"message"`
|
Name string `json:"name"`
|
||||||
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateAgent 创建智能体
|
// CreateAgent 创建智能体
|
||||||
@@ -329,6 +338,7 @@ func (s *AgentService) CreateAgent(req CreateAgentRequest, userID int) (*CreateA
|
|||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
OwnerID: fmt.Sprintf("%d", userID),
|
OwnerID: fmt.Sprintf("%d", userID),
|
||||||
|
Avatar: req.Avatar,
|
||||||
Skills: skills,
|
Skills: skills,
|
||||||
RoleDescription: req.Prompt,
|
RoleDescription: req.Prompt,
|
||||||
ModelProvider: req.ModelProvider,
|
ModelProvider: req.ModelProvider,
|
||||||
@@ -347,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)
|
log.Printf("[AgentService] Agent created in database: %s (ID: %s)", agent.Name, agent.ID)
|
||||||
|
|
||||||
// 解析 agent ID 为整数返回
|
// 返回数据库中实际的 Agent ID (UUID字符串)
|
||||||
agentIDInt := int(time.Now().Unix()) % 100000
|
|
||||||
|
|
||||||
return &CreateAgentResponse{
|
return &CreateAgentResponse{
|
||||||
AgentID: agentIDInt,
|
AgentIDStr: agent.ID,
|
||||||
Name: agent.Name,
|
Name: agent.Name,
|
||||||
Message: "Agent created successfully",
|
Message: "Agent created successfully",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 获取智能体列表响应
|
// ListAgentsResponse 获取智能体列表响应
|
||||||
type ListAgentsResponse struct {
|
type ListAgentsResponse struct {
|
||||||
Agents []interface{} `json:"agents"`
|
Agents []interface{} `json:"agents"`
|
||||||
@@ -429,6 +520,14 @@ func (s *AgentService) DeleteAgent(agentID string) error {
|
|||||||
return fmt.Errorf("agent not found: %w", err)
|
return fmt.Errorf("agent not found: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 先删除该智能体的所有会话和消息
|
||||||
|
if s.chatRepo != nil {
|
||||||
|
if err := s.chatRepo.DeleteSessionsByAgentID(agentID); err != nil {
|
||||||
|
log.Printf("[AgentService] DeleteAgent: failed to delete sessions: %v", err)
|
||||||
|
// 继续尝试删除 agent,不因为 session 删除失败而中止
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.agentRepo.Delete(agentID); err != nil {
|
if err := s.agentRepo.Delete(agentID); err != nil {
|
||||||
return fmt.Errorf("failed to delete agent: %w", err)
|
return fmt.Errorf("failed to delete agent: %w", err)
|
||||||
}
|
}
|
||||||
@@ -438,7 +537,7 @@ func (s *AgentService) DeleteAgent(agentID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateAgent 更新智能体
|
// UpdateAgent 更新智能体
|
||||||
func (s *AgentService) UpdateAgent(agentID, name, description string, skills []string, roleDescription, modelProvider, modelName string) error {
|
func (s *AgentService) UpdateAgent(agentID, name, description, avatar string, skills []string, roleDescription, modelProvider, modelName string) error {
|
||||||
if s.agentRepo == nil {
|
if s.agentRepo == nil {
|
||||||
return fmt.Errorf("agent repository not initialized")
|
return fmt.Errorf("agent repository not initialized")
|
||||||
}
|
}
|
||||||
@@ -458,6 +557,9 @@ func (s *AgentService) UpdateAgent(agentID, name, description string, skills []s
|
|||||||
if description != "" {
|
if description != "" {
|
||||||
agent.Description = description
|
agent.Description = description
|
||||||
}
|
}
|
||||||
|
if avatar != "" {
|
||||||
|
agent.Avatar = avatar
|
||||||
|
}
|
||||||
if skills != nil {
|
if skills != nil {
|
||||||
agent.Skills = skills
|
agent.Skills = skills
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ html.dark {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-system, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
|
font-family: var(--font-system, 'PingFang SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
|
||||||
font-size: var(--font-size-sm, 14px);
|
font-size: var(--font-size-sm, 14px);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
|
|||||||
@@ -43,8 +43,8 @@
|
|||||||
--spacing-xl: 24px;
|
--spacing-xl: 24px;
|
||||||
--spacing-2xl: 32px;
|
--spacing-2xl: 32px;
|
||||||
|
|
||||||
/* 字体-family: -apple */
|
/* 字体 - 添加中文字体支持 */
|
||||||
--font-system: BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
--font-system: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-size-xs: 12px;
|
--font-size-xs: 12px;
|
||||||
--font-size-sm: 14px;
|
--font-size-sm: 14px;
|
||||||
--font-size-md: 16px;
|
--font-size-md: 16px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
import type { Agent } from '@/views/chat/chat'
|
import type { Agent } from '@/views/chat/chat'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -14,12 +15,32 @@ const emit = defineEmits<{
|
|||||||
(e: 'toggleSelect', agent: Agent): void
|
(e: 'toggleSelect', agent: Agent): void
|
||||||
(e: 'confirm'): void
|
(e: 'confirm'): void
|
||||||
(e: 'update:groupChatName', value: string): void
|
(e: 'update:groupChatName', value: string): void
|
||||||
|
(e: 'delete', agent: Agent): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 点击智能体 - 只是选择,不直接确认
|
// 点击智能体 - 只是选择,不直接确认
|
||||||
const handleAgentClick = (agent: Agent) => {
|
const handleAgentClick = (agent: Agent) => {
|
||||||
emit('toggleSelect', agent)
|
emit('toggleSelect', agent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除智能体
|
||||||
|
const handleDeleteAgent = async (agent: Agent, event: Event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除智能体 "${agent.name}" 吗?删除后无法恢复。`,
|
||||||
|
'删除确认',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
emit('delete', agent)
|
||||||
|
} catch {
|
||||||
|
// 用户取消删除
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -29,10 +50,10 @@ const handleAgentClick = (agent: Agent) => {
|
|||||||
<div class="bg-dark-800 rounded-xl w-full max-w-md border border-dark-600 shadow-2xl" @click.stop>
|
<div class="bg-dark-800 rounded-xl w-full max-w-md border border-dark-600 shadow-2xl" @click.stop>
|
||||||
<div class="p-4 border-b border-dark-600">
|
<div class="p-4 border-b border-dark-600">
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-white">
|
||||||
{{ selectMode === 'single' ? '选择智能体' : '选择群聊成员' }}
|
{{ selectMode === 'single' ? '选择会话' : '选择群聊成员' }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-gray-400 mt-1">
|
<p class="text-sm text-gray-400 mt-1">
|
||||||
{{ selectMode === 'single' ? '选择一个智能体开始对话' : '选择多个智能体创建群聊' }}
|
{{ selectMode === 'single' ? '选择一个智能体开始新对话' : '选择多个智能体创建群聊' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -53,7 +74,7 @@ const handleAgentClick = (agent: Agent) => {
|
|||||||
v-for="agent in chatAgents"
|
v-for="agent in chatAgents"
|
||||||
:key="agent.id"
|
:key="agent.id"
|
||||||
@click="handleAgentClick(agent)"
|
@click="handleAgentClick(agent)"
|
||||||
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200"
|
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 group"
|
||||||
:class="selectedAgents.some(a => a.id === agent.id)
|
:class="selectedAgents.some(a => a.id === agent.id)
|
||||||
? 'bg-orange-500/20 border border-orange-500/50'
|
? 'bg-orange-500/20 border border-orange-500/50'
|
||||||
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'"
|
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'"
|
||||||
|
|||||||
@@ -1,15 +1,137 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: string
|
modelValue: string
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
agents?: { id: string | number; name: string; avatar: string }[]
|
||||||
|
mentionedAgents?: { id: string | number; name: string; avatar: string }[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', value: string): void
|
(e: 'update:modelValue', value: string): void
|
||||||
(e: 'send'): 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) => {
|
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) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
emit('send')
|
emit('send')
|
||||||
@@ -26,6 +148,26 @@ const autoResize = (e: Event) => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-5 border-t border-white/[0.06] bg-[#0c0c0f]/60 backdrop-blur-xl">
|
<div class="p-5 border-t border-white/[0.06] bg-[#0c0c0f]/60 backdrop-blur-xl">
|
||||||
<div class="max-w-3xl mx-auto">
|
<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">
|
<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">
|
<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
|
<textarea
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value); autoResize($event)"
|
@input="handleInput"
|
||||||
@keydown="handleKeydown"
|
@keydown="handleKeydown"
|
||||||
placeholder="发送消息..."
|
placeholder="输入 @ 提及智能体..."
|
||||||
rows="1"
|
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]"
|
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>
|
></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
|
<button
|
||||||
@click="emit('send')"
|
@click="emit('send')"
|
||||||
|
|||||||
@@ -63,7 +63,12 @@ const copyMessage = async () => {
|
|||||||
: 'bg-[#1e1e28] text-gray-100 rounded-bl-sm'"
|
: 'bg-[#1e1e28] text-gray-100 rounded-bl-sm'"
|
||||||
>
|
>
|
||||||
<span v-html="getMessageContent(message.content, message.role === 'user')"></span>
|
<span v-html="getMessageContent(message.content, message.role === 'user')"></span>
|
||||||
<span v-if="message.isStreaming" class="inline-block w-0.5 h-4 ml-0.5 bg-orange-300 cursor-blink align-middle"></span>
|
<!-- 等待提示 - 三个点动画 -->
|
||||||
|
<span v-if="message.isStreaming" class="inline-flex items-center ml-1 align-middle">
|
||||||
|
<span class="w-1.5 h-1.5 mx-0.5 bg-orange-400 rounded-full animate-bounce" style="animation-delay: 0ms;"></span>
|
||||||
|
<span class="w-1.5 h-1.5 mx-0.5 bg-orange-400 rounded-full animate-bounce" style="animation-delay: 150ms;"></span>
|
||||||
|
<span class="w-1.5 h-1.5 mx-0.5 bg-orange-400 rounded-full animate-bounce" style="animation-delay: 300ms;"></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
<!-- 复制按钮 -->
|
<!-- 复制按钮 -->
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
import type { Agent, ChatSession, GroupChat } from '@/views/chat/chat'
|
import type { Agent, ChatSession, GroupChat } from '@/views/chat/chat'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
chatAgents: Agent[]
|
chatAgents: Agent[]
|
||||||
selectedAgent: Agent | null
|
selectedAgent: Agent | null
|
||||||
chatSessions: ChatSession[]
|
chatSessions: ChatSession[]
|
||||||
groupChats: GroupChat[]
|
groupChats?: GroupChat[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -14,8 +16,16 @@ const emit = defineEmits<{
|
|||||||
(e: 'selectAgent', agent: Agent): void
|
(e: 'selectAgent', agent: Agent): void
|
||||||
(e: 'selectSession', session: ChatSession): void
|
(e: 'selectSession', session: ChatSession): void
|
||||||
(e: 'selectGroup', group: GroupChat): void
|
(e: 'selectGroup', group: GroupChat): void
|
||||||
|
(e: 'deleteSession', session: ChatSession): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// 根据 agent_id 获取智能体名称
|
||||||
|
const getAgentName = (agentId: number | string | undefined) => {
|
||||||
|
if (!agentId) return '未知智能体'
|
||||||
|
const agent = props.chatAgents.find(a => a.id === agentId)
|
||||||
|
return agent?.name || '未知智能体'
|
||||||
|
}
|
||||||
|
|
||||||
const formatRelativeTime = (date: Date) => {
|
const formatRelativeTime = (date: Date) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diff = now.getTime() - date.getTime()
|
const diff = now.getTime() - date.getTime()
|
||||||
@@ -27,6 +37,25 @@ const formatRelativeTime = (date: Date) => {
|
|||||||
if (days < 7) return `${days}天前`
|
if (days < 7) return `${days}天前`
|
||||||
return date.toLocaleDateString('zh-CN')
|
return date.toLocaleDateString('zh-CN')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除会话
|
||||||
|
const handleDeleteSession = async (session: ChatSession, event: Event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除与 "${session.title}" 的对话吗?`,
|
||||||
|
'删除确认',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
emit('deleteSession', session)
|
||||||
|
} catch {
|
||||||
|
// 用户取消删除
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -69,47 +98,40 @@ const formatRelativeTime = (date: Date) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AI 助手选择 -->
|
<!-- 会话列表 -->
|
||||||
<div class="px-3 pb-3">
|
|
||||||
<div class="text-xs text-white/40 uppercase tracking-wider mb-2 px-1">选择 AI 助手</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<button
|
|
||||||
v-for="agent in chatAgents"
|
|
||||||
:key="agent.id"
|
|
||||||
@click="emit('selectAgent', agent)"
|
|
||||||
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200"
|
|
||||||
:class="selectedAgent?.id === agent.id
|
|
||||||
? 'bg-orange-500/15 text-orange-400'
|
|
||||||
: 'text-white/60 hover:bg-white/5 hover:text-white'"
|
|
||||||
>
|
|
||||||
<span class="text-base">{{ agent.avatar }}</span>
|
|
||||||
<span class="text-sm truncate">{{ agent.name }}</span>
|
|
||||||
<span
|
|
||||||
v-if="agent.status === 'online'"
|
|
||||||
class="w-1.5 h-1.5 rounded-full bg-emerald-400 ml-auto"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 群聊列表 -->
|
|
||||||
<div class="flex-1 overflow-y-auto px-3 pb-3">
|
<div class="flex-1 overflow-y-auto px-3 pb-3">
|
||||||
<div class="text-xs text-white/40 uppercase tracking-wider mb-2 px-1">群聊</div>
|
<div class="text-xs text-white/40 uppercase tracking-wider mb-2 px-1">会话</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<button
|
<button
|
||||||
v-for="group in groupChats"
|
v-for="session in chatSessions"
|
||||||
:key="group.id"
|
:key="session.id"
|
||||||
@click="emit('selectGroup', group)"
|
@click="emit('selectSession', session)"
|
||||||
class="w-full text-left px-3 py-2.5 rounded-lg hover:bg-white/5 transition-all duration-200 group"
|
class="w-full text-left px-3 py-2.5 rounded-lg hover:bg-white/5 transition-all duration-200 group"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<svg class="w-4 h-4 text-white/30 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 text-white/30 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-sm text-white/70 group-hover:text-white truncate">{{ group.name }}</span>
|
<span class="text-sm text-white/70 group-hover:text-white truncate flex-1">{{ session.title || '新会话' }}</span>
|
||||||
|
<!-- 删除按钮 -->
|
||||||
|
<span
|
||||||
|
@click="handleDeleteSession(session, $event)"
|
||||||
|
class="hidden group-hover:flex w-6 h-6 items-center justify-center rounded-md text-white/30 hover:text-red-400 hover:bg-red-500/20 transition-colors"
|
||||||
|
title="删除会话"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mt-1 pl-6">
|
||||||
|
<span class="text-xs text-orange-500/70">{{ getAgentName(session.agent_id) }}</span>
|
||||||
|
<span class="text-xs text-white/30">{{ formatRelativeTime(session.timestamp) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-white/30 mt-1 pl-6">{{ group.members.length }} members</div>
|
|
||||||
</button>
|
</button>
|
||||||
|
<div v-if="!chatSessions || chatSessions.length === 0" class="text-xs text-white/30 text-center py-4">
|
||||||
|
暂无会话记录
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onUnmounted } from 'vue'
|
||||||
import { Play, Pause, Edit, Trash2 } from 'lucide-vue-next'
|
import { Play, Pause, Edit, Trash2 } from 'lucide-vue-next'
|
||||||
import { useAgents } from './agents/useAgents'
|
import { useAgents } from './agents/useAgents'
|
||||||
import './agents/agents.css'
|
import './agents/agents.css'
|
||||||
@@ -57,8 +58,13 @@ const {
|
|||||||
getSkillsDisplayText,
|
getSkillsDisplayText,
|
||||||
toggleSkillSelection,
|
toggleSkillSelection,
|
||||||
selectAllSkills,
|
selectAllSkills,
|
||||||
statusClass
|
statusClass,
|
||||||
|
cleanup
|
||||||
} = useAgents()
|
} = useAgents()
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -162,18 +168,18 @@ const {
|
|||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
:title="agent.status === 'active' ? 'Deactivate' : 'Activate'"
|
:title="agent.status === 'active' ? 'Deactivate' : 'Activate'"
|
||||||
>
|
>
|
||||||
<Pause v-if="agent.status === 'active'" class="w-4 h-4 text-gray-500 hover:text-yellow-400 transition-colors" />
|
<Pause v-if="agent.status === 'active'" class="w-4 h-4 text-gray-400 hover:text-yellow-400 transition-colors" />
|
||||||
<Play v-else class="w-4 h-4 text-gray-500 hover:text-green-400 transition-colors" />
|
<Play v-else class="w-4 h-4 text-gray-400 hover:text-green-400 transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
<button @click="openEdit(agent)" class="btn-icon" title="Edit">
|
<button @click="openEdit(agent)" class="btn-icon" title="Edit">
|
||||||
<Edit class="w-4 h-4 text-gray-500 hover:text-white transition-colors" />
|
<Edit class="w-4 h-4 text-gray-400 hover:text-white transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click.stop="deleteAgent(agent.id)"
|
@click.stop="deleteAgent(agent.id)"
|
||||||
class="btn-icon"
|
class="btn-icon"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-4 h-4 text-gray-500 hover:text-red-400 transition-colors" />
|
<Trash2 class="w-4 h-4 text-gray-400 hover:text-red-500 transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<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 { useChat } from './chat/chat'
|
||||||
import ChatHeader from '@/components/chat/ChatHeader.vue'
|
import ChatHeader from '@/components/chat/ChatHeader.vue'
|
||||||
import ChatMessage from '@/components/chat/ChatMessage.vue'
|
import ChatMessage from '@/components/chat/ChatMessage.vue'
|
||||||
@@ -45,19 +46,181 @@ const {
|
|||||||
|
|
||||||
const messagesContainer = ref<HTMLElement | null>(null)
|
const messagesContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// Mock 流式响应(用于测试前端流式效果)
|
// @ 提及的智能体
|
||||||
const mockStreamResponse = async (content: string, messageIndex: number) => {
|
const mentionedAgents = ref<{ id: string | number; name: string; avatar: string }[]>([])
|
||||||
const chars = content.split('')
|
|
||||||
for (let i = 0; i < chars.length; i++) {
|
// 触发 @ 提及
|
||||||
messages.value[messageIndex].content += chars[i]
|
const onTriggerMention = () => {
|
||||||
await nextTick()
|
// 可以在这里打开智能体选择弹窗,或显示提示
|
||||||
scrollToBottom()
|
}
|
||||||
await new Promise(resolve => setTimeout(resolve, 15))
|
|
||||||
|
// 移除 @ 提及
|
||||||
|
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
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重置输入框
|
||||||
|
const resetInputHeight = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
const textarea = document.querySelector('.chat-input-textarea') as HTMLTextAreaElement
|
||||||
|
if (textarea) {
|
||||||
|
textarea.style.height = 'auto'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建用户消息对象
|
||||||
|
const createUserMessage = (content: string) => ({
|
||||||
|
id: Date.now(),
|
||||||
|
role: 'user' as const,
|
||||||
|
content,
|
||||||
|
timestamp: new Date()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建 AI 消息对象
|
||||||
|
const createAssistantMessage = () => ({
|
||||||
|
id: Date.now() + 1,
|
||||||
|
role: 'assistant' as const,
|
||||||
|
content: '',
|
||||||
|
timestamp: new Date(),
|
||||||
|
isStreaming: true
|
||||||
|
})
|
||||||
|
|
||||||
// 滚动到底部
|
// 滚动到底部
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
if (messagesContainer.value) {
|
if (messagesContainer.value) {
|
||||||
@@ -65,6 +228,11 @@ const scrollToBottom = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 监听消息变化,自动滚动到底部
|
||||||
|
watch(messages, () => {
|
||||||
|
nextTick(() => scrollToBottom())
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
// 切换模型下拉框
|
// 切换模型下拉框
|
||||||
const toggleModelDropdown = () => {
|
const toggleModelDropdown = () => {
|
||||||
showModelDropdown.value = !showModelDropdown.value
|
showModelDropdown.value = !showModelDropdown.value
|
||||||
@@ -76,133 +244,98 @@ const handleSelectModel = (model: any) => {
|
|||||||
showModelDropdown.value = false
|
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 () => {
|
const sendMessage = async () => {
|
||||||
if (!inputMessage.value.trim() || isLoading.value) return
|
if (!inputMessage.value.trim() || isLoading.value) return
|
||||||
|
|
||||||
|
// 如果没有会话,提示用户先选择智能体
|
||||||
|
if (!currentSessionId.value) {
|
||||||
|
ElMessage.warning('请先选择或创建一个会话')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const userContent = inputMessage.value.trim()
|
const userContent = inputMessage.value.trim()
|
||||||
inputMessage.value = ''
|
inputMessage.value = ''
|
||||||
|
mentionedAgents.value = []
|
||||||
|
resetInputHeight()
|
||||||
|
|
||||||
// 重置输入框高度
|
const userMessage = createUserMessage(userContent)
|
||||||
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()
|
|
||||||
}
|
|
||||||
messages.value.push(userMessage)
|
messages.value.push(userMessage)
|
||||||
|
|
||||||
// 保存用户消息到后端
|
|
||||||
await saveMessage('user', userContent)
|
await saveMessage('user', userContent)
|
||||||
|
|
||||||
const aiMessage = {
|
const aiMessage = createAssistantMessage()
|
||||||
id: Date.now() + 1,
|
|
||||||
role: 'assistant' as const,
|
|
||||||
content: '',
|
|
||||||
timestamp: new Date(),
|
|
||||||
isStreaming: true
|
|
||||||
}
|
|
||||||
messages.value.push(aiMessage)
|
messages.value.push(aiMessage)
|
||||||
|
|
||||||
nextTick(() => scrollToBottom())
|
nextTick(() => scrollToBottom())
|
||||||
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const requestBody: any = {
|
const response = await fetch('/api/agent/chat/stream', {
|
||||||
agent_id: String(selectedAgent.value?.id || 1),
|
|
||||||
message: userContent,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedModel.value) {
|
|
||||||
requestBody.model_id = selectedModel.value.id
|
|
||||||
}
|
|
||||||
|
|
||||||
// 传入 session_id
|
|
||||||
if (currentSessionId.value) {
|
|
||||||
requestBody.session_id = currentSessionId.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/agent/chat/stream`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify(buildRequestBody(userContent)),
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Request failed: ${response.status}`)
|
throw new Error(`Request failed: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 真正的流式处理:边读取边显示
|
await handleStreamResponse(response)
|
||||||
const reader = response.body.getReader()
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
let buffer = ''
|
|
||||||
const aiMessageIndex = messages.value.length - 1
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
|
||||||
|
|
||||||
const lines = buffer.split('\n')
|
|
||||||
buffer = lines.pop() || ''
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('data: ')) {
|
|
||||||
const data = line.slice(6).trim()
|
|
||||||
if (data && data !== '[DONE]') {
|
|
||||||
// 直接累加内容并显示(真正的流式)
|
|
||||||
messages.value[aiMessageIndex].content += data
|
|
||||||
await nextTick()
|
|
||||||
scrollToBottom()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理剩余buffer中的数据
|
|
||||||
if (buffer.startsWith('data: ')) {
|
|
||||||
const data = buffer.slice(6).trim()
|
|
||||||
if (data && data !== '[DONE]') {
|
|
||||||
messages.value[aiMessageIndex].content += data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.value[aiMessageIndex].isStreaming = false
|
|
||||||
isLoading.value = false
|
|
||||||
scrollToBottom()
|
|
||||||
|
|
||||||
// 保存 AI 消息到后端
|
|
||||||
await saveMessage('assistant', messages.value[aiMessageIndex].content)
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[Stream] 错误:', error)
|
handleMessageError(error)
|
||||||
const errorIndex = messages.value.findIndex(m => m.isStreaming)
|
|
||||||
if (errorIndex > -1) {
|
|
||||||
messages.value[errorIndex].content = `Error: ${error.message || 'Failed to send message'}`
|
|
||||||
messages.value[errorIndex].isStreaming = false
|
|
||||||
}
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
console.log('[Chat] Component mounted, calling init()')
|
|
||||||
init()
|
init()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -217,6 +350,7 @@ onUnmounted(() => {
|
|||||||
<div class="flex-1 flex flex-col bg-[#09090b]">
|
<div class="flex-1 flex flex-col bg-[#09090b]">
|
||||||
<!-- 顶部栏 -->
|
<!-- 顶部栏 -->
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
|
v-if="currentSessionId"
|
||||||
:selected-agent="selectedAgent"
|
:selected-agent="selectedAgent"
|
||||||
:chat-models="chatModels"
|
:chat-models="chatModels"
|
||||||
:selected-model="selectedModel"
|
:selected-model="selectedModel"
|
||||||
@@ -231,8 +365,26 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- 消息区域 -->
|
<!-- 消息区域 -->
|
||||||
<div ref="messagesContainer" class="flex-1 overflow-y-auto py-4">
|
<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-center">
|
||||||
<div class="text-5xl mb-4">{{ selectedAgent?.avatar || '🧠' }}</div>
|
<div class="text-5xl mb-4">{{ selectedAgent?.avatar || '🧠' }}</div>
|
||||||
<h2 class="text-xl font-semibold text-white mb-2">和 {{ selectedAgent?.name || 'AI' }} 开始对话</h2>
|
<h2 class="text-xl font-semibold text-white mb-2">和 {{ selectedAgent?.name || 'AI' }} 开始对话</h2>
|
||||||
@@ -250,16 +402,22 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 输入区域 -->
|
<!-- 输入区域 - 仅在有会话时显示 -->
|
||||||
<ChatInput
|
<ChatInput
|
||||||
|
v-if="currentSessionId"
|
||||||
v-model="inputMessage"
|
v-model="inputMessage"
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
|
:agents="chatAgents"
|
||||||
|
:mentioned-agents="mentionedAgents"
|
||||||
@send="sendMessage"
|
@send="sendMessage"
|
||||||
|
@trigger-mention="onTriggerMention"
|
||||||
|
@remove-mention="onRemoveMention"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧边栏 -->
|
<!-- 右侧边栏 - 仅在有会话时显示 -->
|
||||||
<ChatSidebar
|
<ChatSidebar
|
||||||
|
v-if="currentSessionId"
|
||||||
:collapsed="sidebarCollapsed"
|
:collapsed="sidebarCollapsed"
|
||||||
:chat-agents="chatAgents"
|
:chat-agents="chatAgents"
|
||||||
:selected-agent="selectedAgent"
|
:selected-agent="selectedAgent"
|
||||||
@@ -269,6 +427,7 @@ onUnmounted(() => {
|
|||||||
@select-agent="selectAgent"
|
@select-agent="selectAgent"
|
||||||
@select-session="selectSession"
|
@select-session="selectSession"
|
||||||
@select-group="selectGroup"
|
@select-group="selectGroup"
|
||||||
|
@delete-session="handleDeleteSession"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 智能体选择弹窗 -->
|
<!-- 智能体选择弹窗 -->
|
||||||
|
|||||||
@@ -1,70 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { Play, Pause, Edit, Trash2, Plus, Search, Clock } from 'lucide-vue-next'
|
import { Play, Pause, Edit, Trash2, Plus, Search, Clock } from 'lucide-vue-next'
|
||||||
|
import { usePlan } from './plan/usePlan'
|
||||||
|
import './plan/plan.css'
|
||||||
|
|
||||||
// Mock scheduled tasks data
|
const {
|
||||||
const tasks = ref([
|
filterStatus,
|
||||||
{
|
searchQuery,
|
||||||
id: 1,
|
filteredTasks,
|
||||||
name: 'Human-like Heartbeat',
|
} = usePlan()
|
||||||
status: 'running',
|
|
||||||
triggerType: 'Interval 30 minutes',
|
|
||||||
nextRun: '2026/03/10 16:26',
|
|
||||||
lastRun: '2026/03/10 15:56',
|
|
||||||
notifyChannel: '-',
|
|
||||||
executionCount: 13,
|
|
||||||
description: 'Check if proactive messages need to be sent (greetings/reminders/follow-ups)',
|
|
||||||
tags: ['System Task', 'Agent Task', 'Interval']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'Memory Organization',
|
|
||||||
status: 'running',
|
|
||||||
triggerType: 'Interval 3 hours',
|
|
||||||
nextRun: '2026/03/10 18:35',
|
|
||||||
lastRun: '2026/03/10 15:35',
|
|
||||||
notifyChannel: '-',
|
|
||||||
executionCount: 2,
|
|
||||||
description: 'Execute memory organization: organize chat history, extract key memories, refresh MEMORY.md',
|
|
||||||
tags: ['System Task', 'Agent Task', 'Interval']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'System Self-Check',
|
|
||||||
status: 'running',
|
|
||||||
triggerType: 'Daily 04:00',
|
|
||||||
nextRun: '2026/03/11 04:00',
|
|
||||||
lastRun: 'Never',
|
|
||||||
notifyChannel: '-',
|
|
||||||
executionCount: 0,
|
|
||||||
description: 'Execute system self-check: analyze ERROR logs, try to fix tool issues, generate report',
|
|
||||||
tags: ['System Task', 'Agent Task', 'Daily']
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const filterStatus = ref('all') // running, stopped, all
|
|
||||||
const searchQuery = ref('')
|
|
||||||
|
|
||||||
const filteredTasks = computed(() => {
|
|
||||||
let result = tasks.value
|
|
||||||
if (filterStatus.value !== 'all') {
|
|
||||||
result = result.filter(t => t.status === filterStatus.value)
|
|
||||||
}
|
|
||||||
if (searchQuery.value) {
|
|
||||||
const query = searchQuery.value.toLowerCase()
|
|
||||||
result = result.filter(t =>
|
|
||||||
t.name.toLowerCase().includes(query) ||
|
|
||||||
t.description.toLowerCase().includes(query)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const getTaskCount = (status: string) => {
|
|
||||||
if (status === 'running') return tasks.value.filter(t => t.status === 'running').length
|
|
||||||
if (status === 'stopped') return tasks.value.filter(t => t.status === 'stopped').length
|
|
||||||
return tasks.value.length
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -170,99 +113,3 @@ const getTaskCount = (status: string) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.plan-page {
|
|
||||||
background-color: #0f1419;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
background-color: #1f2937;
|
|
||||||
border: 1px solid #374151;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px 12px 10px 36px;
|
|
||||||
color: white;
|
|
||||||
font-size: 14px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input:focus {
|
|
||||||
border-color: #f97316;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input::placeholder {
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-row {
|
|
||||||
border-top: 1px solid #2a2a3a;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-row:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-tag {
|
|
||||||
background-color: #374151;
|
|
||||||
color: #d1d5db;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ml-13 {
|
|
||||||
margin-left: 3.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 空状态样式 */
|
|
||||||
.empty-box {
|
|
||||||
min-height: 340px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: linear-gradient(135deg, #1f2937, #111827);
|
|
||||||
border-radius: 24px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon i {
|
|
||||||
font-size: 40px;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-text {
|
|
||||||
color: #d1d5db;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-tip {
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ async function createAgent() {
|
|||||||
const skillsLabels = newAgent.value.selectedSkills.map(id => getSkillLabel(id)).join(', ')
|
const skillsLabels = newAgent.value.selectedSkills.map(id => getSkillLabel(id)).join(', ')
|
||||||
|
|
||||||
agents.value.unshift({
|
agents.value.unshift({
|
||||||
id: result.agent_id,
|
id: result.agent_id_str || result.agent_id,
|
||||||
name: newAgent.value.name,
|
name: newAgent.value.name,
|
||||||
avatar: newAgent.value.avatar,
|
avatar: newAgent.value.avatar,
|
||||||
description: newAgent.value.description,
|
description: newAgent.value.description,
|
||||||
@@ -341,6 +341,7 @@ async function saveEdit() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: editingAgent.value.name,
|
name: editingAgent.value.name,
|
||||||
description: editingAgent.value.description,
|
description: editingAgent.value.description,
|
||||||
|
avatar: editingAgent.value.avatar,
|
||||||
skills: skills,
|
skills: skills,
|
||||||
role_description: editingAgent.value.prompt,
|
role_description: editingAgent.value.prompt,
|
||||||
model_provider: selectedModel?.provider || '',
|
model_provider: selectedModel?.provider || '',
|
||||||
@@ -353,6 +354,7 @@ async function saveEdit() {
|
|||||||
if (agent) {
|
if (agent) {
|
||||||
agent.name = editingAgent.value.name
|
agent.name = editingAgent.value.name
|
||||||
agent.description = editingAgent.value.description
|
agent.description = editingAgent.value.description
|
||||||
|
agent.avatar = editingAgent.value.avatar
|
||||||
agent.skills = editingAgent.value.skillsMode === 'all' ? '*' : editingAgent.value.selectedSkills.join(', ')
|
agent.skills = editingAgent.value.skillsMode === 'all' ? '*' : editingAgent.value.selectedSkills.join(', ')
|
||||||
agent.model = selectedModel?.name || ''
|
agent.model = selectedModel?.name || ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,3 +49,159 @@
|
|||||||
.agent-glow {
|
.agent-glow {
|
||||||
animation: pulse-glow 2s ease-in-out infinite;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface ChatMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
id: number
|
id: string | number
|
||||||
name: string
|
name: string
|
||||||
avatar: string
|
avatar: string
|
||||||
description: string
|
description: string
|
||||||
@@ -40,7 +40,7 @@ export interface ChatSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupChat {
|
export interface GroupChat {
|
||||||
id: number
|
id: string | number
|
||||||
name: string
|
name: string
|
||||||
members: string[]
|
members: string[]
|
||||||
lastMessage: string
|
lastMessage: string
|
||||||
@@ -82,7 +82,6 @@ export const renderMarkdown = (content: string): string => {
|
|||||||
const processed = preprocessContent(content)
|
const processed = preprocessContent(content)
|
||||||
return marked.parse(processed) as string
|
return marked.parse(processed) as string
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Markdown parse error:', e)
|
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,24 +149,21 @@ export function useChat() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`/model/list`)
|
const response = await fetch(`/model/list`)
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
console.log('[Chat] Raw models:', data.list)
|
|
||||||
|
|
||||||
if (data.list) {
|
if (data.list) {
|
||||||
// 只过滤出 active 的 chat 模型 (status: 1=active, 0=inactive)
|
// 只过滤出 active 的 chat 模型 (status: 1=active, 0=inactive)
|
||||||
const activeChatModels = data.list.filter((m: ChatModel) =>
|
const activeChatModels = data.list.filter((m: ChatModel) =>
|
||||||
m.model_type === 'chat' && m.status === 1
|
m.model_type === 'chat' && m.status === 1
|
||||||
)
|
)
|
||||||
console.log('[Chat] Filtered chat models:', activeChatModels)
|
|
||||||
chatModels.value = activeChatModels
|
chatModels.value = activeChatModels
|
||||||
|
|
||||||
// 默认选中第一个 active 的 chat 模型
|
// 默认选中第一个 active 的 chat 模型
|
||||||
if (chatModels.value.length > 0) {
|
if (chatModels.value.length > 0) {
|
||||||
selectedModel.value = chatModels.value[0]
|
selectedModel.value = chatModels.value[0]
|
||||||
console.log('[Chat] Selected model:', selectedModel.value)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to fetch models:', error)
|
// 静默处理
|
||||||
} finally {
|
} finally {
|
||||||
modelsLoading.value = false
|
modelsLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -178,7 +174,6 @@ export function useChat() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/agent/list')
|
const response = await fetch('/api/agent/list')
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
console.log('[Chat] Agents:', data)
|
|
||||||
|
|
||||||
if (data.agents) {
|
if (data.agents) {
|
||||||
chatAgents.value = data.agents.map((agent: any) => ({
|
chatAgents.value = data.agents.map((agent: any) => ({
|
||||||
@@ -186,9 +181,9 @@ export function useChat() {
|
|||||||
name: agent.name,
|
name: agent.name,
|
||||||
avatar: agent.avatar || '🧠',
|
avatar: agent.avatar || '🧠',
|
||||||
description: agent.description || '',
|
description: agent.description || '',
|
||||||
accentColor: agent.accent_color || '#f97316',
|
accentColor: '#f97316',
|
||||||
gradient: 'from-orange-500/20 to-amber-500/20',
|
gradient: 'from-orange-500/20 to-amber-500/20',
|
||||||
status: agent.status === 'active' ? 'online' : 'offline'
|
status: agent.is_active ? 'online' : 'offline'
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 默认选中第一个智能体
|
// 默认选中第一个智能体
|
||||||
@@ -196,8 +191,8 @@ export function useChat() {
|
|||||||
selectedAgent.value = chatAgents.value[0]
|
selectedAgent.value = chatAgents.value[0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to fetch agents:', error)
|
// 静默处理
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +202,6 @@ export function useChat() {
|
|||||||
const userId = localStorage.getItem('user_id') || 'default-user'
|
const userId = localStorage.getItem('user_id') || 'default-user'
|
||||||
const response = await fetch(`/api/chat/sessions?user_id=${userId}&limit=50`)
|
const response = await fetch(`/api/chat/sessions?user_id=${userId}&limit=50`)
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
console.log('[Chat] Sessions:', data)
|
|
||||||
|
|
||||||
if (data.list) {
|
if (data.list) {
|
||||||
chatSessions.value = data.list.map((s: any) => ({
|
chatSessions.value = data.list.map((s: any) => ({
|
||||||
@@ -219,9 +213,13 @@ export function useChat() {
|
|||||||
timestamp: new Date(s.created_at || Date.now()),
|
timestamp: new Date(s.created_at || Date.now()),
|
||||||
status: s.status
|
status: s.status
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// 自动选择最近的会话并加载消息
|
||||||
|
// 页面加载时不自动选择会话,显示空页面
|
||||||
|
// 用户点击"新建聊天"或选择智能体时才创建会话
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to fetch sessions:', error)
|
// 静默处理
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +229,6 @@ export function useChat() {
|
|||||||
const userId = localStorage.getItem('user_id') || 'default-user'
|
const userId = localStorage.getItem('user_id') || 'default-user'
|
||||||
const response = await fetch(`/api/chat/groups?user_id=${userId}`)
|
const response = await fetch(`/api/chat/groups?user_id=${userId}`)
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
console.log('[Chat] Groups:', data)
|
|
||||||
|
|
||||||
if (data.list) {
|
if (data.list) {
|
||||||
groupChats.value = data.list.map((g: any) => ({
|
groupChats.value = data.list.map((g: any) => ({
|
||||||
@@ -242,13 +239,13 @@ export function useChat() {
|
|||||||
timestamp: new Date(g.created_at || Date.now())
|
timestamp: new Date(g.created_at || Date.now())
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to fetch groups:', error)
|
// 静默处理
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建群聊
|
// 创建群聊
|
||||||
const createGroup = async (name: string, agentIds: number[]) => {
|
const createGroup = async (name: string, agentIds: (string | number)[]) => {
|
||||||
try {
|
try {
|
||||||
const userId = localStorage.getItem('user_id') || 'default-user'
|
const userId = localStorage.getItem('user_id') || 'default-user'
|
||||||
const response = await fetch('/api/chat/groups', {
|
const response = await fetch('/api/chat/groups', {
|
||||||
@@ -260,8 +257,12 @@ export function useChat() {
|
|||||||
agent_ids: JSON.stringify(agentIds)
|
agent_ids: JSON.stringify(agentIds)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Create group failed:', response.status, await response.text())
|
||||||
|
return null
|
||||||
|
}
|
||||||
const group = await response.json()
|
const group = await response.json()
|
||||||
console.log('[Chat] Created group:', group)
|
|
||||||
|
|
||||||
// 添加到群聊列表
|
// 添加到群聊列表
|
||||||
groupChats.value.unshift({
|
groupChats.value.unshift({
|
||||||
@@ -273,8 +274,7 @@ export function useChat() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return group
|
return group
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to create group:', error)
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,8 +293,12 @@ export function useChat() {
|
|||||||
model_id: selectedModel.value?.id
|
model_id: selectedModel.value?.id
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const session = await response.json()
|
const session = await response.json()
|
||||||
console.log('[Chat] Created session:', session)
|
|
||||||
|
|
||||||
// 添加到会话列表
|
// 添加到会话列表
|
||||||
chatSessions.value.unshift({
|
chatSessions.value.unshift({
|
||||||
@@ -305,10 +309,9 @@ export function useChat() {
|
|||||||
timestamp: new Date()
|
timestamp: new Date()
|
||||||
})
|
})
|
||||||
|
|
||||||
currentSessionId.value = session.id
|
saveSessionId(session.id)
|
||||||
return session
|
return session
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to create session:', error)
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,7 +321,6 @@ export function useChat() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/chat/sessions/${sessionId}/messages?limit=100`)
|
const response = await fetch(`/api/chat/sessions/${sessionId}/messages?limit=100`)
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
console.log('[Chat] Messages:', data)
|
|
||||||
|
|
||||||
if (data.list) {
|
if (data.list) {
|
||||||
messages.value = data.list.map((m: any) => ({
|
messages.value = data.list.map((m: any) => ({
|
||||||
@@ -328,27 +330,37 @@ export function useChat() {
|
|||||||
timestamp: new Date(m.created_at)
|
timestamp: new Date(m.created_at)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to fetch messages:', error)
|
// 静默处理
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存消息到后端
|
// 保存消息到后端
|
||||||
const saveMessage = async (role: 'user' | 'assistant', content: string) => {
|
const saveMessage = async (role: 'user' | 'assistant', content: string) => {
|
||||||
if (!currentSessionId.value) return
|
const sessionId = currentSessionId.value
|
||||||
|
if (!sessionId || typeof sessionId !== 'string' || sessionId.trim() === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查内容是否有效
|
||||||
|
if (!content || typeof content !== 'string' || content.trim() === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
session_id: sessionId,
|
||||||
|
role: role,
|
||||||
|
content: content
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch('/api/chat/messages', {
|
await fetch('/api/chat/messages', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload)
|
||||||
session_id: currentSessionId.value,
|
|
||||||
role: role,
|
|
||||||
content: content
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to save message:', error)
|
// 静默处理
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,8 +372,8 @@ export function useChat() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title })
|
body: JSON.stringify({ title })
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to update session:', error)
|
// 静默处理
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,8 +388,8 @@ export function useChat() {
|
|||||||
currentSessionId.value = null
|
currentSessionId.value = null
|
||||||
messages.value = []
|
messages.value = []
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to delete session:', error)
|
// 静默处理
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +445,24 @@ export function useChat() {
|
|||||||
// 调用后端 API 创建群聊
|
// 调用后端 API 创建群聊
|
||||||
const group = await createGroup(name, agentIds)
|
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 调用失败,使用本地数据
|
// 如果 API 调用失败,使用本地数据
|
||||||
groupChats.value.unshift({
|
groupChats.value.unshift({
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
@@ -452,34 +481,82 @@ export function useChat() {
|
|||||||
showAgentSelector.value = false
|
showAgentSelector.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选择助手
|
// 选择助手 - 如果是同一智能体则不创建新会话
|
||||||
const selectAgent = (agent: Agent) => {
|
const selectAgent = async (agent: Agent) => {
|
||||||
|
// 如果选择的是同一智能体,不创建新会话,直接返回
|
||||||
|
if (selectedAgent.value?.id === agent.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
selectedAgent.value = agent
|
selectedAgent.value = agent
|
||||||
|
|
||||||
|
// 创建新会话
|
||||||
|
const session = await createSession(`与 ${agent.name} 的对话`)
|
||||||
|
if (session) {
|
||||||
|
currentSessionId.value = session.id
|
||||||
|
}
|
||||||
|
|
||||||
messages.value = [
|
messages.value = [
|
||||||
{ id: 1, role: 'assistant', content: `你好!我是 ${agent.name}。有什么我可以帮助你的吗?`, timestamp: new Date() }
|
{ id: Date.now(), role: 'assistant', content: `你好!我是 ${agent.name},你的 AI 助手。有什么我可以帮助你的吗?`, timestamp: new Date() }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 保存助手欢迎消息
|
||||||
|
if (currentSessionId.value) {
|
||||||
|
await saveMessage('assistant', messages.value[0].content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选择群聊
|
// 选择群聊
|
||||||
const selectGroup = (group: GroupChat) => {
|
const selectGroup = async (group: GroupChat) => {
|
||||||
|
// 创建新会话
|
||||||
|
const session = await createSession(group.name)
|
||||||
|
if (session) {
|
||||||
|
currentSessionId.value = session.id
|
||||||
|
}
|
||||||
|
|
||||||
messages.value = [
|
messages.value = [
|
||||||
{ id: 1, role: 'assistant', content: `你好!欢迎进入群聊 "${group.name}",${group.members.length} 位智能体已加入。`, timestamp: new Date() }
|
{ id: Date.now(), role: 'assistant', content: `你好!欢迎进入群聊 "${group.name}",${group.members.length} 位智能体已加入。`, timestamp: new Date() }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 保存助手欢迎消息
|
||||||
|
if (currentSessionId.value) {
|
||||||
|
await saveMessage('assistant', messages.value[0].content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选择历史对话
|
// 选择历史对话
|
||||||
|
// 保存会话 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 selectSession = async (session: ChatSession) => {
|
||||||
const agent = chatAgents.value.find(a => a.id === session.agent_id)
|
const agent = chatAgents.value.find(a => a.id === session.agent_id)
|
||||||
if (agent) {
|
if (agent) {
|
||||||
selectedAgent.value = agent
|
selectedAgent.value = agent
|
||||||
}
|
}
|
||||||
|
|
||||||
currentSessionId.value = session.id
|
saveSessionId(session.id)
|
||||||
await fetchSessionMessages(session.id)
|
await fetchSessionMessages(session.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新建聊天 - 先打开智能体选择器
|
// 新建聊天 - 先打开智能体选择器
|
||||||
const newChat = () => {
|
const newChat = () => {
|
||||||
|
// 清除当前会话 ID(新建会话时会重新设置)
|
||||||
|
currentSessionId.value = null
|
||||||
|
localStorage.removeItem('current_session_id')
|
||||||
// 打开智能体选择器,让用户选择智能体
|
// 打开智能体选择器,让用户选择智能体
|
||||||
openAgentSelector('single')
|
openAgentSelector('single')
|
||||||
}
|
}
|
||||||
@@ -521,12 +598,15 @@ export function useChat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
const init = () => {
|
const init = async () => {
|
||||||
fetchModels()
|
fetchModels()
|
||||||
fetchAgents()
|
fetchAgents()
|
||||||
fetchSessions()
|
await fetchSessions()
|
||||||
fetchGroups()
|
fetchGroups()
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
|
||||||
|
// 恢复之前选中的会话
|
||||||
|
await restoreSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理
|
// 清理
|
||||||
|
|||||||
92
web/src/views/plan/plan.css
Normal file
92
web/src/views/plan/plan.css
Normal 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;
|
||||||
|
}
|
||||||
87
web/src/views/plan/usePlan.ts
Normal file
87
web/src/views/plan/usePlan.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user