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:
@@ -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)}"
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
17
agent/app/xbot/__init__.py
Normal file
17
agent/app/xbot/__init__.py
Normal 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
186
agent/app/xbot/adapter.py
Normal 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
256
agent/app/xbot/agent.py
Normal 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
190
agent/app/xbot/loop.py
Normal 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
240
agent/app/xbot/memory.py
Normal 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
169
agent/app/xbot/session.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
109
web/src/components/chat/ChatAgentSelector.vue
Normal file
109
web/src/components/chat/ChatAgentSelector.vue
Normal 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>
|
||||
119
web/src/components/chat/ChatHeader.vue
Normal file
119
web/src/components/chat/ChatHeader.vue
Normal 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>
|
||||
72
web/src/components/chat/ChatInput.vue
Normal file
72
web/src/components/chat/ChatInput.vue
Normal 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>
|
||||
135
web/src/components/chat/ChatMessage.vue
Normal file
135
web/src/components/chat/ChatMessage.vue
Normal 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>
|
||||
115
web/src/components/chat/ChatSidebar.vue
Normal file
115
web/src/components/chat/ChatSidebar.vue
Normal 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>
|
||||
332
web/src/composables/useChat.ts
Normal file
332
web/src/composables/useChat.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
|
||||
@@ -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,
|
||||
|
||||
8
web/src/vite-env.d.ts
vendored
8
web/src/vite-env.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user