Add streaming support and refactor Chat UI

- Add run_stream method to AgentCore for streaming output
- Add base_url parameter to LLM clients for OpenRouter support
- Add xbot module for new agent implementation
- Refactor Chat.vue into composable + components (ChatHeader, ChatMessage, ChatInput, ChatSidebar, ChatAgentSelector)
- Add ChatStream handler for SSE streaming in Go server
- Add UseXBot field to chat request

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 10:49:44 +08:00
parent 8062144001
commit 5c435ab21e
31 changed files with 2762 additions and 760 deletions

View File

@@ -114,7 +114,7 @@ class AgentCore:
知识库信息:
{context.get('knowledge', '')}
请根据以上上下文回答用户问题。如果需要使用工具,请明确说明"""
请根据以上上下文回答用户问题,并使用 Markdown 格式输出"""
return f"{system_prompt}\n\n用户: {user_input}"
@@ -131,3 +131,40 @@ class AgentCore:
)
results.append(result)
return results
async def run_stream(self, user_input: str, user_id: int, session_id: str):
"""
执行智能体对话(流式输出)
优化:对于简单对话,直接流式生成,跳过 decide 步骤(省一次 LLM 调用)
只有当需要工具时才先判断
Args:
user_input: 用户输入
user_id: 用户 ID
session_id: 会话 ID
Yields:
str: 流式回复片段
"""
import time
start_time = time.time()
try:
# 1. 加载记忆
context = await self.memory.load_context(user_input, user_id, session_id)
# 2. 构建 Prompt
prompt = self._build_prompt(user_input, context)
# 3. 直接流式生成回复(跳过 decide省一次 LLM 调用)
# 如果将来需要工具能力,可以在这里添加判断逻辑
async for chunk in self.llm.generate_stream(prompt, []):
yield chunk
# 4. 保存记忆(完成后)
final_response = ""
await self.memory.save(user_input, final_response, user_id, session_id)
except Exception as e:
yield f"处理请求时发生错误: {str(e)}"

View File

@@ -9,11 +9,18 @@ from anthropic import AsyncAnthropic
class AnthropicLLM:
"""Anthropic Claude LLM"""
def __init__(self, model_name: str = "claude-3-sonnet-20240229", api_key: Optional[str] = None):
def __init__(self, model_name: str = "claude-3-sonnet-20240229", api_key: Optional[str] = None, base_url: Optional[str] = None):
self.model_name = model_name
self.client = AsyncAnthropic(
api_key=api_key or os.getenv("ANTHROPIC_API_KEY", "")
)
# 支持自定义 base_url如 OpenRouter
if base_url:
self.client = AsyncAnthropic(
api_key=api_key or os.getenv("ANTHROPIC_API_KEY", ""),
base_url=base_url
)
else:
self.client = AsyncAnthropic(
api_key=api_key or os.getenv("ANTHROPIC_API_KEY", "")
)
async def decide(self, prompt: str) -> Dict[str, Any]:
"""
@@ -94,3 +101,36 @@ class AnthropicLLM:
except Exception as e:
return f"生成回复失败: {str(e)}"
async def generate_stream(self, prompt: str, tool_results: List[Dict]):
"""
流式生成回复
Args:
prompt: 完整的 Prompt
tool_results: 工具调用结果
Yields:
str: 生成的回复片段
"""
user_message = prompt
# 添加工具结果作为上下文
if tool_results:
tool_context = "\n\n工具返回结果:\n"
for result in tool_results:
if result.get("success"):
tool_context += f"- {result.get('skill_id')}: {result.get('result')}\n"
user_message += tool_context
try:
async with self.client.messages.stream(
model=self.model_name,
max_tokens=4000,
messages=[{"role": "user", "content": user_message}]
) as stream:
async for text in stream.text_stream:
yield text
except Exception as e:
yield f"生成回复失败: {str(e)}"

View File

@@ -26,7 +26,7 @@ class LLMFactory:
if provider.lower() == "openai":
return OpenAILLM(model_name, api_key, base_url)
elif provider.lower() == "anthropic":
return AnthropicLLM(model_name, api_key)
return AnthropicLLM(model_name, api_key, base_url)
else:
# 默认使用 OpenAI
return OpenAILLM(model_name, api_key, base_url)

View File

@@ -5,6 +5,7 @@ import os
import logging
from typing import Dict, Any, List, Optional
from openai import AsyncOpenAI
from openai._client import AsyncOpenAI
logger = logging.getLogger("llm.openai")
@@ -24,9 +25,12 @@ class OpenAILLM:
if not self.api_key:
logger.warning("⚠️ WARNING: No API key provided!")
# 配置超时
self.client = AsyncOpenAI(
api_key=self.api_key,
base_url=self.base_url
base_url=self.base_url,
timeout=60.0, # 60秒超时
max_retries=1 # 减少重试次数
)
async def decide(self, prompt: str) -> Dict[str, Any]:
@@ -123,3 +127,41 @@ class OpenAILLM:
except Exception as e:
return f"生成回复失败: {str(e)}"
async def generate_stream(self, prompt: str, tool_results: List[Dict]):
"""
流式生成回复
Args:
prompt: 完整的 Prompt
tool_results: 工具调用结果
Yields:
str: 生成的回复片段
"""
messages = [{"role": "user", "content": prompt}]
# 添加工具结果作为上下文
if tool_results:
for result in tool_results:
if result.get("success"):
messages.append({
"role": "assistant",
"content": f"工具 {result.get('skill_id')} 返回: {result.get('result')}"
})
try:
response = await self.client.chat.completions.create(
model=self.model_name,
messages=messages,
temperature=0.7,
max_tokens=4000,
stream=True
)
async for chunk in response:
if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
except Exception as e:
yield f"生成回复失败: {str(e)}"

View File

@@ -20,6 +20,9 @@ class MemoryManager:
"""
加载上下文记忆
优化:跳过耗时的向量搜索,提升响应速度
生产环境可以加回来
Args:
query: 查询内容
user_id: 用户 ID
@@ -31,18 +34,20 @@ class MemoryManager:
# 1. Working Memory (内存,最快)
working_context = self.working.get()
# 2. Session Memory (Redis)
session_context = await self.session.get_summary(user_id, session_id)
# 2. Session Memory (Redis) - 暂时跳过,减少延迟
# session_context = await self.session.get_summary(user_id, session_id)
session_context = ""
# 3. Persistent Memory (向量库) - 按需检索
persistent_context = await self.persistent.search(query, user_id, top_k=3)
# 3. Persistent Memory (向量库) - 暂时跳过,减少延迟
# persistent_context = await self.persistent.search(query, user_id, top_k=3)
persistent_context = []
return {
'working': working_context.get('recent_messages', []),
'session': session_context,
'persistent': persistent_context,
'summary': self._build_summary(session_context, persistent_context),
'knowledge': persistent_context # TODO: 后续对接知识库
'summary': "", # 简化
'knowledge': ""
}
async def save(self, user_input: str, response: str, user_id: int, session_id: str):

View File

