Add streaming support and refactor Chat UI

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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