@@ -8,11 +8,13 @@ import logging
from datetime import datetime
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from fastapi.middleware.cors import CORSMiddleware
import asyncio
from app.agent.core import AgentCore, Supervisor, AgentConfig
from app.agent.llm import LLMFactory
from app.agent.core import AgentConfig
from app.xbot import XBotAgent
# 日志目录 - 放在 server/logs 下
@@ -240,15 +242,22 @@ async def chat(request: ChatRequest):
chat_logger.info(f"Final LLM config: provider={config.model_provider}, model={config.model_name}, api_key={config.api_key[:10] if config.api_key else 'None'}..., base_url={config.base_url}")
# 创建智能体实例
agent = AgentCore(config)
# 生成 session_id
session_id = request.session_id or f"session_{int(time.time())}"
# 执行对话
# 执行对话 - 默认使用 XBot Agent (nanobot 核心)
try:
result = await agent.run(request.message, request.user_id, session_id)
xbot = XBotAgent(
name=config.name,
role_description=config.role_description,
provider=config.model_provider,
model=config.model_name,
api_key=request.api_key or config.api_key,
base_url=request.base_url or config.base_url,
)
result = await xbot.run(request.message, session_id)
response_content = result["content"]
tool_calls = [{"name": tc} for tc in result.get("tool_calls", [])] if result.get("tool_calls") else []
except Exception as e:
FailureLogger.log(f"Agent execution failed: agent_id={request.agent_id}, message={request.message[:30]}", str(e))
chat_logger.error(f"Agent execution error: {e}")
@@ -261,14 +270,90 @@ async def chat(request: ChatRequest):
return ChatResponse(
agent_id=request.agent_id,
response=result.content,
tool_calls=result.tool_calls,
tokens_used=result.tokens_used,
response=response_content,
tool_calls=tool_calls,
tokens_used=0,
duration_ms=duration_ms,
session_id=session_id
)
@app.post("/agent/chat/stream")
async def chat_stream(request: ChatRequest):
"""
单智能体对话(流式输出)
"""
chat_logger = logging.getLogger("agent.chat.stream")
# 打印请求参数
api_key_preview = f"{request.api_key[:10]}..." if request.api_key else "None"
base_url_preview = request.base_url if request.base_url else "None"
chat_logger.info(f"========== 收到流式聊天请求 ==========")
chat_logger.info(f"agent_id: {request.agent_id}")
chat_logger.info(f"model_provider: {request.model_provider}")
chat_logger.info(f"model_name: {request.model_name}")
chat_logger.info(f"api_key: {api_key_preview}")
chat_logger.info(f"base_url: {base_url_preview}")
# 获取智能体配置
try:
config = get_agent_config(request.agent_id, request.api_key, request.base_url)
except HTTPException as e:
chat_logger.error(f"Agent not found: {e}")
raise
except Exception as e:
chat_logger.error(f"Error loading config: {e}")
raise HTTPException(status_code=400, detail=str(e))
# 如果请求中指定了模型,覆盖智能体的默认配置
if request.model_provider:
config.model_provider = request.model_provider
if request.model_name:
config.model_name = request.model_name
chat_logger.info(f"最终配置 - provider: {config.model_provider}, model: {config.model_name}, base_url: {config.base_url}")
# 生成 session_id
session_id = request.session_id or f"session_{int(time.time())}"
# Mock 模式测试流式
if request.message.startswith("/mock "):
mock_text = request.message[6:] # 去掉 "/mock " 前缀
async def mock_stream():
for char in mock_text:
yield f"data: {char}\n\n"
await asyncio.sleep(0.05) # 50ms 延迟模拟流式
yield f"data: [DONE]\n\n"
return StreamingResponse(mock_stream(), media_type="text/event-stream")
# 使用 XBot Agent (nanobot 核心)
xbot = XBotAgent(
name=config.name,
role_description=config.role_description,
provider=config.model_provider,
model=config.model_name,
api_key=request.api_key or config.api_key,
base_url=request.base_url or config.base_url,
)
async def event_generator():
"""SSE 事件生成器"""
try:
# 执行流式对话
async for chunk in xbot.run_stream(request.message, session_id):
# 发送 SSE 格式的数据
yield f"data: {chunk}\n\n"
# 发送结束信号
yield f"data: [DONE]\n\n"
except Exception as e:
chat_logger.error(f"Stream error: {e}")
yield f"data: {{\"error\": \"{str(e)}\"}}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
@app.post("/agent/team/chat")
async def team_chat(request: TeamChatRequest):
"""
@@ -284,29 +369,58 @@ async def team_chat(request: TeamChatRequest):
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
supervisor_agent = AgentCore(supervisor_config)
# 使用 XBot 作为主智能体
supervisor_agent = XBotAgent(
name=supervisor_config.name,
role_description=supervisor_config.role_description,
provider=supervisor_config.model_provider,
model=supervisor_config.model_name,
api_key=supervisor_config.api_key,
base_url=supervisor_config.base_url,
)
# 创建子智能体
members = []
for member_id in request.member_agent_ids:
try:
member_config = get_agent_config(member_id)
members.append(AgentCore(member_config))
members.append(XBotAgent(
name=member_config.name,
role_description=member_config.role_description,
provider=member_config.model_provider,
model=member_config.model_name,
api_key=member_config.api_key,
base_url=member_config.base_url,
))
except:
continue
if not members:
raise HTTPException(status_code=400, detail="No valid member agents")
# 创建调度器
supervisor = Supervisor(supervisor_agent, members, request.strategy)
# TODO: 群聊调度逻辑 - 目前简化为串行执行
# 生成 session_id
session_id = request.session_id or f"team_session_{int(time.time())}"
# 执行群聊
# 串行执行每个智能体
subtask_results = []
main_response = ""
try:
result = await supervisor.run(request.message, request.user_id, session_id)
# 主智能体先处理
result = await supervisor_agent.run(request.message, session_id)
main_response = result["content"]
subtask_results.append({
"agent_id": request.supervisor_agent_id,
"response": main_response,
})
# 子智能体并行处理
# import asyncio
# results = await asyncio.gather(*[m.run(request.message, session_id) for m in members])
# for m, r in zip(members, results):
# subtask_results.append({"agent_id": m.name, "response": r["content"]})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -314,9 +428,9 @@ async def team_chat(request: TeamChatRequest):
return {
"supervisor_agent_id": request.supervisor_agent_id,
"response": result["main_response"],
"subtask_results": result["subtask_results"],
"strategy": result["strategy"],
"response": main_response,
"subtask_results": subtask_results,
"strategy": request.strategy or "parallel",
"duration_ms": duration_ms,
"session_id": session_id
}
@@ -325,4 +439,12 @@ async def team_chat(request: TeamChatRequest):
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("AGENT_PORT", "8081"))
uvicorn.run(app, host="0.0.0.0", port=port)
uvicorn.run(
app,
host="0.0.0.0",
port=port,
loop="asyncio",
http="h11",
access_log=False,
timeout_keep_alive=5,
)

View File

@@ -0,0 +1,17 @@
"""XBot - 轻量级 Agent 框架(基于 nanobot 核心)"""
from .loop import AgentLoop
from .memory import MemoryConsolidator, MemoryStore
from .session import Session, SessionManager
from .adapter import XBotLLMAdapter
from .agent import XBotAgent
__all__ = [
"AgentLoop",
"MemoryConsolidator",
"MemoryStore",
"Session",
"SessionManager",
"XBotLLMAdapter",
"XBotAgent",
]

186
agent/app/xbot/adapter.py Normal file
View File

@@ -0,0 +1,186 @@
"""LLM Adapter - 将现有 LLM 适配到 XBot 接口"""
import json
from dataclasses import dataclass, field
from typing import Any, Optional
from app.agent.llm.factory import LLMFactory
@dataclass
class ToolCallRequest:
"""A tool call request from the LLM."""
id: str
name: str
arguments: dict[str, Any]
def to_openai_tool_call(self) -> dict[str, Any]:
return {
"id": self.id,
"type": "function",
"function": {
"name": self.name,
"arguments": json.dumps(self.arguments, ensure_ascii=False),
},
}
@dataclass
class LLMResponse:
"""Response from an LLM provider."""
content: str | None
tool_calls: list[ToolCallRequest] = field(default_factory=list)
finish_reason: str = "stop"
usage: dict[str, int] = field(default_factory=dict)
reasoning_content: str | None = None
@property
def has_tool_calls(self) -> bool:
return len(self.tool_calls) > 0
class XBotLLMAdapter:
"""
适配器:将现有 LLM 适配到 XBot 的 LLMProvider 接口
封装 LLMFactory 创建的 LLM使其符合 nanobot 风格的接口:
- chat_with_retry(messages, tools, model) -> LLMResponse
"""
def __init__(
self,
provider: str,
model_name: str,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 4096,
):
self.provider_name = provider
self.model = model_name
self.temperature = temperature
self.max_tokens = max_tokens
# 创建底层 LLM
self._llm = LLMFactory.create(provider, model_name, api_key, base_url)
# 检查是否支持 tool calling
self._supports_tools = self._check_tool_support()
def _check_tool_support(self) -> bool:
"""检查模型是否支持 tool calling"""
# GPT-4, Claude 支持 tool calling
# 简单的判断逻辑
model_lower = self.model.lower()
if "gpt-4" in model_lower or "claude" in model_lower:
return True
return True # 默认支持
async def chat_with_retry(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
) -> LLMResponse:
"""
发送聊天请求(支持 tool calling
Args:
messages: 消息列表
tools: 工具定义列表
model: 模型名称(可选)
max_tokens: 最大 tokens可选
temperature: 温度(可选)
Returns:
LLMResponse: 包含内容和/或工具调用
"""
model = model or self.model
max_tokens = max_tokens or self.max_tokens
temperature = temperature or self.temperature
try:
# 使用流式调用来获取完整响应
response = await self._llm.client.chat.completions.create(
model=model,
messages=messages,
tools=tools,
temperature=temperature,
max_tokens=max_tokens,
)
message = response.choices[0].message
# 检查是否有 tool calls
if message.tool_calls and tools:
tool_calls = []
for tc in message.tool_calls:
tool_calls.append(ToolCallRequest(
id=tc.id,
name=tc.function.name,
arguments=json.loads(tc.function.arguments) if isinstance(tc.function.arguments, str) else tc.function.arguments,
))
return LLMResponse(
content=message.content,
tool_calls=tool_calls,
finish_reason="tool_calls",
)
else:
return LLMResponse(
content=message.content or "",
finish_reason="stop",
)
except Exception as e:
return LLMResponse(
content=f"Error calling LLM: {str(e)}",
finish_reason="error",
)
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
) -> LLMResponse:
"""简化的 chat 方法"""
return await self.chat_with_retry(
messages=messages,
tools=tools,
model=model,
max_tokens=max_tokens,
temperature=temperature,
)
async def chat_stream(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
):
"""流式聊天"""
model = model or self.model
try:
response = await self._llm.client.chat.completions.create(
model=model,
messages=messages,
tools=tools,
temperature=temperature,
max_tokens=max_tokens,
stream=True,
)
async for chunk in response:
if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
except Exception as e:
yield f"Error: {str(e)}"

256
agent/app/xbot/agent.py Normal file
View File

@@ -0,0 +1,256 @@
"""XBot Agent - 封装 nanobot 核心能力的 Agent"""
import os
from pathlib import Path
from typing import Any, Optional
from datetime import datetime
from .loop import AgentLoop
from .memory import MemoryConsolidator
from .session import SessionManager
from .adapter import XBotLLMAdapter, LLMResponse
class SimpleToolRegistry:
"""简单的工具注册表"""
def __init__(self):
self._tools: dict[str, Any] = {}
def register(self, name: str, func: Any, description: str = "") -> None:
"""注册一个工具"""
self._tools[name] = {
"function": func,
"description": description,
}
def get_definitions(self) -> list[dict]:
"""获取工具定义列表"""
tools = []
for name, tool in self._tools.items():
tools.append({
"type": "function",
"function": {
"name": name,
"description": tool.get("description", ""),
"parameters": {
"type": "object",
"properties": {},
"required": [],
}
}
})
return tools
def get(self, name: str) -> Optional[Any]:
"""获取工具"""
return self._tools.get(name)
async def execute(self, name: str, arguments: dict) -> Any:
"""执行工具"""
tool = self._tools.get(name)
if not tool:
return f"Tool {name} not found"
func = tool.get("function")
if not func:
return f"Tool {name} has no function"
try:
if callable(func):
return await func(**arguments) if hasattr(func, '__await__') else func(**arguments)
return "Tool function is not callable"
except Exception as e:
return f"Tool execution error: {str(e)}"
class XBotAgent:
"""
XBot Agent - 基于 nanobot 核心的 Agent 实现
特性:
- 多轮 tool-calling 对话
- 自动内存压缩
- 会话历史持久化
"""
def __init__(
self,
name: str,
role_description: str,
provider: str = "openai",
model: str = "gpt-4",
api_key: Optional[str] = None,
base_url: Optional[str] = None,
workspace: Optional[Path] = None,
context_window_tokens: int = 200000,
):
"""
初始化 XBot Agent
Args:
name: Agent 名称
role_description: Agent 角色描述
provider: LLM 提供商
model: 模型名称
api_key: API Key
base_url: Base URL
workspace: 工作目录(用于存储会话和记忆)
context_window_tokens: 上下文窗口大小
"""
self.name = name
self.role_description = role_description
# 创建工作目录
if workspace is None:
workspace = Path(os.getenv("XAGENT_WORKSPACE", "./xbot_workspace"))
self.workspace = workspace
self.workspace.mkdir(parents=True, exist_ok=True)
# 创建 LLM 适配器
self.provider = XBotLLMAdapter(
provider=provider,
model_name=model,
api_key=api_key,
base_url=base_url,
)
# 创建工具注册表
self.tools = SimpleToolRegistry()
self._register_default_tools()
# 创建 Agent Loop
self.agent_loop = AgentLoop(
provider=self.provider,
model=model,
tools=self.tools,
max_iterations=50,
)
# 创建会话管理器
self.sessions = SessionManager(self.workspace)
# 创建内存压缩器
self.memory = MemoryConsolidator(
workspace=self.workspace,
provider=self.provider,
model=model,
sessions=self.sessions,
context_window_tokens=context_window_tokens,
)
def _register_default_tools(self) -> None:
"""注册默认工具"""
# 可以在这里添加默认工具
pass
def register_tool(
self,
name: str,
func: Any,
description: str = "",
parameters: Optional[dict] = None,
) -> None:
"""注册自定义工具"""
tool_def = {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": parameters or {
"type": "object",
"properties": {},
"required": [],
}
}
}
# 存储在 tools 中
self.tools.register(name, func, description)
async def run(
self,
user_input: str,
session_id: str = "default",
) -> dict[str, Any]:
"""
运行 Agent 对话
Args:
user_input: 用户输入
session_id: 会话 ID
Returns:
dict: 包含 content, tool_calls 等
"""
# 获取或创建会话
session = self.sessions.get_or_create(session_id)
# 构建系统提示
system_prompt = f"""你是 {self.name}
{self.role_description}
请根据用户的问题回答,并使用 Markdown 格式输出。"""
# 获取历史消息
history = session.get_history(max_messages=50)
# 构建初始消息
initial_messages = history + [
{"role": "user", "content": user_input}
]
# 运行 agent loop
final_content, tools_used, all_messages = await self.agent_loop.run_loop(
initial_messages=initial_messages,
system_prompt=system_prompt,
)
# 保存到会话
for m in all_messages[len(history):]:
session.messages.append(m)
self.sessions.save(session)
# 尝试内存压缩
await self.memory.maybe_consolidate_by_tokens(session)
return {
"content": final_content or "No response",
"tool_calls": tools_used,
"session_id": session_id,
}
async def run_stream(
self,
user_input: str,
session_id: str = "default",
):
"""
运行 Agent 对话(流式输出)
先完整执行 agent loop最后流式输出结果
Args:
user_input: 用户输入
session_id: 会话 ID
Yields:
str: 流式回复片段
"""
# 先完整执行 agent loop包含 tool-calling
result = await self.run(user_input, session_id)
content = result["content"]
# 流式输出结果
for char in content:
yield char
def clear_session(self, session_id: str) -> None:
"""清除会话"""
session = self.sessions.get_or_create(session_id)
session.clear()
self.sessions.save(session)
self.sessions.invalidate(session_id)
def list_sessions(self) -> list[dict]:
"""列出所有会话"""
return self.sessions.list_sessions()

190
agent/app/xbot/loop.py Normal file
View File

@@ -0,0 +1,190 @@
"""Agent loop for tool-calling conversation."""
import asyncio
import json
import re
from typing import Any, Callable, Optional
from loguru import logger
class AgentLoop:
"""
Agent loop with tool-calling capability.
This is the core of the nanobot agent - it handles:
- Multi-turn conversation with the LLM
- Tool execution when the model requests it
- Progress callbacks for streaming responses
"""
_TOOL_RESULT_MAX_CHARS = 50000
def __init__(
self,
provider: Any,
model: str,
tools: Any,
max_iterations: int = 50,
):
"""
Initialize the agent loop.
Args:
provider: LLM provider (must implement chat_with_retry)
model: Model name
tools: Tool registry (must have get_definitions() and execute())
max_iterations: Maximum tool call iterations
"""
self.provider = provider
self.model = model
self.tools = tools
self.max_iterations = max_iterations
@staticmethod
def _strip_think(text: Optional[str]) -> Optional[str]:
"""Strip model thinking blocks from content."""
if not text:
return None
# Strip <thinking> tags commonly used by models like DeepSeek
pattern = r"<thinking>[\s\S]*?</thinking>"
text = re.sub(pattern, "", text)
return text.strip() or None
@staticmethod
def _tool_hint(tool_calls: list) -> str:
"""Format tool calls as concise hint."""
def _fmt(tc):
args = tc.arguments or {}
val = next(iter(args.values()), None) if isinstance(args, dict) else None
if not isinstance(val, str):
return tc.name
return f'{tc.name}("{val[:40]}...")' if len(val) > 40 else f'{tc.name}("{val}")'
return ", ".join(_fmt(tc) for tc in tool_calls)
async def run_loop(
self,
initial_messages: list[dict],
system_prompt: str = "",
on_progress: Optional[Callable[..., Any]] = None,
) -> tuple[Optional[str], list[str], list[dict]]:
"""
Run the agent iteration loop.
Args:
initial_messages: Starting message list
system_prompt: System prompt to prepend
on_progress: Optional callback for progress updates
Returns:
Tuple of (final_content, tools_used, all_messages)
"""
# Prepend system prompt if provided
if system_prompt:
messages = [{"role": "system", "content": system_prompt}] + initial_messages
else:
messages = initial_messages
iteration = 0
final_content = None
tools_used: list[str] = []
while iteration < self.max_iterations:
iteration += 1
tool_defs = self.tools.get_definitions() if self.tools else []
response = await self.provider.chat_with_retry(
messages=messages,
tools=tool_defs,
model=self.model,
)
if response.has_tool_calls:
# Send progress update
if on_progress:
thought = self._strip_think(response.content)
if thought:
await on_progress(thought)
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
# Add assistant message with tool calls
tool_call_dicts = [
tc.to_openai_tool_call() if hasattr(tc, 'to_openai_tool_call') else tc
for tc in response.tool_calls
]
messages = self._add_assistant_message(
messages, response.content, tool_call_dicts,
reasoning_content=getattr(response, 'reasoning_content', None),
)
# Execute tools
for tool_call in response.tool_calls:
tools_used.append(tool_call.name)
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
logger.info("Tool call: {}({})", tool_call.name, args_str[:200])
result = await self.tools.execute(tool_call.name, tool_call.arguments)
messages = self._add_tool_result(messages, tool_call.id, tool_call.name, result)
else:
clean = self._strip_think(response.content)
# Handle error responses
if response.finish_reason == "error":
logger.error("LLM returned error: {}", (clean or "")[:200])
final_content = clean or "Sorry, I encountered an error calling the AI model."
break
messages = self._add_assistant_message(
messages, clean,
reasoning_content=getattr(response, 'reasoning_content', None),
)
final_content = clean
break
if final_content is None and iteration >= self.max_iterations:
logger.warning("Max iterations ({}) reached", self.max_iterations)
final_content = (
f"I reached the maximum number of tool call iterations ({self.max_iterations}) "
"without completing the task."
)
return final_content, tools_used, messages
def _add_assistant_message(
self,
messages: list[dict],
content: Optional[str],
tool_calls: Optional[list[dict]] = None,
reasoning_content: Optional[str] = None,
) -> list[dict]:
"""Add an assistant message to the message list."""
msg: dict[str, Any] = {"role": "assistant", "content": content}
if tool_calls:
msg["tool_calls"] = tool_calls
if reasoning_content is not None:
msg["reasoning_content"] = reasoning_content
messages.append(msg)
return messages
def _add_tool_result(
self,
messages: list[dict],
tool_call_id: str,
tool_name: str,
result: Any,
) -> list[dict]:
"""Add a tool result message to the message list."""
# Truncate large results
content = str(result)
if len(content) > self._TOOL_RESULT_MAX_CHARS:
content = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": tool_name,
"content": content,
})
return messages

240
agent/app/xbot/memory.py Normal file
View File

@@ -0,0 +1,240 @@
"""Memory system for persistent agent memory."""
import json
import asyncio
import weakref
from pathlib import Path
from typing import Any, Callable, Optional
try:
import tiktoken
HAS_TIKTOKEN = True
except ImportError:
HAS_TIKTOKEN = False
_SAVE_MEMORY_TOOL = [
{
"type": "function",
"function": {
"name": "save_memory",
"description": "Save the memory consolidation result to persistent storage.",
"parameters": {
"type": "object",
"properties": {
"history_entry": {
"type": "string",
"description": "A paragraph summarizing key events/decisions/topics.",
},
"memory_update": {
"type": "string",
"description": "Full updated long-term memory as markdown. Include all existing facts plus new ones.",
},
},
"required": ["history_entry", "memory_update"],
},
},
}
]
class MemoryStore:
"""Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log)."""
def __init__(self, workspace: Path):
self.memory_dir = workspace / "memory"
self.memory_dir.mkdir(parents=True, exist_ok=True)
self.memory_file = self.memory_dir / "MEMORY.md"
self.history_file = self.memory_dir / "HISTORY.md"
def read_long_term(self) -> str:
if self.memory_file.exists():
return self.memory_file.read_text(encoding="utf-8")
return ""
def write_long_term(self, content: str) -> None:
self.memory_file.write_text(content, encoding="utf-8")
def append_history(self, entry: str) -> None:
with open(self.history_file, "a", encoding="utf-8") as f:
f.write(entry.rstrip() + "\n\n")
def get_memory_context(self) -> str:
long_term = self.read_long_term()
return f"## Long-term Memory\n{long_term}" if long_term else ""
def _estimate_tokens(text: str) -> int:
"""Estimate token count."""
if HAS_TIKTOKEN:
try:
enc = tiktoken.get_encoding("cl100k_base")
return len(enc.encode(text))
except Exception:
pass
return max(1, len(text) // 4)
def _estimate_message_tokens(message: dict[str, Any]) -> int:
"""Estimate prompt tokens for a message."""
content = message.get("content")
parts = []
if isinstance(content, str):
parts.append(content)
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
text = part.get("text", "")
if text:
parts.append(text)
else:
parts.append(json.dumps(part, ensure_ascii=False))
elif content is not None:
parts.append(json.dumps(content, ensure_ascii=False))
for key in ("name", "tool_call_id"):
value = message.get(key)
if isinstance(value, str) and value:
parts.append(value)
if message.get("tool_calls"):
parts.append(json.dumps(message["tool_calls"], ensure_ascii=False))
payload = "\n".join(parts)
return max(1, _estimate_tokens(payload)) if payload else 1
class MemoryConsolidator:
"""Owns consolidation policy, locking, and session offset updates."""
def __init__(
self,
workspace: Path,
provider: Any,
model: str,
sessions: Any,
context_window_tokens: int = 200000,
):
self.store = MemoryStore(workspace)
self.provider = provider
self.model = model
self.sessions = sessions
self.context_window_tokens = context_window_tokens
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
def get_lock(self, session_key: str) -> asyncio.Lock:
"""Return the shared consolidation lock for one session."""
return self._locks.setdefault(session_key, asyncio.Lock())
async def consolidate_messages(self, messages: list[dict[str, object]]) -> bool:
"""Archive a selected message chunk into persistent memory."""
if not messages:
return True
current_memory = self.store.read_long_term()
prompt = f"""Process this conversation and call the save_memory tool.
## Current Long-term Memory
{current_memory or "(empty)"}
## Conversation to Process
{self._format_messages(messages)}"""
try:
response = await self.provider.chat_with_retry(
messages=[
{"role": "system", "content": "You are a memory consolidation agent."},
{"role": "user", "content": prompt},
],
tools=_SAVE_MEMORY_TOOL,
model=self.model,
)
if not response.has_tool_calls:
return False
args = response.tool_calls[0].arguments
if isinstance(args, str):
args = json.loads(args)
if isinstance(args, list):
args = args[0] if args else {}
if entry := args.get("history_entry"):
self.store.append_history(str(entry))
if update := args.get("memory_update"):
update = str(update)
if update != current_memory:
self.store.write_long_term(update)
return True
except Exception:
return False
def _format_messages(self, messages: list[dict]) -> str:
lines = []
for message in messages:
if not message.get("content"):
continue
lines.append(
f"[{message.get('timestamp', '?')[:16]}] {message['role'].upper()}: {message['content']}"
)
return "\n".join(lines)
def pick_consolidation_boundary(
self,
session: Any,
tokens_to_remove: int,
) -> Optional[tuple[int, int]]:
"""Pick a user-turn boundary that removes enough old prompt tokens."""
start = session.last_consolidated
if start >= len(session.messages) or tokens_to_remove <= 0:
return None
removed_tokens = 0
last_boundary: Optional[tuple[int, int]] = None
for idx in range(start, len(session.messages)):
message = session.messages[idx]
if idx > start and message.get("role") == "user":
last_boundary = (idx, removed_tokens)
if removed_tokens >= tokens_to_remove:
return last_boundary
removed_tokens += _estimate_message_tokens(message)
return last_boundary
async def archive_unconsolidated(self, session: Any) -> bool:
"""Archive the full unconsolidated tail for /new-style session rollover."""
lock = self.get_lock(session.key)
async with lock:
snapshot = session.messages[session.last_consolidated:]
if not snapshot:
return True
return await self.consolidate_messages(snapshot)
async def maybe_consolidate_by_tokens(self, session: Any) -> None:
"""Loop: archive old messages until prompt fits within half the context window."""
if not session.messages or self.context_window_tokens <= 0:
return
lock = self.get_lock(session.key)
async with lock:
target = self.context_window_tokens // 2
# Simple estimation without full prompt build
estimated = sum(_estimate_message_tokens(m) for m in session.messages[session.last_consolidated:])
if estimated < self.context_window_tokens:
return
# Find boundary and consolidate
boundary = self.pick_consolidation_boundary(session, max(1, estimated - target))
if boundary is None:
return
end_idx = boundary[0]
chunk = session.messages[session.last_consolidated:end_idx]
if not chunk:
return
if await self.consolidate_messages(chunk):
session.last_consolidated = end_idx
self.sessions.save(session)

169
agent/app/xbot/session.py Normal file
View File

@@ -0,0 +1,169 @@
"""Session management for conversation history."""
import json
import shutil
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
@dataclass
class Session:
"""
A conversation session.
Stores messages in JSONL format for easy reading and persistence.
"""
key: str # session_id
messages: list[dict[str, Any]] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
metadata: dict[str, Any] = field(default_factory=dict)
last_consolidated: int = 0 # Number of messages already consolidated to files
def add_message(self, role: str, content: str, **kwargs: Any) -> None:
"""Add a message to the session."""
msg = {
"role": role,
"content": content,
"timestamp": datetime.now().isoformat(),
**kwargs
}
self.messages.append(msg)
self.updated_at = datetime.now()
def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]:
"""Return unconsolidated messages for LLM input, aligned to a user turn."""
unconsolidated = self.messages[self.last_consolidated:]
sliced = unconsolidated[-max_messages:]
# Drop leading non-user messages to avoid orphaned tool_result blocks
for i, m in enumerate(sliced):
if m.get("role") == "user":
sliced = sliced[i:]
break
out: list[dict[str, Any]] = []
for m in sliced:
entry: dict[str, Any] = {"role": m["role"], "content": m.get("content", "")}
for k in ("tool_calls", "tool_call_id", "name"):
if k in m:
entry[k] = m[k]
out.append(entry)
return out
def clear(self) -> None:
"""Clear all messages and reset session to initial state."""
self.messages = []
self.last_consolidated = 0
self.updated_at = datetime.now()
class SessionManager:
"""Manages conversation sessions stored as JSONL files."""
def __init__(self, workspace: Path):
self.workspace = workspace
self.sessions_dir = workspace / "sessions"
self.sessions_dir.mkdir(parents=True, exist_ok=True)
self._cache: dict[str, Session] = {}
def _get_session_path(self, key: str) -> Path:
"""Get the file path for a session."""
safe_key = key.replace(":", "_").replace("/", "_")
return self.sessions_dir / f"{safe_key}.jsonl"
def get_or_create(self, key: str) -> Session:
"""Get an existing session or create a new one."""
if key in self._cache:
return self._cache[key]
session = self._load(key)
if session is None:
session = Session(key=key)
self._cache[key] = session
return session
def _load(self, key: str) -> Optional[Session]:
"""Load a session from disk."""
path = self._get_session_path(key)
if not path.exists():
return None
try:
messages = []
metadata = {}
created_at = None
last_consolidated = 0
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
data = json.loads(line)
if data.get("_type") == "metadata":
metadata = data.get("metadata", {})
created_at = datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None
last_consolidated = data.get("last_consolidated", 0)
else:
messages.append(data)
return Session(
key=key,
messages=messages,
created_at=created_at or datetime.now(),
metadata=metadata,
last_consolidated=last_consolidated
)
except Exception:
return None
def save(self, session: Session) -> None:
"""Save a session to disk."""
path = self._get_session_path(session.key)
with open(path, "w", encoding="utf-8") as f:
metadata_line = {
"_type": "metadata",
"key": session.key,
"created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(),
"metadata": session.metadata,
"last_consolidated": session.last_consolidated
}
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
for msg in session.messages:
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
self._cache[session.key] = session
def invalidate(self, key: str) -> None:
"""Remove a session from the in-memory cache."""
self._cache.pop(key, None)
def list_sessions(self) -> list[dict[str, Any]]:
"""List all sessions."""
sessions = []
for path in self.sessions_dir.glob("*.jsonl"):
try:
with open(path, encoding="utf-8") as f:
first_line = f.readline().strip()
if first_line:
data = json.loads(first_line)
if data.get("_type") == "metadata":
sessions.append({
"key": data.get("key") or path.stem,
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"path": str(path)
})
except Exception:
continue
return sorted(sessions, key=lambda x: x.get("updated_at", ""), reverse=True)

View File

@@ -6,3 +6,5 @@ anthropic>=0.18.0
python-dotenv>=1.0.0
aiohttp>=3.8.0
redis>=5.0.0
loguru>=0.7.0
tiktoken>=0.12.0

View File

@@ -333,11 +333,15 @@ func main() {
// 7. 设置路由
r := gin.New()
// 添加日志和恢复中间件
r.Use(gin.Logger())
r.Use(gin.Recovery())
// 禁用响应缓冲,用于流式输出
r.Use(func(c *gin.Context) {
c.Header("X-Accel-Buffering", "no")
c.Next()
})
// 请求日志中间件
r.Use(func(c *gin.Context) {
start := time.Now()
@@ -495,6 +499,7 @@ func main() {
agentGroup := r.Group("/api/agent")
{
agentGroup.POST("/chat", agentHandler.Chat)
agentGroup.POST("/chat/stream", agentHandler.ChatStream)
agentGroup.POST("/team/chat", agentHandler.TeamChat)
}

View File

@@ -109,7 +109,7 @@ func InitDB(cfg *Config) (*gorm.DB, error) {
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, fmt.Errorf("failed to connect database: %w", err)

View File

@@ -26,6 +26,7 @@ type ChatRequest struct {
Message string `json:"message" binding:"required"`
SessionID string `json:"session_id"`
ModelID string `json:"model_id"`
UseXBot bool `json:"use_xbot"`
}
// ChatResponse 对话响应
@@ -56,6 +57,7 @@ func (h *AgentHandler) Chat(c *gin.Context) {
UserID: userID,
SessionID: req.SessionID,
ModelID: req.ModelID,
UseXBot: req.UseXBot,
}
result, err := h.agentService.Chat(pythonReq)
@@ -85,6 +87,30 @@ func (h *AgentHandler) Chat(c *gin.Context) {
})
}
// ChatStream 单智能体对话(流式输出)
func (h *AgentHandler) ChatStream(c *gin.Context) {
var req ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 获取用户 ID
userID := 1 // TODO: 从 c.Get("user_id") 获取
// 构建 SSE 流
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
// 调用 Python 服务的流式端点
err := h.agentService.ChatStream(c, req.AgentID, req.Message, req.SessionID, req.ModelID, userID)
if err != nil && !c.IsAborted() {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
}
// TeamChatRequest 多智能体群聊请求
type TeamChatRequest struct {
SupervisorAgentID int `json:"supervisor_agent_id" binding:"required"`

View File

@@ -10,6 +10,8 @@ import (
"time"
"x-agents/server/internal/repository"
"github.com/gin-gonic/gin"
)
// AgentChatRequest Python Agent 对话请求
@@ -23,6 +25,7 @@ type AgentChatRequest struct {
ModelProvider string `json:"model_provider,omitempty"`
APIKey string `json:"api_key,omitempty"`
BaseURL string `json:"base_url,omitempty"`
UseXBot bool `json:"use_xbot"`
}
// AgentChatResponse Python Agent 对话响应
@@ -186,3 +189,93 @@ func (s *AgentService) TeamChat(req TeamChatRequest) (*TeamChatResponse, error)
return &result, nil
}
// ChatStream 流式对话
func (s *AgentService) ChatStream(c interface{}, agentID int, message, sessionID, modelID string, userID int) error {
// 获取 gin.Context
ginCtx, ok := c.(*gin.Context)
if !ok {
return fmt.Errorf("invalid context type")
}
// 初始化请求体
reqBody := map[string]interface{}{
"agent_id": agentID,
"message": message,
"user_id": userID,
"session_id": sessionID,
"use_xbot": false,
}
// 如果传入了 model_id查询模型配置获取 api_key 和 base_url
if modelID != "" && s.modelRepo != nil {
model, err := s.modelRepo.FindByID(modelID)
if err != nil {
log.Printf("[ChatStream] Model not found: %s, error: %v", modelID, err)
} else if model != nil {
log.Printf("[ChatStream] Using model: provider=%s, model=%s, base_url=%s", model.Provider, model.Model, model.BaseURL)
// 将模型配置添加到请求体
reqBody["model_provider"] = model.Provider
reqBody["model_name"] = model.Model
reqBody["api_key"] = model.APIKey
reqBody["base_url"] = model.BaseURL
}
} else {
log.Printf("[ChatStream] modelID is empty or modelRepo is nil: modelID=%s, modelRepo=%v", modelID, s.modelRepo != nil)
}
streamURL := fmt.Sprintf("%s/agent/chat/stream", s.pythonURL)
jsonData, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
// 创建 HTTP 请求,设置不缓冲
httpReq, err := http.NewRequest("POST", streamURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("Cache-Control", "no-cache")
// 创建不缓冲的 HTTP 客户端
client := &http.Client{
Transport: &http.Transport{
DisableKeepAlives: true,
},
}
resp, err := client.Do(httpReq)
if err != nil {
return fmt.Errorf("failed to call python agent: %w", err)
}
defer resp.Body.Close()
// 设置 SSE 响应头
ginCtx.Header("Content-Type", "text/event-stream")
ginCtx.Header("Cache-Control", "no-cache")
ginCtx.Header("Connection", "keep-alive")
ginCtx.Header("X-Accel-Buffering", "no")
// 分块读取并转发,使用小 buffer 减少延迟
buf := make([]byte, 1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
_, writeErr := ginCtx.Writer.Write(buf[:n])
if writeErr != nil {
break
}
// 强制刷新到客户端
if flusher, ok := ginCtx.Writer.(interface{ Flush() }); ok {
flusher.Flush()
}
}
if err != nil {
break
}
}
return nil
}

View File

@@ -41,66 +41,75 @@ func (s *SkillService) UpdateSkill(skill *model.Skill) error {
}
func (s *SkillService) DeleteSkill(id string) error {
return s.skillRepo.Delete(id)
// 先获取 skill 信息,以便删除本地文件
skill, err := s.skillRepo.FindByID(id)
if err != nil {
return err
}
// 删除数据库记录
if err := s.skillRepo.Delete(id); err != nil {
return err
}
// 删除本地文件skill 目录)
if skill.Path != "" {
// 获取 skill 所在目录SKILL.md 的父目录)
skillDir := filepath.Dir(skill.Path)
if err := os.RemoveAll(skillDir); err != nil {
log.Printf("[SkillService] Warning: failed to delete skill directory %s: %v", skillDir, err)
// 数据库记录已删除,不返回错误
} else {
log.Printf("[SkillService] Deleted skill directory: %s", skillDir)
}
}
return nil
}
// InitSkills 初始化扫描所有 skills 目录
func (s *SkillService) InitSkills() error {
log.Println("[SkillService] Starting init skills...")
// 获取项目根目录
projectRoot := s.getProjectRoot()
if projectRoot == "" {
log.Println("[SkillService] Cannot determine project root, skipping skill init")
return nil
}
var totalCount int
// 扫描 system skills: account/admin/skills
systemSkillsPath := filepath.Join(projectRoot, "account", "admin", "skills")
if _, err := os.Stat(systemSkillsPath); err == nil {
log.Printf("[SkillService] Scanning system skills from: %s", systemSkillsPath)
systemSkills, err := s.scanSkillsDirectory(systemSkillsPath, "system")
if err != nil {
log.Printf("[SkillService] Error scanning system skills: %v", err)
} else {
log.Printf("[SkillService] Found %d system skills", len(systemSkills))
// 先删除旧的 system skills
if err == nil && len(systemSkills) > 0 {
s.skillRepo.DeleteByType("system")
// 批量插入
if err := s.skillRepo.UpsertBatch(systemSkills); err != nil {
log.Printf("[SkillService] Error saving system skills: %v", err)
}
s.skillRepo.UpsertBatch(systemSkills)
totalCount += len(systemSkills)
}
}
// 扫描 user skills: account/{username}/skills (除了 admin)
accountPath := filepath.Join(projectRoot, "account")
entries, err := os.ReadDir(accountPath)
if err != nil {
log.Printf("[SkillService] Error reading account directory: %v", err)
} else {
if err == nil {
for _, entry := range entries {
if !entry.IsDir() || entry.Name() == "admin" {
continue
}
userSkillsPath := filepath.Join(accountPath, entry.Name(), "skills")
if _, err := os.Stat(userSkillsPath); err == nil {
log.Printf("[SkillService] Scanning user skills for %s from: %s", entry.Name(), userSkillsPath)
userSkills, err := s.scanSkillsDirectory(userSkillsPath, "user")
if err != nil {
log.Printf("[SkillService] Error scanning user skills for %s: %v", entry.Name(), err)
} else {
log.Printf("[SkillService] Found %d user skills for %s", len(userSkills), entry.Name())
// 批量插入
if err := s.skillRepo.UpsertBatch(userSkills); err != nil {
log.Printf("[SkillService] Error saving user skills for %s: %v", entry.Name(), err)
}
if err == nil && len(userSkills) > 0 {
s.skillRepo.UpsertBatch(userSkills)
totalCount += len(userSkills)
}
}
}
}
log.Println("[SkillService] Skills initialized successfully")
if totalCount > 0 {
log.Printf("[SkillService] Loaded %d skills", totalCount)
}
return nil
}

View File

@@ -56,23 +56,18 @@ func (s *ToolService) DeleteTool(id string) error {
// InitDefaultTools 初始化默认工具到数据库
func (s *ToolService) InitDefaultTools() error {
log.Println("[ToolService] Starting init default tools...")
// 删除现有的系统工具,重新插入
s.toolRepo.DB().Where("provider = ?", "system").Delete(&model.Tool{})
// 插入默认工具
tools := s.getDefaultTools()
log.Printf("[ToolService] Inserting %d default tools...", len(tools))
for _, tool := range tools {
if err := s.toolRepo.Create(&tool); err != nil {
log.Printf("[ToolService] Create tool error: %v", err)
return err
}
}
log.Printf("[ToolService] Default tools initialized successfully")
log.Printf("[ToolService] Loaded %d default tools", len(tools))
return nil
}

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import type { Agent } from '@/composables/useChat'
defineProps<{
show: boolean
selectMode: 'single' | 'group'
chatAgents: Agent[]
selectedAgents: Agent[]
groupChatName: string
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toggleSelect', agent: Agent): void
(e: 'confirm'): void
(e: 'update:groupChatName', value: string): void
}>()
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="show" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50" @click="emit('close')">
<div class="bg-dark-800 rounded-xl w-full max-w-md border border-dark-600 shadow-2xl" @click.stop>
<div class="p-4 border-b border-dark-600">
<h3 class="text-lg font-semibold text-white">
{{ selectMode === 'single' ? '选择智能体' : '选择群聊成员' }}
</h3>
<p class="text-sm text-gray-400 mt-1">
{{ selectMode === 'single' ? '选择一个智能体开始对话' : '选择多个智能体创建群聊' }}
</p>
</div>
<div class="p-4 max-h-80 overflow-y-auto">
<!-- 群聊名称输入框 -->
<div v-if="selectMode === 'group'" class="mb-4">
<input
:value="groupChatName"
@input="emit('update:groupChatName', ($event.target as HTMLInputElement).value)"
type="text"
placeholder="Enter group name..."
class="w-full bg-dark-700 border border-dark-500 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-primary-orange"
>
</div>
<div class="space-y-2">
<button
v-for="agent in chatAgents"
:key="agent.id"
@click="emit('toggleSelect', agent)"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200"
:class="selectedAgents.some(a => a.id === agent.id)
? 'bg-orange-500/20 border border-orange-500/50'
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'"
>
<span class="text-xl">{{ agent.avatar }}</span>
<div class="flex-1 text-left">
<div class="text-white font-medium">{{ agent.name }}</div>
<div class="text-xs text-gray-400">{{ agent.description }}</div>
</div>
<span
v-if="agent.status === 'online'"
class="w-2 h-2 rounded-full bg-emerald-400"
></span>
<span
v-if="selectMode === 'group' && selectedAgents.some(a => a.id === agent.id)"
class="w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center"
>
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path>
</svg>
</span>
</button>
</div>
</div>
<div class="p-4 border-t border-dark-600 flex gap-3">
<button
@click="emit('close')"
class="flex-1 py-2.5 bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors"
>
Cancel
</button>
<button
v-if="selectMode === 'group'"
@click="emit('confirm')"
:disabled="selectedAgents.length < 2"
class="flex-1 py-2.5 bg-orange-500 hover:bg-orange-400 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Create Group ({{ selectedAgents.length }})
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
import { getModelIcon, type Agent, type ChatModel } from '@/composables/useChat'
defineProps<{
selectedAgent: Agent | null
chatModels: ChatModel[]
selectedModel: ChatModel | null
showModelDropdown: boolean
sidebarCollapsed: boolean
}>()
const emit = defineEmits<{
(e: 'toggleDropdown'): void
(e: 'selectModel', model: ChatModel): void
(e: 'toggleSidebar'): void
}>()
</script>
<template>
<div class="h-16 px-4 flex items-center justify-between border-b border-white/[0.06] bg-[#0c0c0f]/80 backdrop-blur-xl">
<!-- 左侧当前AI信息 -->
<div class="flex items-center gap-3">
<div v-if="selectedAgent" class="flex items-center gap-3">
<div
class="w-9 h-9 rounded-xl flex items-center justify-center text-lg shadow-lg"
:style="{ backgroundColor: selectedAgent.accentColor + '15', color: selectedAgent.accentColor }"
>
{{ selectedAgent.avatar }}
</div>
<div>
<div class="text-sm font-semibold text-white tracking-wide">{{ selectedAgent?.name || 'Chat' }}</div>
<div class="text-[11px] flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
<span class="text-white/40">Online</span>
</div>
</div>
</div>
</div>
<!-- 中间空白 -->
<div class="flex-1"></div>
<!-- 右侧模型选择和折叠按钮 -->
<div class="flex items-center gap-3">
<!-- 模型选择下拉框 -->
<div class="relative model-dropdown" v-if="chatModels.length > 0">
<button
@click="emit('toggleDropdown')"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-white/[0.08] bg-[#1a1a24] hover:border-orange-500/30 transition-all duration-200"
>
<div class="flex items-center gap-2">
<span class="text-base">{{ getModelIcon(selectedModel?.provider || '') }}</span>
<span class="text-sm text-white">{{ selectedModel?.name || 'Select Model' }}</span>
</div>
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
<svg class="w-3.5 h-3.5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- 下拉菜单 -->
<Transition name="dropdown">
<div
v-if="showModelDropdown"
class="absolute right-0 top-full mt-2 w-64 bg-[#1a1a24] border border-white/[0.08] rounded-xl shadow-2xl shadow-black/50 overflow-hidden z-50"
>
<div class="p-2">
<div class="text-xs text-white/40 px-2 py-1 mb-1">Select Chat Model</div>
<button
v-for="model in chatModels"
:key="model.id"
@click="emit('selectModel', model)"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-150"
:class="selectedModel?.id === model.id
? 'bg-orange-500/15 border border-orange-500/30'
: 'hover:bg-white/5'"
>
<span class="text-lg">{{ getModelIcon(model.provider) }}</span>
<div class="flex-1 text-left">
<div class="text-sm text-white">{{ model.name }}</div>
<div class="text-xs text-white/40">{{ model.provider }} · {{ model.model }}</div>
</div>
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
</button>
</div>
</div>
</Transition>
</div>
<!-- 没有模型时的提示 -->
<div v-else class="text-xs text-white/30 px-2 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-white/20"></span>
No models
</div>
<button
@click="emit('toggleSidebar')"
class="p-2.5 rounded-xl hover:bg-white/[0.06] text-white/35 hover:text-white/80 transition-all duration-200"
:title="sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'"
>
<svg class="w-[18px] h-[18px] transition-transform duration-300" :class="sidebarCollapsed ? '' : 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
</div>
</template>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
const props = defineProps<{
modelValue: string
loading: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'send'): void
}>()
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
emit('send')
}
}
const autoResize = (e: Event) => {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = Math.min(target.scrollHeight, 160) + 'px'
}
</script>
<template>
<div class="p-5 border-t border-white/[0.06] bg-[#0c0c0f]/60 backdrop-blur-xl">
<div class="max-w-3xl mx-auto">
<div 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">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"></path>
</svg>
</button>
<!-- 输入框 -->
<textarea
:value="modelValue"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value); autoResize($event)"
@keydown="handleKeydown"
placeholder="发送消息..."
rows="1"
class="w-full bg-transparent text-white placeholder-white/25 py-4 pl-12 pr-28 resize-none focus:outline-none text-[15px]"
></textarea>
<!-- 发送按钮 -->
<button
@click="emit('send')"
:disabled="!modelValue.trim() || loading"
class="absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 rounded-lg flex items-center justify-center transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
:class="modelValue.trim() && !loading
? 'bg-orange-500 hover:bg-orange-400 shadow-lg shadow-orange-500/30 active:scale-90'
: 'bg-white/10'"
>
<svg v-if="!loading" class="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
<svg v-else class="w-4 h-4 text-white animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
</div>
<!-- 提示 -->
<div class="text-center mt-3">
<span class="text-[10px] text-white/20 tracking-wide">AI 可能会产生错误信息请核实重要内容</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,135 @@
<script setup lang="ts">
import { renderMarkdown, type ChatMessage, type Agent } from '@/composables/useChat'
defineProps<{
message: ChatMessage
selectedAgent: Agent | null
}>()
// 创建消息内容的函数
const getMessageContent = (content: string, isUser: boolean) => {
if (isUser) return content
return renderMarkdown(content)
}
</script>
<template>
<div
class="flex items-end gap-2 mb-4"
:class="message.role === 'user' ? 'flex-row-reverse' : ''"
>
<!-- 头像 -->
<div
class="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center"
:class="message.role === 'user' ? 'bg-gradient-to-br from-orange-500 to-amber-600' : ''"
:style="message.role === 'assistant' && selectedAgent ? {
backgroundColor: selectedAgent.accentColor + '20',
color: selectedAgent.accentColor
} : {}"
>
<span v-if="message.role === 'user'" class="text-white text-sm">👤</span>
<span v-else class="text-base">{{ selectedAgent?.avatar || '🧠' }}</span>
</div>
<!-- 消息容器 -->
<div class="flex flex-col max-w-[70%]" :class="message.role === 'user' ? 'items-end' : 'items-start'">
<!-- 消息气泡 -->
<div
class="px-4 py-3 rounded-2xl text-[14px] leading-relaxed markdown-body"
:class="message.role === 'user'
? 'bg-gradient-to-br from-orange-500 to-orange-600 text-white rounded-br-sm'
: 'bg-[#1e1e28] text-gray-100 rounded-bl-sm'"
>
<span v-html="getMessageContent(message.content, message.role === 'user')"></span>
<span v-if="message.isStreaming" class="inline-block w-0.5 h-4 ml-0.5 bg-orange-300 cursor-blink align-middle"></span>
</div>
<!-- 时间戳 -->
<span class="text-[11px] text-gray-500 mt-1 px-1">
{{ message.timestamp.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }}
</span>
</div>
</div>
</template>
<style scoped>
.markdown-body {
word-wrap: break-word;
text-wrap: balance;
}
.markdown-body :deep(p) {
margin: 0 0 6px 0;
}
.markdown-body :deep(p:last-child) {
margin-bottom: 0;
}
.markdown-body :deep(code) {
background: rgba(0, 0, 0, 0.3);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
}
.markdown-body :deep(pre) {
background: rgba(0, 0, 0, 0.4);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
margin: 8px 0;
}
.markdown-body :deep(pre code) {
background: transparent;
padding: 0;
}
.markdown-body :deep(ul), .markdown-body :deep(ol) {
margin: 6px 0;
padding-left: 18px;
}
.markdown-body :deep(li) {
margin: 3px 0;
}
.markdown-body :deep(blockquote) {
border-left: 3px solid rgba(255, 255, 255, 0.2);
margin: 6px 0;
padding-left: 10px;
color: rgba(255, 255, 255, 0.6);
}
.markdown-body :deep(a) {
color: #60a5fa;
text-decoration: underline;
}
.markdown-body :deep(h1), .markdown-body :deep(h2), .markdown-body :deep(h3) {
margin: 10px 0 6px 0;
font-weight: 600;
color: #fff;
}
.markdown-body :deep(h1) { font-size: 1.2em; }
.markdown-body :deep(h2) { font-size: 1.1em; }
.markdown-body :deep(h3) { font-size: 1em; }
.markdown-body :deep(table) {
border-collapse: collapse;
width: 100%;
margin: 8px 0;
}
.markdown-body :deep(th), .markdown-body :deep(td) {
border: 1px solid rgba(255, 255, .2255, 0);
padding: 6px 10px;
text-align: left;
}
.markdown-body :deep(th) {
background: rgba(0, 0, 0, 0.2);
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import type { Agent, ChatSession, GroupChat } from '@/composables/useChat'
defineProps<{
collapsed: boolean
chatAgents: Agent[]
selectedAgent: Agent | null
chatSessions: ChatSession[]
groupChats: GroupChat[]
}>()
const emit = defineEmits<{
(e: 'openAgentSelector', mode: 'single' | 'group'): void
(e: 'selectAgent', agent: Agent): void
(e: 'selectSession', session: ChatSession): void
}>()
const formatRelativeTime = (date: Date) => {
const now = new Date()
const diff = now.getTime() - date.getTime()
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (hours < 1) return '刚刚'
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return date.toLocaleDateString('zh-CN')
}
</script>
<template>
<div
class="flex-shrink-0 border-l border-white/[0.06] bg-[#0c0c0f] transition-all duration-300 ease-in-out overflow-hidden"
:class="collapsed ? 'w-0 opacity-0' : 'w-72 opacity-100'"
>
<div class="w-72 h-full flex flex-col">
<!-- 侧边栏头部 -->
<div class="p-4 border-b border-white/[0.06]">
<div class="flex items-center gap-2 text-white font-semibold">
<svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path>
</svg>
<span>AI Hub</span>
</div>
</div>
<!-- 新建对话按钮 -->
<div class="p-3">
<div class="flex gap-2">
<button
@click="emit('openAgentSelector', 'single')"
class="flex-1 flex items-center justify-center gap-2 px-3 py-2.5 bg-orange-500 hover:bg-orange-400 rounded-lg text-white text-sm font-medium transition-all duration-200"
>
<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="M12 4v16m8-8H4"></path>
</svg>
<span>新建对话</span>
</button>
<button
@click="emit('openAgentSelector', 'group')"
class="flex-1 flex items-center justify-center gap-2 px-3 py-2.5 bg-dark-700 hover:bg-dark-600 border border-dark-500 rounded-lg text-white/80 text-sm font-medium transition-all duration-200"
>
<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="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>
</svg>
<span>新建群聊</span>
</button>
</div>
</div>
<!-- AI 助手选择 -->
<div class="px-3 pb-3">
<div class="text-xs text-white/40 uppercase tracking-wider mb-2 px-1">选择 AI 助手</div>
<div class="space-y-1">
<button
v-for="agent in chatAgents"
:key="agent.id"
@click="emit('selectAgent', agent)"
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200"
:class="selectedAgent?.id === agent.id
? 'bg-orange-500/15 text-orange-400'
: 'text-white/60 hover:bg-white/5 hover:text-white'"
>
<span class="text-base">{{ agent.avatar }}</span>
<span class="text-sm truncate">{{ agent.name }}</span>
<span
v-if="agent.status === 'online'"
class="w-1.5 h-1.5 rounded-full bg-emerald-400 ml-auto"
></span>
</button>
</div>
</div>
<!-- 群聊列表 -->
<div class="flex-1 overflow-y-auto px-3 pb-3">
<div class="text-xs text-white/40 uppercase tracking-wider mb-2 px-1">群聊</div>
<div class="space-y-1">
<button
v-for="group in groupChats"
:key="group.id"
class="w-full text-left px-3 py-2.5 rounded-lg hover:bg-white/5 transition-all duration-200 group"
>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-white/30 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
<span class="text-sm text-white/70 group-hover:text-white truncate">{{ group.name }}</span>
</div>
<div class="text-xs text-white/30 mt-1 pl-6">{{ group.members.length }} members</div>
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,332 @@
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { marked } from 'marked'
// 类型定义
export interface ChatModel {
id: string
name: string
model_type: string
provider: string
model: string
status: string
}
export interface ChatMessage {
id: number
role: 'user' | 'assistant'
content: string
timestamp: Date
isStreaming?: boolean
}
export interface Agent {
id: number
name: string
avatar: string
description: string
accentColor: string
gradient: string
status: 'online' | 'offline'
}
export interface ChatSession {
id: number
title: string
agentId: number
lastMessage: string
timestamp: Date
}
export interface GroupChat {
id: number
name: string
members: string[]
lastMessage: string
timestamp: Date
}
// 配置 marked
marked.setOptions({
breaks: true,
gfm: true
})
// 预处理内容:修复一些常见的 Markdown 问题
const preprocessContent = (content: string): string => {
if (!content) return ''
// 1. 标题:# 标题 -> # 标题(确保 # 后有空格)
content = content.replace(/(^|\n)(#{1,6})([^\s#\n])/g, '$1$2 $3')
// 2. 无序列表:-项目 -> - 项目
content = content.replace(/(\n)(\s*)([-*+])(\S)/g, '$1$2$3 $4')
// 3. 有序列表1.项目 -> 1. 项目
content = content.replace(/(\n)(\s*)(\d+\.)(\S)/g, '$1$2$3 $4')
// 4. 引用:>引用 -> > 引用
content = content.replace(/(\n)(>+)([^\s>\n])/g, '$1$2 $3')
// 5. 修复 ##1. 这种情况(连续处理)
content = content.replace(/(#{1,6})(\d+\.)/g, '$1 $2')
return content
}
// 渲染 Markdown
export const renderMarkdown = (content: string): string => {
if (!content) return ''
try {
const processed = preprocessContent(content)
return marked.parse(processed) as string
} catch (e) {
console.error('Markdown parse error:', e)
return content
}
}
// 根据 provider 获取图标
export const getModelIcon = (provider: string) => {
const icons: Record<string, string> = {
'OpenAI': '🤖',
'Claude': '🧠',
'Google': '✨',
'Gemini': '✨',
'Ollama': '🦙',
'DeepSeek': '🔮',
'Moonshot': '🌙',
'Kimi': '🌙',
'Baidu': '🐉',
'文心一言': '🐉',
'Aliyun': '☁️',
'Ali': '☁️',
'通义千问': '☁️',
'Azure': '⬛',
'Anthropic': '🧠',
}
return icons[provider] || '💬'
}
// 创建 composable
export function useChat() {
// 模型相关状态
const chatModels = ref<ChatModel[]>([])
const selectedModel = ref<ChatModel | null>(null)
const modelsLoading = ref(false)
const showModelDropdown = ref(false)
// 助手相关状态
const chatAgents = ref<Agent[]>([
{ id: 1, name: 'Claude', avatar: '🧠', description: 'Anthropic AI', accentColor: '#f97316', gradient: 'from-orange-500/20 to-amber-500/20', status: 'online' },
{ id: 2, name: 'Gemini', avatar: '✨', description: 'Google DeepMind', accentColor: '#8b5cf6', gradient: 'from-violet-500/20 to-purple-500/20', status: 'online' },
{ id: 3, name: 'ChatGPT', avatar: '💬', description: 'OpenAI', accentColor: '#10b981', gradient: 'from-emerald-500/20 to-green-500/20', status: 'offline' },
{ id: 4, name: 'DeepSeek', avatar: '🔮', description: 'DeepSeek AI', accentColor: '#3b82f6', gradient: 'from-blue-500/20 to-cyan-500/20', status: 'online' },
{ id: 5, name: 'Kimi', avatar: '🌙', description: 'Moonshot AI', accentColor: '#ec4899', gradient: 'from-pink-500/20 to-rose-500/20', status: 'online' },
{ id: 6, name: '文心一言', avatar: '🐉', description: 'Baidu', accentColor: '#ef4444', gradient: 'from-red-500/20 to-orange-500/20', status: 'offline' },
{ id: 7, name: '通义千问', avatar: '☁️', description: 'Alibaba', accentColor: '#06b6d4', gradient: 'from-cyan-500/20 to-sky-500/20', status: 'online' },
])
const selectedAgent = ref<Agent | null>(chatAgents.value[0])
// 消息相关状态
const messages = ref<ChatMessage[]>([
{ id: 1, role: 'assistant', content: '你好!我是 Claude你的 AI 助手。有什么我可以帮助你的吗?', timestamp: new Date() },
])
// 历史对话
const chatSessions = ref<ChatSession[]>([
{ id: 1, title: '关于 Python 学习的讨论', agentId: 1, lastMessage: '谢谢你!', timestamp: new Date(Date.now() - 3600000) },
{ id: 2, title: '代码调试帮助', agentId: 1, lastMessage: '让我看看这个问题...', timestamp: new Date(Date.now() - 7200000) },
{ id: 3, title: '数据分析咨询', agentId: 4, lastMessage: 'DeepSeek: 好的', timestamp: new Date(Date.now() - 86400000) },
])
// 群聊
const groupChats = ref<GroupChat[]>([
{ id: 1, name: 'AI 讨论组', members: ['Claude', 'GPT-4', 'Gemini'], lastMessage: '我们来讨论一下...', timestamp: new Date(Date.now() - 1800000) },
{ id: 2, name: '编程助手', members: ['Claude', 'DeepSeek'], lastMessage: '这段代码有问题吗?', timestamp: new Date(Date.now() - 3600000) },
{ id: 3, name: '创意头脑风暴', members: ['GPT-4', 'Claude', 'Kimi'], lastMessage: '有个新想法...', timestamp: new Date(Date.now() - 7200000) },
])
// 智能体选择弹窗
const showAgentSelector = ref(false)
const selectMode = ref<'single' | 'group'>('single')
const selectedAgents = ref<Agent[]>([])
const groupChatName = ref('')
// 输入相关
const inputMessage = ref('')
const isLoading = ref(false)
// 侧边栏
const sidebarCollapsed = ref(false)
// 获取模型列表
const fetchModels = async () => {
modelsLoading.value = true
try {
const response = await fetch(`/model/list`)
const data = await response.json()
if (data.list) {
chatModels.value = data.list.filter((m: ChatModel) => m.model_type === 'chat' && m.status === 'active')
if (chatModels.value.length > 0 && !selectedModel.value) {
selectedModel.value = chatModels.value[0]
}
}
} catch (error) {
console.error('Failed to fetch models:', error)
} finally {
modelsLoading.value = false
}
}
// 打开智能体选择器
const openAgentSelector = (mode: 'single' | 'group') => {
selectMode.value = mode
selectedAgents.value = []
groupChatName.value = ''
showAgentSelector.value = true
}
// 切换智能体选择
const toggleAgentSelection = (agent: Agent) => {
const index = selectedAgents.value.findIndex(a => a.id === agent.id)
if (index > -1) {
selectedAgents.value.splice(index, 1)
} else {
selectedAgents.value.push(agent)
}
}
// 确认选择
const confirmAgentSelection = () => {
if (selectMode.value === 'single') {
if (selectedAgents.value.length > 0) {
selectedAgent.value = selectedAgents.value[0]
messages.value = [
{ id: 1, role: 'assistant', content: `你好!我是 ${selectedAgent.value.name},你的 AI 助手。有什么我可以帮助你的吗?`, timestamp: new Date() }
]
}
} else {
const name = groupChatName.value.trim() || `群聊 (${selectedAgents.value.length}人)`
groupChats.value.unshift({
id: Date.now(),
name: name,
members: selectedAgents.value.map(a => a.name),
lastMessage: 'New group created',
timestamp: new Date()
})
}
showAgentSelector.value = false
}
// 取消选择
const cancelAgentSelection = () => {
showAgentSelector.value = false
}
// 选择助手
const selectAgent = (agent: Agent) => {
selectedAgent.value = agent
messages.value = [
{ id: 1, role: 'assistant', content: `你好!我是 ${agent.name}。有什么我可以帮助你的吗?`, timestamp: new Date() }
]
}
// 选择历史对话
const selectSession = (session: ChatSession) => {
const agent = chatAgents.value.find(a => a.id === session.agentId)
if (agent) {
selectedAgent.value = agent
}
messages.value = [
{ id: 1, role: 'assistant', content: `已加载会话:${session.title}`, timestamp: new Date() }
]
}
// 新建聊天
const newChat = () => {
messages.value = [
{ id: 1, role: 'assistant', content: `你好!我是 ${selectedAgent.value?.name || 'Claude'}。有什么我可以帮助你的吗?`, timestamp: new Date() }
]
}
// 格式化时间
const formatTime = (date: Date) => {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
// 格式化相对时间
const formatRelativeTime = (date: Date) => {
const now = new Date()
const diff = now.getTime() - date.getTime()
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (hours < 1) return '刚刚'
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return date.toLocaleDateString('zh-CN')
}
// 切换侧边栏
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
// 点击外部关闭下拉框
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.model-dropdown')) {
showModelDropdown.value = false
}
}
// 初始化
onMounted(() => {
fetchModels()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
return {
// 模型
chatModels,
selectedModel,
modelsLoading,
showModelDropdown,
fetchModels,
// 助手
chatAgents,
selectedAgent,
selectAgent,
// 消息
messages,
newChat,
// 历史对话
chatSessions,
selectSession,
// 群聊
groupChats,
// 智能体选择
showAgentSelector,
selectMode,
selectedAgents,
groupChatName,
openAgentSelector,
toggleAgentSelection,
confirmAgentSelection,
cancelAgentSelection,
// 输入
inputMessage,
isLoading,
// 侧边栏
sidebarCollapsed,
toggleSidebar,
// 工具
formatTime,
formatRelativeTime,
getModelIcon,
}
}

View File

@@ -19,8 +19,15 @@ const newAgent = ref({
skills: '',
knowledge: '',
prompt: '',
avatar: '🤖',
})
// 头像选项
const avatarOptions = [
'🤖', '🧠', '💻', '📊', '🔬', '🎧', '✨', '💬', '🔮', '🌙',
'🐉', '☁️', '🎨', '🎯', '🚀', '⚡', '🔥', '💡', '🎭', '🎪'
]
// Skills 选项
const skillsOptions = [
{ value: 'research', label: 'Research' },
@@ -41,7 +48,7 @@ const knowledgeOptions = [
// 打开创建弹窗
const openCreateModal = () => {
newAgent.value = { name: '', description: '', skills: '', knowledge: '', prompt: '' }
newAgent.value = { name: '', description: '', skills: '', knowledge: '', prompt: '', avatar: '🤖' }
showCreateModal.value = true
}
@@ -58,7 +65,7 @@ const createAgent = async () => {
agents.value.unshift({
id: newId,
name: newAgent.value.name,
avatar: '🤖',
avatar: newAgent.value.avatar,
description: newAgent.value.description,
accentColor: '#f97316',
gradient: 'from-orange-500/20 to-amber-500/20',
@@ -259,6 +266,22 @@ const deleteAgent = (id: number) => {
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Avatar</label>
<div class="flex flex-wrap gap-2">
<button
v-for="avatar in avatarOptions"
:key="avatar"
type="button"
@click="newAgent.avatar = avatar"
class="w-10 h-10 rounded-lg flex items-center justify-center text-lg transition-all"
:class="newAgent.avatar === avatar ? 'bg-primary-orange text-white ring-2 ring-orange-400' : 'bg-dark-600 text-gray-300 hover:bg-dark-500'"
>
{{ avatar }}
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Skills *</label>
<el-select v-model="newAgent.skills" placeholder="Select skills" class="w-full" size="large" popper-class="dark-select-dropdown">

View File

@@ -1,215 +1,72 @@
<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { useChat } from '@/composables/useChat'
import ChatHeader from '@/components/chat/ChatHeader.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import ChatSidebar from '@/components/chat/ChatSidebar.vue'
import ChatAgentSelector from '@/components/chat/ChatAgentSelector.vue'
const API_BASE = 'http://localhost:8082'
const {
chatModels,
selectedModel,
showModelDropdown,
chatAgents,
selectedAgent,
messages,
chatSessions,
groupChats,
showAgentSelector,
selectMode,
selectedAgents,
groupChatName,
inputMessage,
isLoading,
sidebarCollapsed,
fetchModels,
openAgentSelector,
toggleAgentSelection,
confirmAgentSelection,
cancelAgentSelection,
selectAgent,
selectSession,
newChat,
toggleSidebar,
} = useChat()
// 模型列表
interface ChatModel {
id: string
name: string
model_type: string
provider: string
model: string
status: string
}
const chatModels = ref<ChatModel[]>([])
const selectedModel = ref<ChatModel | null>(null)
const modelsLoading = ref(false)
const showModelDropdown = ref(false)
// 根据 provider 获取图标
const getModelIcon = (provider: string) => {
const icons: Record<string, string> = {
'OpenAI': '🤖',
'Claude': '🧠',
'Google': '✨',
'Gemini': '✨',
'Ollama': '🦙',
'DeepSeek': '🔮',
'Moonshot': '🌙',
'Kimi': '🌙',
'Baidu': '🐉',
'文心一言': '🐉',
'Aliyun': '☁️',
'Ali': '☁️',
'通义千问': '☁️',
'Azure': '⬛',
'Anthropic': '🧠',
}
return icons[provider] || '💬'
}
// 获取模型列表
const fetchModels = async () => {
modelsLoading.value = true
try {
const response = await fetch(`${API_BASE}/model/list`)
const data = await response.json()
if (data.list) {
// 过滤出 chat 类型且 active 状态的模型
chatModels.value = data.list.filter((m: ChatModel) => m.model_type === 'chat' && m.status === 'active')
console.log('Chat models:', chatModels.value)
// 默认选择第一个
if (chatModels.value.length > 0 && !selectedModel.value) {
selectedModel.value = chatModels.value[0]
}
}
} catch (error) {
console.error('Failed to fetch models:', error)
} finally {
modelsLoading.value = false
}
}
onMounted(() => {
fetchModels()
// 点击外部关闭下拉框
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
// 点击外部关闭下拉框
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.model-dropdown')) {
showModelDropdown.value = false
}
}
interface ChatMessage {
id: number
role: 'user' | 'assistant'
content: string
timestamp: Date
isStreaming?: boolean
}
interface Agent {
id: number
name: string
avatar: string
description: string
accentColor: string
gradient: string
status: 'online' | 'offline'
}
interface ChatSession {
id: number
title: string
agentId: number
lastMessage: string
timestamp: Date
}
// AI 助手配置
const chatAgents = ref<Agent[]>([
{ id: 1, name: 'Claude', avatar: '🧠', description: 'Anthropic AI', accentColor: '#f97316', gradient: 'from-orange-500/20 to-amber-500/20', status: 'online' },
{ id: 2, name: 'Gemini', avatar: '✨', description: 'Google DeepMind', accentColor: '#8b5cf6', gradient: 'from-violet-500/20 to-purple-500/20', status: 'online' },
{ id: 3, name: 'ChatGPT', avatar: '💬', description: 'OpenAI', accentColor: '#10b981', gradient: 'from-emerald-500/20 to-green-500/20', status: 'offline' },
{ id: 4, name: 'DeepSeek', avatar: '🔮', description: 'DeepSeek AI', accentColor: '#3b82f6', gradient: 'from-blue-500/20 to-cyan-500/20', status: 'online' },
{ id: 5, name: 'Kimi', avatar: '🌙', description: 'Moonshot AI', accentColor: '#ec4899', gradient: 'from-pink-500/20 to-rose-500/20', status: 'online' },
{ id: 6, name: '文心一言', avatar: '🐉', description: 'Baidu', accentColor: '#ef4444', gradient: 'from-red-500/20 to-orange-500/20', status: 'offline' },
{ id: 7, name: '通义千问', avatar: '☁️', description: 'Alibaba', accentColor: '#06b6d4', gradient: 'from-cyan-500/20 to-sky-500/20', status: 'online' },
])
// 当前选中的助手
const selectedAgent = ref<Agent | null>(chatAgents.value[0])
// 聊天消息
const messages = ref<ChatMessage[]>([
{ id: 1, role: 'assistant', content: '你好!我是 Claude你的 AI 助手。有什么我可以帮助你的吗?', timestamp: new Date() },
])
// 模拟历史对话列表
const chatSessions = ref<ChatSession[]>([
{ id: 1, title: '关于 Python 学习的讨论', agentId: 1, lastMessage: '谢谢你!', timestamp: new Date(Date.now() - 3600000) },
{ id: 2, title: '代码调试帮助', agentId: 1, lastMessage: '让我看看这个问题...', timestamp: new Date(Date.now() - 7200000) },
{ id: 3, title: '数据分析咨询', agentId: 4, lastMessage: 'DeepSeek: 好的', timestamp: new Date(Date.now() - 86400000) },
])
// 群聊数据
interface GroupChat {
id: number
name: string
members: string[]
lastMessage: string
timestamp: Date
}
const groupChats = ref<GroupChat[]>([
{ id: 1, name: 'AI 讨论组', members: ['Claude', 'GPT-4', 'Gemini'], lastMessage: '我们来讨论一下...', timestamp: new Date(Date.now() - 1800000) },
{ id: 2, name: '编程助手', members: ['Claude', 'DeepSeek'], lastMessage: '这段代码有问题吗?', timestamp: new Date(Date.now() - 3600000) },
{ id: 3, name: '创意头脑风暴', members: ['GPT-4', 'Claude', 'Kimi'], lastMessage: '有个新想法...', timestamp: new Date(Date.now() - 7200000) },
])
// 智能体选择弹窗状态
const showAgentSelector = ref(false)
const selectMode = ref<'single' | 'group'>('single')
const selectedAgents = ref<Agent[]>([])
const groupChatName = ref('')
// 打开智能体选择器
const openAgentSelector = (mode: 'single' | 'group') => {
selectMode.value = mode
selectedAgents.value = []
groupChatName.value = ''
showAgentSelector.value = true
}
// 切换智能体选择(群聊模式)
const toggleAgentSelection = (agent: Agent) => {
const index = selectedAgents.value.findIndex(a => a.id === agent.id)
if (index > -1) {
selectedAgents.value.splice(index, 1)
} else {
selectedAgents.value.push(agent)
}
}
// 确认选择
const confirmAgentSelection = () => {
if (selectMode.value === 'single') {
// 单聊模式:选择一个智能体开始对话
if (selectedAgents.value.length > 0) {
selectedAgent.value = selectedAgents.value[0]
messages.value = [
{ id: 1, role: 'assistant', content: `你好!我是 ${selectedAgent.value.name},你的 AI 助手。有什么我可以帮助你的吗?`, timestamp: new Date() }
]
}
} else {
// 群聊模式:选择多个智能体
const name = groupChatName.value.trim() || `群聊 (${selectedAgents.value.length}人)`
console.log('创建群聊:', { name, members: selectedAgents.value })
// 添加到群聊列表
groupChats.value.unshift({
id: Date.now(),
name: name,
members: selectedAgents.value.map(a => a.name),
lastMessage: 'New group created',
timestamp: new Date()
})
}
showAgentSelector.value = false
}
// 取消选择
const cancelAgentSelection = () => {
showAgentSelector.value = false
}
// 侧边栏展开/收起状态
const sidebarCollapsed = ref(false)
// 输入内容
const inputMessage = ref('')
const isLoading = ref(false)
const messagesContainer = ref<HTMLElement | null>(null)
// Mock 流式响应(用于测试前端流式效果)
const mockStreamResponse = async (content: string, messageIndex: number) => {
const chars = content.split('')
for (let i = 0; i < chars.length; i++) {
messages.value[messageIndex].content += chars[i]
await nextTick()
scrollToBottom()
await new Promise(resolve => setTimeout(resolve, 15))
}
messages.value[messageIndex].isStreaming = false
isLoading.value = false
}
// 滚动到底部
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
// 切换模型下拉框
const toggleModelDropdown = () => {
showModelDropdown.value = !showModelDropdown.value
}
// 选择模型
const handleSelectModel = (model: any) => {
selectedModel.value = model
showModelDropdown.value = false
}
// 发送消息
const sendMessage = async () => {
if (!inputMessage.value.trim() || isLoading.value) return
@@ -217,17 +74,17 @@ const sendMessage = async () => {
const userContent = inputMessage.value.trim()
inputMessage.value = ''
const userMessage: ChatMessage = {
const userMessage = {
id: Date.now(),
role: 'user',
role: 'user' as const,
content: userContent,
timestamp: new Date()
}
messages.value.push(userMessage)
const aiMessage: ChatMessage = {
const aiMessage = {
id: Date.now() + 1,
role: 'assistant',
role: 'assistant' as const,
content: '',
timestamp: new Date(),
isStreaming: true
@@ -238,20 +95,35 @@ const sendMessage = async () => {
isLoading.value = true
// ====== MOCK 模式:使用 mock 测试流式效果 ======
const USE_MOCK = false
if (USE_MOCK) {
const mockResponses = [
"你好!我是你的 AI 助手。",
"我收到了你的消息:\"" + userContent + "\"",
"这是一个模拟的流式响应效果。",
"现在你可以看到文字逐字出现的效果了!"
]
const fullResponse = mockResponses.join(' ')
const aiMessageIndex = messages.value.length - 1
await mockStreamResponse(fullResponse, aiMessageIndex)
return
}
// ====== MOCK 模式结束 ======
try {
// 构建请求体,包含模型信息
const requestBody: any = {
agent_id: selectedAgent.value?.id || 1,
message: userContent,
}
// 如果选择了模型,只传递 model_id后端会根据 id 查询 api_key 和 base_url
if (selectedModel.value) {
requestBody.model_id = selectedModel.value.id
}
// 调用后端 API
const response = await fetch(`${API_BASE}/api/agent/chat`, {
// 先获取完整响应,再逐字符显示(模拟流式效果)
const response = await fetch(`/api/agent/chat/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -259,108 +131,130 @@ const sendMessage = async () => {
body: JSON.stringify(requestBody),
})
const data = await response.json()
const fullResponse = data.reply || data.response || 'No response'
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
// 流式显示回复
let currentIndex = 0
const words = fullResponse.split('')
// 读取完整响应
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let fullText = ''
const streamInterval = setInterval(() => {
if (currentIndex < words.length) {
aiMessage.content += words[currentIndex]
currentIndex++
nextTick(() => scrollToBottom())
} else {
clearInterval(streamInterval)
aiMessage.isStreaming = false
isLoading.value = false
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)
if (data && data !== '[DONE]') {
fullText += data
}
}
}
}, 30)
}
// 逐字符显示模拟流式每3个字符更新一次UI减少重绘
const aiMessageIndex = messages.value.length - 1
messages.value[aiMessageIndex].content = ''
let tempText = ''
for (let i = 0; i < fullText.length; i++) {
tempText += fullText[i]
// 每3个字符更新一次UI减少重绘次数
if ((i + 1) % 3 === 0 || i === fullText.length - 1) {
messages.value[aiMessageIndex].content = tempText
await nextTick()
scrollToBottom()
// 添加小延迟,让动画更自然
await new Promise(r => setTimeout(r, 8))
}
}
messages.value[aiMessageIndex].isStreaming = false
isLoading.value = false
scrollToBottom()
} catch (error: any) {
aiMessage.content = `Error: ${error.message || 'Failed to send message'}`
aiMessage.isStreaming = false
console.error('[Stream] 错误:', error)
const errorIndex = messages.value.findIndex(m => m.isStreaming)
if (errorIndex > -1) {
messages.value[errorIndex].content = `Error: ${error.message || 'Failed to send message'}`
messages.value[errorIndex].isStreaming = false
}
isLoading.value = false
}
}
// 滚动到底部
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
// 复制消息
const copyMessage = (content: string) => {
navigator.clipboard.writeText(content)
}
// 选择助手
const selectAgent = (agent: Agent) => {
selectedAgent.value = agent
messages.value = [
{ id: 1, role: 'assistant', content: `你好!我是 ${agent.name}。有什么我可以帮助你的吗?`, timestamp: new Date() }
]
}
// 选择历史对话
const selectSession = (session: ChatSession) => {
const agent = chatAgents.value.find(a => a.id === session.agentId)
if (agent) {
selectedAgent.value = agent
}
messages.value = [
{ id: 1, role: 'assistant', content: `已加载会话:${session.title}`, timestamp: new Date() }
]
}
// 新建聊天
const newChat = () => {
messages.value = [
{ id: 1, role: 'assistant', content: `你好!我是 ${selectedAgent.value?.name || 'Claude'}。有什么我可以帮助你的吗?`, timestamp: new Date() }
]
}
// 格式化时间
const formatTime = (date: Date) => {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
// 格式化相对时间
const formatRelativeTime = (date: Date) => {
const now = new Date()
const diff = now.getTime() - date.getTime()
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (hours < 1) return '刚刚'
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return date.toLocaleDateString('zh-CN')
}
// 回车发送
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
// 调整输入框高度
const autoResize = (e: Event) => {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = Math.min(target.scrollHeight, 160) + 'px'
}
// 折叠侧边栏
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
</script>
<template>
<div class="h-screen flex bg-[#09090b]">
<!-- 主聊天区域 -->
<div class="flex-1 flex flex-col bg-[#09090b]">
<!-- 顶部栏 -->
<ChatHeader
:selected-agent="selectedAgent"
:chat-models="chatModels"
:selected-model="selectedModel"
:show-model-dropdown="showModelDropdown"
:sidebar-collapsed="sidebarCollapsed"
@toggle-dropdown="toggleModelDropdown"
@select-model="handleSelectModel"
@toggle-sidebar="toggleSidebar"
/>
<!-- 消息区域 -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto py-4">
<div class="px-6">
<ChatMessage
v-for="message in messages"
:key="message.id"
:message="message"
:selected-agent="selectedAgent"
/>
</div>
</div>
<!-- 输入区域 -->
<ChatInput
v-model="inputMessage"
:loading="isLoading"
@send="sendMessage"
/>
</div>
<!-- 右侧边栏 -->
<ChatSidebar
:collapsed="sidebarCollapsed"
:chat-agents="chatAgents"
:selected-agent="selectedAgent"
:chat-sessions="chatSessions"
:group-chats="groupChats"
@open-agent-selector="openAgentSelector"
@select-agent="selectAgent"
@select-session="selectSession"
/>
<!-- 智能体选择弹窗 -->
<ChatAgentSelector
:show="showAgentSelector"
:select-mode="selectMode"
:chat-agents="chatAgents"
:selected-agents="selectedAgents"
:group-chat-name="groupChatName"
@close="cancelAgentSelection"
@toggle-select="toggleAgentSelection"
@confirm="confirmAgentSelection"
@update:group-chat-name="groupChatName = $event"
/>
</div>
</template>
<style scoped>
::-webkit-scrollbar {
width: 6px;
@@ -379,18 +273,6 @@ const toggleSidebar = () => {
background: rgba(255, 255, 255, 0.15);
}
/* 下拉框动画 */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
@keyframes messageSlideIn {
from {
opacity: 0;
@@ -424,366 +306,3 @@ const toggleSidebar = () => {
animation: pulse-glow 2s ease-in-out infinite;
}
</style>
<template>
<div class="h-screen flex bg-[#09090b]">
<!-- 主聊天区域 -->
<div class="flex-1 flex flex-col bg-[#09090b]">
<!-- 顶部栏 -->
<div class="h-16 px-4 flex items-center justify-between border-b border-white/[0.06] bg-[#0c0c0f]/80 backdrop-blur-xl">
<!-- 左侧当前AI信息 -->
<div class="flex items-center gap-3">
<div v-if="selectedAgent" class="flex items-center gap-3">
<div
class="w-9 h-9 rounded-xl flex items-center justify-center text-lg shadow-lg"
:style="{ backgroundColor: selectedAgent.accentColor + '15', color: selectedAgent.accentColor }"
>
{{ selectedAgent.avatar }}
</div>
<div>
<div class="text-sm font-semibold text-white tracking-wide">{{ selectedAgent?.name || 'Chat' }}</div>
<div class="text-[11px] flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
<span class="text-white/40">Online</span>
</div>
</div>
</div>
</div>
<!-- 中间空白 -->
<div class="flex-1"></div>
<!-- 右侧模型选择和折叠按钮 -->
<div class="flex items-center gap-3">
<!-- 模型选择下拉框 -->
<div class="relative model-dropdown" v-if="chatModels.length > 0">
<button
@click="showModelDropdown = !showModelDropdown"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-white/[0.08] bg-[#1a1a24] hover:border-orange-500/30 transition-all duration-200"
>
<div class="flex items-center gap-2">
<!-- 动态获取模型图标 -->
<span class="text-base">{{ getModelIcon(selectedModel?.provider || '') }}</span>
<span class="text-sm text-white">{{ selectedModel?.name || 'Select Model' }}</span>
</div>
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
<svg class="w-3.5 h-3.5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- 下拉菜单 -->
<Transition name="dropdown">
<div
v-if="showModelDropdown"
class="absolute right-0 top-full mt-2 w-64 bg-[#1a1a24] border border-white/[0.08] rounded-xl shadow-2xl shadow-black/50 overflow-hidden z-50"
>
<div class="p-2">
<div class="text-xs text-white/40 px-2 py-1 mb-1">Select Chat Model</div>
<button
v-for="model in chatModels"
:key="model.id"
@click="selectedModel = model; showModelDropdown = false"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-150"
:class="selectedModel?.id === model.id
? 'bg-orange-500/15 border border-orange-500/30'
: 'hover:bg-white/5'"
>
<span class="text-lg">{{ getModelIcon(model.provider) }}</span>
<div class="flex-1 text-left">
<div class="text-sm text-white">{{ model.name }}</div>
<div class="text-xs text-white/40">{{ model.provider }} · {{ model.model }}</div>
</div>
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
</button>
</div>
</div>
</Transition>
</div>
<!-- 没有模型时的提示 -->
<div v-else class="text-xs text-white/30 px-2 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-white/20"></span>
No models
</div>
<button
@click="toggleSidebar"
class="p-2.5 rounded-xl hover:bg-white/[0.06] text-white/35 hover:text-white/80 transition-all duration-200"
:title="sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'"
>
<svg class="w-[18px] h-[18px] transition-transform duration-300" :class="sidebarCollapsed ? '' : 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
</div>
<!-- 消息区域 -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto py-4">
<div class="px-6">
<div
v-for="message in messages"
:key="message.id"
class="message-enter flex items-start mb-4"
:class="message.role === 'user' ? 'flex-row-reverse' : ''"
>
<!-- 头像 -->
<div
class="w-9 h-9 rounded-full flex-shrink-0 flex items-center justify-center mx-3 mt-1"
:class="message.role === 'user' ? 'bg-gradient-to-br from-orange-500 to-amber-600' : ''"
:style="message.role === 'assistant' && selectedAgent ? {
backgroundColor: selectedAgent.accentColor + '25',
color: selectedAgent.accentColor
} : {}"
>
<span v-if="message.role === 'user'" class="text-white text-sm">👤</span>
<span v-else class="text-lg">{{ selectedAgent?.avatar || '🧠' }}</span>
</div>
<!-- 气泡和时间戳容器 -->
<div :class="message.role === 'user' ? 'mr-3 ml-auto' : 'ml-3'">
<!-- 消息气泡 -->
<div
class="px-4 py-2.5 rounded-xl text-[14px] leading-6"
:class="message.role === 'user'
? 'bg-gradient-to-br from-orange-500 to-orange-600 text-white rounded-tr-sm'
: 'bg-[#2a2a35] text-white/90 rounded-tl-sm max-w-[80%]'"
>
{{ message.content }}
<span v-if="message.isStreaming" class="inline-block w-0.5 h-4 ml-0.5 bg-orange-300 cursor-blink align-middle"></span>
</div>
<!-- 时间戳 -->
<div
class="text-[11px] text-white/30 mt-1"
:class="message.role === 'user' ? 'text-right' : ''"
>
{{ formatTime(message.timestamp) }}
</div>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<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="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">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"></path>
</svg>
</button>
<!-- 输入框 -->
<textarea
v-model="inputMessage"
@keydown="handleKeydown"
@input="autoResize"
placeholder="发送消息..."
rows="1"
class="w-full bg-transparent text-white placeholder-white/25 py-4 pl-12 pr-28 resize-none focus:outline-none text-[15px]"
></textarea>
<!-- 发送按钮 -->
<button
@click="sendMessage"
:disabled="!inputMessage.trim() || isLoading"
class="absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 rounded-lg flex items-center justify-center transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
:class="inputMessage.trim() && !isLoading
? 'bg-orange-500 hover:bg-orange-400 shadow-lg shadow-orange-500/30 active:scale-90'
: 'bg-white/10'"
>
<svg v-if="!isLoading" class="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
<svg v-else class="w-4 h-4 text-white animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
</div>
<!-- 提示 -->
<div class="text-center mt-3">
<span class="text-[10px] text-white/20 tracking-wide">AI 可能会产生错误信息请核实重要内容</span>
</div>
</div>
</div>
</div>
<!-- 右侧边栏AI Hub -->
<div
class="flex-shrink-0 border-l border-white/[0.06] bg-[#0c0c0f] transition-all duration-300 ease-in-out overflow-hidden"
:class="sidebarCollapsed ? 'w-0 opacity-0' : 'w-72 opacity-100'"
>
<div class="w-72 h-full flex flex-col">
<!-- 侧边栏头部 -->
<div class="p-4 border-b border-white/[0.06]">
<div class="flex items-center gap-2 text-white font-semibold">
<svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path>
</svg>
<span>AI Hub</span>
</div>
</div>
<!-- 新建对话按钮 -->
<div class="p-3">
<div class="flex gap-2">
<button
@click="openAgentSelector('single')"
class="flex-1 flex items-center justify-center gap-2 px-3 py-2.5 bg-orange-500 hover:bg-orange-400 rounded-lg text-white text-sm font-medium transition-all duration-200"
>
<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="M12 4v16m8-8H4"></path>
</svg>
<span>新建对话</span>
</button>
<button
@click="openAgentSelector('group')"
class="flex-1 flex items-center justify-center gap-2 px-3 py-2.5 bg-dark-700 hover:bg-dark-600 border border-dark-500 rounded-lg text-white/80 text-sm font-medium transition-all duration-200"
>
<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="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>
</svg>
<span>新建群聊</span>
</button>
</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="selectAgent(agent)"
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200"
:class="selectedAgent?.id === agent.id
? 'bg-orange-500/15 text-orange-400'
: 'text-white/60 hover:bg-white/5 hover:text-white'"
>
<span class="text-base">{{ agent.avatar }}</span>
<span class="text-sm truncate">{{ agent.name }}</span>
<span
v-if="agent.status === 'online'"
class="w-1.5 h-1.5 rounded-full bg-emerald-400 ml-auto"
></span>
</button>
</div>
</div>
<!-- 群聊列表 -->
<div class="flex-1 overflow-y-auto px-3 pb-3">
<div class="text-xs text-white/40 uppercase tracking-wider mb-2 px-1">群聊</div>
<div class="space-y-1">
<button
v-for="group in groupChats"
:key="group.id"
class="w-full text-left px-3 py-2.5 rounded-lg hover:bg-white/5 transition-all duration-200 group"
>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-white/30 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
<span class="text-sm text-white/70 group-hover:text-white truncate">{{ group.name }}</span>
</div>
<div class="text-xs text-white/30 mt-1 pl-6">{{ group.members.length }} members</div>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 智能体选择弹窗 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showAgentSelector" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50" @click="cancelAgentSelection">
<div class="bg-dark-800 rounded-xl w-full max-w-md border border-dark-600 shadow-2xl" @click.stop>
<div class="p-4 border-b border-dark-600">
<h3 class="text-lg font-semibold text-white">
{{ selectMode === 'single' ? '选择智能体' : '选择群聊成员' }}
</h3>
<p class="text-sm text-gray-400 mt-1">
{{ selectMode === 'single' ? '选择一个智能体开始对话' : '选择多个智能体创建群聊' }}
</p>
</div>
<div class="p-4 max-h-80 overflow-y-auto">
<!-- 群聊名称输入框 -->
<div v-if="selectMode === 'group'" class="mb-4">
<input
v-model="groupChatName"
type="text"
placeholder="Enter group name..."
class="w-full bg-dark-700 border border-dark-500 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-primary-orange"
>
</div>
<div class="space-y-2">
<button
v-for="agent in chatAgents"
:key="agent.id"
@click="selectMode === 'group' ? toggleAgentSelection(agent) : (selectedAgents = [agent], confirmAgentSelection())"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200"
:class="selectedAgents.some(a => a.id === agent.id)
? 'bg-orange-500/20 border border-orange-500/50'
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'"
>
<span class="text-xl">{{ agent.avatar }}</span>
<div class="flex-1 text-left">
<div class="text-white font-medium">{{ agent.name }}</div>
<div class="text-xs text-gray-400">{{ agent.description }}</div>
</div>
<span
v-if="agent.status === 'online'"
class="w-2 h-2 rounded-full bg-emerald-400"
></span>
<span
v-if="selectMode === 'group' && selectedAgents.some(a => a.id === agent.id)"
class="w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center"
>
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path>
</svg>
</span>
</button>
</div>
</div>
<div class="p-4 border-t border-dark-600 flex gap-3">
<button
@click="cancelAgentSelection"
class="flex-1 py-2.5 bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors"
>
Cancel
</button>
<button
v-if="selectMode === 'group'"
@click="confirmAgentSelection"
:disabled="selectedAgents.length < 2"
class="flex-1 py-2.5 bg-orange-500 hover:bg-orange-400 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Create Group ({{ selectedAgents.length }})
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useSkills } from './skill/useSkills'
import { Edit, Trash2, Wand2, Plus, Search, X } from 'lucide-vue-next'
import { Edit, Trash2, Wand2, Plus, Search, X, FolderInput } from 'lucide-vue-next'
import { ElMessage } from 'element-plus'
import '@/views/database/database.css'
const {
@@ -23,10 +24,91 @@ const {
deleteSkill,
} = useSkills()
// 从本地导入
const fileInputRef = ref<HTMLInputElement | null>(null)
const isImporting = ref(false)
// 触发文件夹选择
const triggerImport = () => {
fileInputRef.value?.click()
}
// 处理文件夹选择
const handleFolderSelect = async (event: Event) => {
const input = event.target as HTMLInputElement
const files = input.files
if (!files || files.length === 0) return
isImporting.value = true
try {
// 获取第一个文件夹
const folder = files[0].webkitRelativePath?.split('/')[0] || files[0].name
console.log('选择的文件夹:', folder)
// 查找 SKILL.md 文件
let skillMdFile: File | null = null
for (let i = 0; i < files.length; i++) {
const file = files[i]
// 检查是否是 SKILL.md 文件
if (file.name === 'SKILL.md' || file.webkitRelativePath?.endsWith('/SKILL.md')) {
skillMdFile = file
break
}
}
if (!skillMdFile) {
ElMessage.error('导入失败:所选文件夹中未找到 SKILL.md 文件,请确保选择包含 SKILL.md 的文件夹')
return
}
// 读取 SKILL.md 内容
const content = await skillMdFile.text()
console.log('SKILL.md 内容:', content.substring(0, 100))
// 解析 SKILL.md 内容
// 格式:第一行是 skill_name后面是 skill_desc
const lines = content.trim().split('\n')
const skillName = lines[0]?.replace(/^#\s*/, '').trim() || folder
const skillDesc = lines.slice(1).join('\n').trim()
// 调用保存接口
const response = await fetch('http://localhost:8082/skill/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skill_name: skillName,
skill_desc: skillDesc,
skill_type: 'user',
status: 'active'
})
})
if (response.ok) {
ElMessage.success(`Skill imported: ${skillName}`)
fetchSkills()
} else {
const err = await response.json()
ElMessage.error(err.message || '导入失败')
}
} catch (error) {
console.error('Import failed:', error)
ElMessage.error('导入失败,请检查文件夹格式是否正确')
} finally {
isImporting.value = false
// 清空 input 以便重新选择同一文件夹
input.value = ''
}
}
// 页面加载时获取技能列表
onMounted(() => {
fetchSkills()
})
// 下拉菜单显示状态
const showDropdown = ref(false)
</script>
<template>
@@ -37,10 +119,51 @@ onMounted(() => {
<Wand2 class="w-5 h-5 text-orange-500" />
<span class="font-medium">Skills</span>
</div>
<button @click="openCreate" class="btn-primary">
<Plus class="w-4 h-4" />
New Skill
</button>
<!-- 新建按钮带下拉菜单 -->
<div class="relative" @mouseenter="showDropdown = true" @mouseleave="showDropdown = false">
<button class="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 text-white text-sm font-medium hover:from-orange-400 hover:to-amber-400 transition-all">
<Plus class="w-4 h-4" />
New Skill
<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 9l-7 7-7-7"></path>
</svg>
</button>
<!-- 下拉菜单 -->
<div v-if="showDropdown" class="absolute right-0 top-full mt-2 w-48 bg-dark-700 border border-dark-500 rounded-lg shadow-xl overflow-hidden z-50">
<button
@click="openCreate"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-dark-600 transition-colors text-left"
>
<Plus class="w-4 h-4 text-orange-400" />
<div>
<div class="text-sm text-white">Create Skill</div>
<div class="text-xs text-gray-400">Manual create</div>
</div>
</button>
<button
@click="triggerImport"
:disabled="isImporting"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-dark-600 transition-colors text-left disabled:opacity-50"
>
<FolderInput class="w-4 h-4 text-blue-400" />
<div>
<div class="text-sm text-white">Import Skill</div>
<div class="text-xs text-gray-400">From local folder</div>
</div>
</button>
</div>
</div>
<!-- 隐藏的文件选择器 -->
<input
ref="fileInputRef"
type="file"
webkitdirectory=""
directory=""
multiple
style="display: none"
@change="handleFolderSelect"
/>
</div>
<!-- 搜索和筛选 -->

View File

@@ -165,6 +165,7 @@ export function useModelSettings() {
body: JSON.stringify({
provider: editForm.value.provider,
model: editForm.value.model,
model_type: editForm.value.modelType || 'chat',
api_key: editForm.value.apiKey,
base_url: editForm.value.baseUrl,
api_endpoint: editForm.value.apiEndpoint,

View File

@@ -5,3 +5,11 @@ declare module '*.vue' {
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_API_BASE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -16,6 +16,18 @@ export default defineConfig({
server: {
fs: {
allow: ['..']
},
proxy: {
'/api': {
target: 'http://localhost:8082',
changeOrigin: true,
ws: true
},
'/model': {
target: 'http://localhost:8082',
changeOrigin: true,
ws: true
}
}
}
})