Compare commits
19 Commits
a70f151615
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a31505cd95 | |||
| 92c6c27b25 | |||
| 1f8ba61444 | |||
| 8c515a8b3e | |||
| caada15831 | |||
| eb0061b644 | |||
| 60f183b8e4 | |||
| 5e3da508cf | |||
| 3041ce770f | |||
| a6667e0a33 | |||
| 0662165c77 | |||
| cdbaefecf8 | |||
| f532b21a7a | |||
| 658866a661 | |||
| 9d04b0b200 | |||
| 85a3d1e27d | |||
| 62813cbe2e | |||
| 53488f792d | |||
| 1cfebd37be |
@@ -1,7 +0,0 @@
|
|||||||
# LLM API 配置
|
|
||||||
LLM_API_KEY=your-api-key-here
|
|
||||||
LLM_BASE_URL=https://api.deepseek.com
|
|
||||||
LLM_MODEL=deepseek-chat
|
|
||||||
|
|
||||||
# 服务器配置
|
|
||||||
SERVER_PORT=8091
|
|
||||||
@@ -8,12 +8,16 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: '1.26'
|
GO_VERSION: '1.26'
|
||||||
|
GOPROXY: 'https://goproxy.cn,direct'
|
||||||
GOLANGCI_LINT_VERSION: 'v1.57.2'
|
GOLANGCI_LINT_VERSION: 'v1.57.2'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -22,19 +26,10 @@ jobs:
|
|||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
cache: true
|
cache: false
|
||||||
|
|
||||||
- name: Cache golangci-lint
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: /usr/local/bin/golangci-lint
|
|
||||||
key: golangci-lint-${{ env.GOLANGCI_LINT_VERSION }}
|
|
||||||
|
|
||||||
- name: Install golangci-lint
|
- name: Install golangci-lint
|
||||||
run: |
|
run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin ${{ env.GOLANGCI_LINT_VERSION }}
|
||||||
if [ ! -f /usr/local/bin/golangci-lint ]; then
|
|
||||||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin ${{ env.GOLANGCI_LINT_VERSION }}
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Run golangci-lint
|
- name: Run golangci-lint
|
||||||
run: golangci-lint run --timeout=5m --issues-exit-code=0
|
run: golangci-lint run --timeout=5m --issues-exit-code=0
|
||||||
@@ -42,6 +37,9 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Test
|
name: Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -50,7 +48,7 @@ jobs:
|
|||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
cache: true
|
cache: false
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: |
|
||||||
@@ -66,12 +64,15 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: coverage-report
|
name: coverage-report
|
||||||
path: coverage.out
|
path: backend/coverage.out
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [lint, test]
|
needs: [lint, test]
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -80,7 +81,7 @@ jobs:
|
|||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
cache: true
|
cache: false
|
||||||
|
|
||||||
- name: Build binary
|
- name: Build binary
|
||||||
run: |
|
run: |
|
||||||
@@ -91,32 +92,22 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: goloom-server
|
name: goloom-server
|
||||||
path: goloom-server
|
path: backend/goloom-server
|
||||||
|
|
||||||
docker:
|
# TODO: 添加 Dockerfile 后启用
|
||||||
name: Docker Build
|
# docker:
|
||||||
runs-on: ubuntu-latest
|
# name: Docker Build
|
||||||
needs: [build]
|
# runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
# needs: [build]
|
||||||
steps:
|
# if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
- name: Checkout code
|
# steps:
|
||||||
uses: actions/checkout@v4
|
# - name: Checkout code
|
||||||
|
# uses: actions/checkout@v4
|
||||||
- name: Download binary
|
# - name: Download binary
|
||||||
uses: actions/download-artifact@v3
|
# uses: actions/download-artifact@v3
|
||||||
with:
|
# with:
|
||||||
name: goloom-server
|
# name: goloom-server
|
||||||
|
# - name: Build Docker image
|
||||||
- name: Set up Docker Buildx
|
# run: |
|
||||||
uses: docker/setup-buildx-action@v3
|
# chmod +x goloom-server
|
||||||
|
# docker build -t goloom:latest -t goloom:${{ github.sha }} .
|
||||||
- name: Build Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: false
|
|
||||||
tags: |
|
|
||||||
goloom:latest
|
|
||||||
goloom:${{ github.sha }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|||||||
33
.gitignore
vendored
33
.gitignore
vendored
@@ -1,2 +1,33 @@
|
|||||||
|
# Go
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
backend/coverage.out
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
backend/.env
|
||||||
|
|
||||||
|
# Documentation
|
||||||
docs/
|
docs/
|
||||||
CLAUDE.md
|
|
||||||
|
# Claude
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/.next/
|
||||||
|
frontend/out/
|
||||||
|
|||||||
197
README.md
Normal file
197
README.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# GoLoom
|
||||||
|
|
||||||
|
**AI Agent Scaffold** — 一个基于 Go 的多 Agent LLM 编排框架。
|
||||||
|
|
||||||
|
GoLoom 提供开箱即用的 HTTP 服务,支持多 Agent 编排、SSE 流式对话、工具调用(Function Calling)以及 YAML 配置驱动的 Agent 定义,帮助你快速构建和部署 AI Agent 应用。
|
||||||
|
|
||||||
|
## 核心特性
|
||||||
|
|
||||||
|
- **多 Agent 编排** — 支持 4 种编排模式:LLM(单轮)、Sequential(顺序)、Parallel(并发)、Loop(循环)
|
||||||
|
- **SSE 流式对话** — 基于 Server-Sent Events 的实时流式输出
|
||||||
|
- **工具调用** — OpenAI Function Calling 协议,支持自定义 Tool 扩展
|
||||||
|
- **YAML 配置驱动** — 通过 YAML 文件定义 Agent 拓扑,支持环境变量展开
|
||||||
|
- **OpenAI 兼容** — 适配 DeepSeek、通义千问等 OpenAI 兼容 API
|
||||||
|
- **Next.js 前端** — 配套的 React + TypeScript + Tailwind CSS 聊天界面
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| 后端语言 | Go 1.26 |
|
||||||
|
| Web 框架 | Gin |
|
||||||
|
| 日志 | Zap (structured logging) |
|
||||||
|
| 前端框架 | Next.js + React + TypeScript |
|
||||||
|
| 样式 | Tailwind CSS |
|
||||||
|
| LLM 协议 | OpenAI Chat Completions API |
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
GoLoom/
|
||||||
|
├── backend/ # Go 后端
|
||||||
|
│ ├── cmd/server/main.go # 入口:.env → config → bootstrap → Gin
|
||||||
|
│ ├── internal/
|
||||||
|
│ │ ├── config/ # YAML 配置加载 + ${VAR} 环境变量展开
|
||||||
|
│ │ ├── handler/ # Gin 路由、请求/响应处理、SSE
|
||||||
|
│ │ ├── service/ # ChatService、Agent 实现、Runner、Assembler
|
||||||
|
│ │ ├── model/ # 核心接口(Agent、ChatModel、Tool、Runner)
|
||||||
|
│ │ └── llm/ # OpenAI 兼容 HTTP 客户端
|
||||||
|
│ ├── pkg/types/ # 错误码与 AppError 类型
|
||||||
|
│ ├── configs/ # application.yaml + agent/*.yaml
|
||||||
|
│ └── .env.example # 环境变量模板
|
||||||
|
├── frontend/ # Next.js 前端
|
||||||
|
├── docs/ # 详细文档(中文)
|
||||||
|
└── CLAUDE.md # Claude Code 开发指南
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖方向:** handler → service → model/llm(`model` 不依赖任何内部包)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 环境准备
|
||||||
|
|
||||||
|
- Go 1.26+
|
||||||
|
- Node.js 18+(前端)
|
||||||
|
- 一个 OpenAI 兼容的 LLM API Key
|
||||||
|
|
||||||
|
### 2. 后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# 配置环境变量
|
||||||
|
cp .env.example .env
|
||||||
|
# 编辑 .env,填入你的 API Key
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
go run ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
服务默认监听 `http://localhost:8091`。
|
||||||
|
|
||||||
|
### 3. 前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
前端默认运行在 `http://localhost:3000`。
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
基础路径:`/api/v1`
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/healthz` | 健康检查 |
|
||||||
|
| GET | `/api/v1/query_ai_agent_config_list` | 查询已注册 Agent 列表 |
|
||||||
|
| POST | `/api/v1/create_session` | 创建会话 |
|
||||||
|
| POST | `/api/v1/chat` | 同步对话 |
|
||||||
|
| POST | `/api/v1/chat_stream` | SSE 流式对话 |
|
||||||
|
|
||||||
|
**典型流程:** 查询 Agent 列表 → 创建会话 → 使用 sessionId 进行对话。
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查询 Agent 列表
|
||||||
|
curl http://localhost:8091/api/v1/query_ai_agent_config_list
|
||||||
|
|
||||||
|
# 创建会话
|
||||||
|
curl -X POST http://localhost:8091/api/v1/create_session \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"agent_id": "your-agent-id"}'
|
||||||
|
|
||||||
|
# 同步对话
|
||||||
|
curl -X POST http://localhost:8091/api/v1/chat \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"session_id": "xxx", "content": "你好"}'
|
||||||
|
|
||||||
|
# 流式对话
|
||||||
|
curl -X POST http://localhost:8091/api/v1/chat_stream \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"session_id": "xxx", "content": "你好"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent 配置
|
||||||
|
|
||||||
|
在 `backend/configs/agent/` 目录下创建 YAML 文件定义 Agent:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: my-agent
|
||||||
|
name: My Agent
|
||||||
|
description: 一个示例 Agent
|
||||||
|
type: llm
|
||||||
|
model_id: deepseek-chat
|
||||||
|
system_prompt: |
|
||||||
|
你是一个 helpful assistant.
|
||||||
|
tools:
|
||||||
|
- name: search
|
||||||
|
description: 搜索工具
|
||||||
|
parameters:
|
||||||
|
query:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: 搜索关键词
|
||||||
|
```
|
||||||
|
|
||||||
|
支持 4 种 Agent 类型:
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `llm` | 单次 LLM 调用,支持工具调用循环(最多 4 轮) |
|
||||||
|
| `sequential` | 顺序执行子 Agent,前一个的输出注入下一个的 `{outputKey}` |
|
||||||
|
| `parallel` | 并发执行所有子 Agent,合并结果 |
|
||||||
|
| `loop` | 重复执行子 Agent,最多 `maxIterations` 次 |
|
||||||
|
|
||||||
|
配置支持环境变量展开:`${VAR}` 或 `${VAR:-default}`。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# 运行所有测试
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# 带 race 检测和覆盖率
|
||||||
|
go test -race -coverprofile=coverage.out ./...
|
||||||
|
|
||||||
|
# 查看覆盖率
|
||||||
|
go tool cover -html=coverage.out
|
||||||
|
|
||||||
|
# 运行指定测试
|
||||||
|
go test -run TestFuncName ./internal/service/...
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
golangci-lint run --timeout=5m
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| [架构设计](docs/architecture.md) | 整体架构与设计决策 |
|
||||||
|
| [API 参考](docs/api-reference.md) | HTTP API 详细规格与 curl 示例 |
|
||||||
|
| [后端构建指南](docs/build-from-scratch.md) | 完整的 Go 后端实现参考 |
|
||||||
|
| [前端构建指南](docs/frontend-build-from-scratch.md) | Next.js 前端实现参考 |
|
||||||
|
| [测试指南](docs/testing-guide.md) | 测试规范与最佳实践 |
|
||||||
|
| [日志指南](docs/logging-guide.md) | Zap 日志级别与结构化字段规范 |
|
||||||
|
| [构建计划](docs/plan.md) | 6 阶段开发计划与进度追踪 |
|
||||||
|
|
||||||
|
## 设计理念
|
||||||
|
|
||||||
|
- **接口驱动** — 核心抽象(Agent、ChatModel、Tool、Runner)定义在 `model` 包,零外部依赖
|
||||||
|
- **配置即代码** — YAML 定义 Agent 拓扑,无需修改代码即可编排复杂工作流
|
||||||
|
- **依赖注入** — ChatService 通过接口注入 AgentRegistry 和 SessionStore,便于测试和扩展
|
||||||
|
- **渐进式复杂度** — 从单 Agent 到多 Agent 编排,按需组合
|
||||||
|
|
||||||
|
## 协议
|
||||||
|
|
||||||
|
MIT License
|
||||||
2
backend/.env.example
Normal file
2
backend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
OPENAI_BASE_URL=https://api.openai.com
|
||||||
|
OPENAI_API_KEY=sk-your-key-here
|
||||||
111
backend/cmd/server/main.go
Normal file
111
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"ai-agent-scaffold-go/internal/config"
|
||||||
|
"ai-agent-scaffold-go/internal/handler"
|
||||||
|
"ai-agent-scaffold-go/internal/model"
|
||||||
|
"ai-agent-scaffold-go/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
envPath := flag.String("env", ".env", "path to dotenv file (empty to skip)")
|
||||||
|
configPath := flag.String("config", "configs/application.yaml", "path to application.yaml")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// 1. 加载 .env 文件
|
||||||
|
loadDotenv(*envPath)
|
||||||
|
|
||||||
|
// 2. 加载应用配置
|
||||||
|
appCfg, err := config.LoadApplication(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 初始化日志
|
||||||
|
logger, _ := zap.NewProduction()
|
||||||
|
if appCfg.App.Env == "local" || appCfg.App.Env == "dev" {
|
||||||
|
logger, _ = zap.NewDevelopment()
|
||||||
|
}
|
||||||
|
defer logger.Sync()
|
||||||
|
|
||||||
|
// 4. 组装 Agent
|
||||||
|
registry := model.NewInMemoryAgentRegistry()
|
||||||
|
sessions := model.NewInMemorySessionStore()
|
||||||
|
|
||||||
|
timeout, _ := appCfg.LLM.RequestTimeoutDuration()
|
||||||
|
agents, err := service.LoadAndAssemble(context.Background(), appCfg.Agent.ConfigPaths, timeout)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("assemble agents failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
for _, agent := range agents {
|
||||||
|
if err := registry.Register(agent); err != nil {
|
||||||
|
logger.Fatal("register agent failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatService := service.NewChatService(registry, sessions)
|
||||||
|
|
||||||
|
// 5. 配置 HTTP 路由
|
||||||
|
router := gin.Default()
|
||||||
|
router.Use(corsMiddleware())
|
||||||
|
router.GET("/healthz", func(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
handler.RegisterRoutes(router, chatService)
|
||||||
|
|
||||||
|
// 6. 启动服务
|
||||||
|
addr := appCfg.Server.Addr
|
||||||
|
logger.Info("server starting", zap.String("addr", addr), zap.Int("agents", len(agents)))
|
||||||
|
if err := router.Run(addr); err != nil {
|
||||||
|
logger.Fatal("server stopped", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// corsMiddleware 跨域中间件
|
||||||
|
func corsMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
origin := c.GetHeader("Origin")
|
||||||
|
if origin != "" {
|
||||||
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
|
c.Header("Vary", "Origin")
|
||||||
|
} else {
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
}
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
c.Header("Access-Control-Allow-Credentials", "true")
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadDotenv 加载 dotenv 文件,已有环境变量不会被覆盖
|
||||||
|
func loadDotenv(path string) {
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pairs, err := godotenv.Read(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("read dotenv: %v", err)
|
||||||
|
}
|
||||||
|
for key, value := range pairs {
|
||||||
|
if _, exists := os.LookupEnv(key); !exists {
|
||||||
|
os.Setenv(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/configs/agent/only-one-agent.yaml
Normal file
23
backend/configs/agent/only-one-agent.yaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
ai:
|
||||||
|
agent:
|
||||||
|
config:
|
||||||
|
tables:
|
||||||
|
myAgent:
|
||||||
|
app-name: myAgent
|
||||||
|
agent:
|
||||||
|
agent-id: "10001"
|
||||||
|
agent-name: "my-assistant"
|
||||||
|
agent-desc: "通用 AI 助手"
|
||||||
|
module:
|
||||||
|
ai-api:
|
||||||
|
base-url: ${OPENAI_BASE_URL}
|
||||||
|
api-key: ${OPENAI_API_KEY}
|
||||||
|
chat-model:
|
||||||
|
model: "gpt-4"
|
||||||
|
agents:
|
||||||
|
- name: "assistant"
|
||||||
|
description: "通用助手"
|
||||||
|
instruction: |
|
||||||
|
你是一个有帮助的 AI 助手。请用中文回答用户的问题。
|
||||||
|
runner:
|
||||||
|
agent-name: "assistant"
|
||||||
13
backend/configs/application.yaml
Normal file
13
backend/configs/application.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
app:
|
||||||
|
name: goloom
|
||||||
|
env: local
|
||||||
|
|
||||||
|
server:
|
||||||
|
addr: ":8091"
|
||||||
|
|
||||||
|
llm:
|
||||||
|
request-timeout: 5m
|
||||||
|
|
||||||
|
agent:
|
||||||
|
config-paths:
|
||||||
|
- configs/agent/only-one-agent.yaml
|
||||||
46
backend/go.mod
Normal file
46
backend/go.mod
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
module ai-agent-scaffold-go
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
go.uber.org/zap v1.28.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
)
|
||||||
107
backend/go.sum
Normal file
107
backend/go.sum
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
|
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||||
|
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
75
backend/internal/config/application.go
Normal file
75
backend/internal/config/application.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Application 应用配置顶层结构
|
||||||
|
type Application struct {
|
||||||
|
App AppSection `yaml:"app"`
|
||||||
|
Server ServerSection `yaml:"server"`
|
||||||
|
Agent AgentSection `yaml:"agent"`
|
||||||
|
LLM LLMSection `yaml:"llm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppSection 应用基础信息
|
||||||
|
type AppSection struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Env string `yaml:"env"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerSection 服务器配置
|
||||||
|
type ServerSection struct {
|
||||||
|
Addr string `yaml:"addr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentSection Agent 配置路径列表
|
||||||
|
type AgentSection struct {
|
||||||
|
ConfigPaths []string `yaml:"config-paths"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLMSection LLM 相关配置
|
||||||
|
type LLMSection struct {
|
||||||
|
RequestTimeout string `yaml:"request-timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultLLMRequestTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
// RequestTimeoutDuration 解析 LLM 请求超时时间
|
||||||
|
func (s LLMSection) RequestTimeoutDuration() (time.Duration, error) {
|
||||||
|
if s.RequestTimeout == "" {
|
||||||
|
return defaultLLMRequestTimeout, nil
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(s.RequestTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid llm.request-timeout %q: %w", s.RequestTimeout, err)
|
||||||
|
}
|
||||||
|
if d <= 0 {
|
||||||
|
return 0, fmt.Errorf("llm.request-timeout must be positive, got %q", s.RequestTimeout)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadApplication 从指定路径加载应用配置
|
||||||
|
func LoadApplication(path string) (Application, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return Application{}, fmt.Errorf("read application config %s: %w", path, err)
|
||||||
|
}
|
||||||
|
var app Application
|
||||||
|
if err := yaml.Unmarshal(data, &app); err != nil {
|
||||||
|
return Application{}, fmt.Errorf("parse application config: %w", err)
|
||||||
|
}
|
||||||
|
// 设置默认值
|
||||||
|
if app.Server.Addr == "" {
|
||||||
|
app.Server.Addr = ":8091"
|
||||||
|
}
|
||||||
|
if app.App.Env == "" {
|
||||||
|
app.App.Env = "local"
|
||||||
|
}
|
||||||
|
return app, nil
|
||||||
|
}
|
||||||
122
backend/internal/config/loader.go
Normal file
122
backend/internal/config/loader.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"ai-agent-scaffold-go/internal/model"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// agentRoot 对应 YAML 的 ai.agent.config.tables 结构
|
||||||
|
type agentRoot struct {
|
||||||
|
AI struct {
|
||||||
|
Agent struct {
|
||||||
|
Config struct {
|
||||||
|
Tables map[string]model.AiAgentConfigTable `yaml:"tables"`
|
||||||
|
} `yaml:"config"`
|
||||||
|
} `yaml:"agent"`
|
||||||
|
} `yaml:"ai"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAgentTables 从字节数据加载 Agent 配置表
|
||||||
|
func LoadAgentTables(data []byte) (map[string]model.AiAgentConfigTable, error) {
|
||||||
|
expanded := expandEnvPlaceholders(string(data))
|
||||||
|
var root agentRoot
|
||||||
|
if err := yaml.Unmarshal([]byte(expanded), &root); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse agent config: %w", err)
|
||||||
|
}
|
||||||
|
tables := root.AI.Agent.Config.Tables
|
||||||
|
if len(tables) == 0 {
|
||||||
|
return nil, fmt.Errorf("agent config tables are required")
|
||||||
|
}
|
||||||
|
for name, table := range tables {
|
||||||
|
normalizeDefaults(&table)
|
||||||
|
if err := validateTable(name, table); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tables[name] = table
|
||||||
|
}
|
||||||
|
return tables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAgentTablesFile 从文件路径加载 Agent 配置表
|
||||||
|
func LoadAgentTablesFile(path string) (map[string]model.AiAgentConfigTable, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read agent config %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return LoadAgentTables(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// envPlaceholderRE 匹配 ${VAR} 和 ${VAR:-default} 格式的环境变量占位符
|
||||||
|
var envPlaceholderRE = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}`)
|
||||||
|
|
||||||
|
// expandEnvPlaceholders 替换字符串中的环境变量占位符
|
||||||
|
func expandEnvPlaceholders(input string) string {
|
||||||
|
return envPlaceholderRE.ReplaceAllStringFunc(input, func(match string) string {
|
||||||
|
groups := envPlaceholderRE.FindStringSubmatch(match)
|
||||||
|
name := groups[1]
|
||||||
|
if value, ok := os.LookupEnv(name); ok && value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if len(groups) > 2 {
|
||||||
|
return groups[2]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeDefaults 填充配置默认值
|
||||||
|
func normalizeDefaults(table *model.AiAgentConfigTable) {
|
||||||
|
if table.Module.AiAPI.CompletionsPath == "" {
|
||||||
|
table.Module.AiAPI.CompletionsPath = "v1/chat/completions"
|
||||||
|
}
|
||||||
|
for i := range table.Module.AgentWorkflows {
|
||||||
|
if table.Module.AgentWorkflows[i].MaxIterations == 0 {
|
||||||
|
table.Module.AgentWorkflows[i].MaxIterations = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateTable 校验配置表的必填字段
|
||||||
|
func validateTable(name string, table model.AiAgentConfigTable) error {
|
||||||
|
prefix := "agent table " + name
|
||||||
|
required := map[string]string{
|
||||||
|
"app-name": table.AppName,
|
||||||
|
"agent.agent-id": table.Agent.AgentID,
|
||||||
|
"module.ai-api.base-url": table.Module.AiAPI.BaseURL,
|
||||||
|
"module.ai-api.api-key": table.Module.AiAPI.APIKey,
|
||||||
|
"module.chat-model.model": table.Module.ChatModel.Model,
|
||||||
|
"module.runner.agent-name": table.Module.Runner.AgentName,
|
||||||
|
}
|
||||||
|
for field, value := range required {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fmt.Errorf("%s: %s is required", prefix, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(table.Module.Agents) == 0 {
|
||||||
|
return fmt.Errorf("%s: module.agents is required", prefix)
|
||||||
|
}
|
||||||
|
for i, agent := range table.Module.Agents {
|
||||||
|
if strings.TrimSpace(agent.Name) == "" {
|
||||||
|
return fmt.Errorf("%s: module.agents[%d].name is required", prefix, i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(agent.Instruction) == "" {
|
||||||
|
return fmt.Errorf("%s: module.agents[%d].instruction is required", prefix, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, workflow := range table.Module.AgentWorkflows {
|
||||||
|
switch workflow.Type {
|
||||||
|
case model.WorkflowTypeLoop, model.WorkflowTypeParallel, model.WorkflowTypeSequential:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s: module.agent-workflows[%d].type is invalid: %s", prefix, i, workflow.Type)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(workflow.Name) == "" {
|
||||||
|
return fmt.Errorf("%s: module.agent-workflows[%d].name is required", prefix, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
267
backend/internal/config/loader_test.go
Normal file
267
backend/internal/config/loader_test.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ai-agent-scaffold-go/internal/model"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 辅助函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func newTestTable() model.AiAgentConfigTable {
|
||||||
|
return model.AiAgentConfigTable{
|
||||||
|
AppName: "test-app",
|
||||||
|
Agent: model.AgentSummary{AgentID: "10001", AgentName: "test", AgentDesc: "test agent"},
|
||||||
|
Module: model.AgentModule{
|
||||||
|
AiAPI: model.AiAPIConfig{BaseURL: "http://localhost:8080", APIKey: "test-key"},
|
||||||
|
ChatModel: model.ChatModelConfig{Model: "gpt-4"},
|
||||||
|
Agents: []model.AgentConfig{{Name: "bot", Instruction: "hello"}},
|
||||||
|
Runner: model.RunnerConfig{AgentName: "bot"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestYAML() []byte {
|
||||||
|
return []byte(`
|
||||||
|
ai:
|
||||||
|
agent:
|
||||||
|
config:
|
||||||
|
tables:
|
||||||
|
test-app:
|
||||||
|
app-name: test-app
|
||||||
|
agent:
|
||||||
|
agent-id: "10001"
|
||||||
|
agent-name: test
|
||||||
|
agent-desc: test agent
|
||||||
|
module:
|
||||||
|
ai-api:
|
||||||
|
base-url: "http://localhost:8080"
|
||||||
|
api-key: "test-key"
|
||||||
|
chat-model:
|
||||||
|
model: "gpt-4"
|
||||||
|
agents:
|
||||||
|
- name: bot
|
||||||
|
instruction: hello
|
||||||
|
runner:
|
||||||
|
agent-name: bot
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// expandEnvPlaceholders 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestExpandEnvPlaceholders_SetVar_ReturnsValue(t *testing.T) {
|
||||||
|
t.Setenv("TEST_URL", "http://example.com")
|
||||||
|
result := expandEnvPlaceholders("url=${TEST_URL}")
|
||||||
|
assert.Equal(t, "url=http://example.com", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandEnvPlaceholders_UnsetVar_NoDefault_ReturnsEmpty(t *testing.T) {
|
||||||
|
os.Unsetenv("TEST_MISSING_VAR")
|
||||||
|
result := expandEnvPlaceholders("url=${TEST_MISSING_VAR}")
|
||||||
|
assert.Equal(t, "url=", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandEnvPlaceholders_UnsetVar_WithDefault_ReturnsDefault(t *testing.T) {
|
||||||
|
os.Unsetenv("TEST_DEFAULT_VAR")
|
||||||
|
result := expandEnvPlaceholders("url=${TEST_DEFAULT_VAR:-http://localhost}")
|
||||||
|
assert.Equal(t, "url=http://localhost", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandEnvPlaceholders_SetVar_IgnoresDefault(t *testing.T) {
|
||||||
|
t.Setenv("TEST_OVERRIDE", "http://real.com")
|
||||||
|
result := expandEnvPlaceholders("url=${TEST_OVERRIDE:-http://default.com}")
|
||||||
|
assert.Equal(t, "url=http://real.com", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandEnvPlaceholders_MultipleVars(t *testing.T) {
|
||||||
|
t.Setenv("VAR_A", "aaa")
|
||||||
|
t.Setenv("VAR_B", "bbb")
|
||||||
|
result := expandEnvPlaceholders("${VAR_A}-${VAR_B}")
|
||||||
|
assert.Equal(t, "aaa-bbb", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// validateTable 测试 — table-driven
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestValidateTable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
modify func(*model.AiAgentConfigTable)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{"missing app-name", func(t *model.AiAgentConfigTable) { t.AppName = "" }, "app-name"},
|
||||||
|
{"missing agent-id", func(t *model.AiAgentConfigTable) { t.Agent.AgentID = "" }, "agent.agent-id"},
|
||||||
|
{"missing base-url", func(t *model.AiAgentConfigTable) { t.Module.AiAPI.BaseURL = "" }, "module.ai-api.base-url"},
|
||||||
|
{"missing api-key", func(t *model.AiAgentConfigTable) { t.Module.AiAPI.APIKey = "" }, "module.ai-api.api-key"},
|
||||||
|
{"missing model", func(t *model.AiAgentConfigTable) { t.Module.ChatModel.Model = "" }, "module.chat-model.model"},
|
||||||
|
{"missing runner agent-name", func(t *model.AiAgentConfigTable) { t.Module.Runner.AgentName = "" }, "module.runner.agent-name"},
|
||||||
|
{"missing agents", func(t *model.AiAgentConfigTable) { t.Module.Agents = nil }, "module.agents"},
|
||||||
|
{"empty agent name", func(t *model.AiAgentConfigTable) { t.Module.Agents[0].Name = "" }, "module.agents[0].name"},
|
||||||
|
{"empty agent instruction", func(t *model.AiAgentConfigTable) { t.Module.Agents[0].Instruction = "" }, "module.agents[0].instruction"},
|
||||||
|
{"invalid workflow type", func(t *model.AiAgentConfigTable) {
|
||||||
|
t.Module.AgentWorkflows = []model.AgentWorkflowConfig{
|
||||||
|
{Type: "bad", Name: "wf"},
|
||||||
|
}
|
||||||
|
}, "module.agent-workflows[0].type is invalid"},
|
||||||
|
{"missing workflow name", func(t *model.AiAgentConfigTable) {
|
||||||
|
t.Module.AgentWorkflows = []model.AgentWorkflowConfig{
|
||||||
|
{Type: model.WorkflowTypeSequential, Name: ""},
|
||||||
|
}
|
||||||
|
}, "module.agent-workflows[0].name is required"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
table := newTestTable()
|
||||||
|
tt.modify(&table)
|
||||||
|
err := validateTable("test", table)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTable_ValidTable_NoError(t *testing.T) {
|
||||||
|
table := newTestTable()
|
||||||
|
err := validateTable("test", table)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// normalizeDefaults 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestNormalizeDefaults_SetsCompletionsPath(t *testing.T) {
|
||||||
|
table := newTestTable()
|
||||||
|
table.Module.AiAPI.CompletionsPath = ""
|
||||||
|
normalizeDefaults(&table)
|
||||||
|
assert.Equal(t, "v1/chat/completions", table.Module.AiAPI.CompletionsPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDefaults_SetsMaxIterations(t *testing.T) {
|
||||||
|
table := newTestTable()
|
||||||
|
table.Module.AgentWorkflows = []model.AgentWorkflowConfig{
|
||||||
|
{Type: model.WorkflowTypeLoop, Name: "loop", MaxIterations: 0},
|
||||||
|
}
|
||||||
|
normalizeDefaults(&table)
|
||||||
|
assert.Equal(t, 3, table.Module.AgentWorkflows[0].MaxIterations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LoadAgentTables 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestLoadAgentTables_ValidYAML_ReturnsTable(t *testing.T) {
|
||||||
|
tables, err := LoadAgentTables(newTestYAML())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, tables, 1)
|
||||||
|
|
||||||
|
table, ok := tables["test-app"]
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "test-app", table.AppName)
|
||||||
|
assert.Equal(t, "10001", table.Agent.AgentID)
|
||||||
|
assert.Equal(t, "gpt-4", table.Module.ChatModel.Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAgentTables_WithEnvVar_Substitutes(t *testing.T) {
|
||||||
|
t.Setenv("TEST_LLM_URL", "http://llm.example.com")
|
||||||
|
yaml := []byte(`
|
||||||
|
ai:
|
||||||
|
agent:
|
||||||
|
config:
|
||||||
|
tables:
|
||||||
|
t:
|
||||||
|
app-name: app
|
||||||
|
agent:
|
||||||
|
agent-id: "1"
|
||||||
|
agent-name: n
|
||||||
|
agent-desc: d
|
||||||
|
module:
|
||||||
|
ai-api:
|
||||||
|
base-url: "${TEST_LLM_URL}"
|
||||||
|
api-key: k
|
||||||
|
chat-model:
|
||||||
|
model: m
|
||||||
|
agents:
|
||||||
|
- name: bot
|
||||||
|
instruction: hi
|
||||||
|
runner:
|
||||||
|
agent-name: bot
|
||||||
|
`)
|
||||||
|
tables, err := LoadAgentTables(yaml)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "http://llm.example.com", tables["t"].Module.AiAPI.BaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAgentTables_EmptyTables_ReturnsError(t *testing.T) {
|
||||||
|
yaml := []byte(`
|
||||||
|
ai:
|
||||||
|
agent:
|
||||||
|
config:
|
||||||
|
tables: {}
|
||||||
|
`)
|
||||||
|
_, err := LoadAgentTables(yaml)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "agent config tables are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAgentTables_InvalidYAML_ReturnsError(t *testing.T) {
|
||||||
|
_, err := LoadAgentTables([]byte("not: [valid: yaml"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAgentTables_MissingRequiredField_ReturnsError(t *testing.T) {
|
||||||
|
yaml := []byte(`
|
||||||
|
ai:
|
||||||
|
agent:
|
||||||
|
config:
|
||||||
|
tables:
|
||||||
|
t:
|
||||||
|
app-name: ""
|
||||||
|
agent:
|
||||||
|
agent-id: "1"
|
||||||
|
module:
|
||||||
|
ai-api:
|
||||||
|
base-url: u
|
||||||
|
api-key: k
|
||||||
|
chat-model:
|
||||||
|
model: m
|
||||||
|
agents:
|
||||||
|
- name: bot
|
||||||
|
instruction: hi
|
||||||
|
runner:
|
||||||
|
agent-name: bot
|
||||||
|
`)
|
||||||
|
_, err := LoadAgentTables(yaml)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "app-name")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LoadAgentTablesFile 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestLoadAgentTablesFile_ValidFile_ReturnsTable(t *testing.T) {
|
||||||
|
tmp, err := os.CreateTemp("", "agent-*.yaml")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer os.Remove(tmp.Name())
|
||||||
|
|
||||||
|
_, err = tmp.Write(newTestYAML())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
tmp.Close()
|
||||||
|
|
||||||
|
tables, err := LoadAgentTablesFile(tmp.Name())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, tables, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAgentTablesFile_FileNotFound_ReturnsError(t *testing.T) {
|
||||||
|
_, err := LoadAgentTablesFile("/nonexistent/path.yaml")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
154
backend/internal/handler/handler.go
Normal file
154
backend/internal/handler/handler.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"ai-agent-scaffold-go/internal/service"
|
||||||
|
"ai-agent-scaffold-go/pkg/types"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Envelope 统一响应格式
|
||||||
|
type Envelope struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Info string `json:"info"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AiAgentConfigResponse Agent 配置查询响应
|
||||||
|
type AiAgentConfigResponse struct {
|
||||||
|
AgentID string `json:"agentId"`
|
||||||
|
AgentName string `json:"agentName"`
|
||||||
|
AgentDesc string `json:"agentDesc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSessionRequest 创建会话请求
|
||||||
|
type CreateSessionRequest struct {
|
||||||
|
AgentID string `json:"agentId"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSessionResponse 创建会话响应
|
||||||
|
type CreateSessionResponse struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatRequest 聊天请求
|
||||||
|
type ChatRequest struct {
|
||||||
|
AgentID string `json:"agentId"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatResponse 聊天响应
|
||||||
|
type ChatResponse struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes 注册 HTTP 路由
|
||||||
|
func RegisterRoutes(router gin.IRouter, chatService *service.ChatService) {
|
||||||
|
group := router.Group("/api/v1")
|
||||||
|
group.GET("/query_ai_agent_config_list", queryAgentConfigList(chatService))
|
||||||
|
group.POST("/create_session", createSession(chatService))
|
||||||
|
group.GET("/create_session", createSessionQuery(chatService))
|
||||||
|
group.POST("/chat", chatMessage(chatService))
|
||||||
|
group.POST("/chat_stream", chatStream(chatService))
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryAgentConfigList(s *service.ChatService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
agents := s.QueryAgentConfigList()
|
||||||
|
responses := make([]AiAgentConfigResponse, 0, len(agents))
|
||||||
|
for _, agent := range agents {
|
||||||
|
responses = append(responses, AiAgentConfigResponse{
|
||||||
|
AgentID: agent.AgentID,
|
||||||
|
AgentName: agent.AgentName,
|
||||||
|
AgentDesc: agent.AgentDesc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, success(responses))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSession(s *service.ChatService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req CreateSessionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, types.NewAppError(types.CodeIllegalParameter, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionID, err := s.CreateSession(req.AgentID, req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, success(CreateSessionResponse{SessionID: sessionID}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSessionQuery(s *service.ChatService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
sessionID, err := s.CreateSession(c.Query("agentId"), c.Query("userId"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, success(CreateSessionResponse{SessionID: sessionID}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatMessage(s *service.ChatService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req ChatRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, types.NewAppError(types.CodeIllegalParameter, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outputs, err := s.HandleMessage(req.AgentID, req.UserID, req.SessionID, req.Message)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, success(ChatResponse{Content: strings.Join(outputs, "\n")}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatStream(s *service.ChatService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req ChatRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, types.NewAppError(types.CodeIllegalParameter, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outputs, errs := s.HandleMessageStream(req.AgentID, req.UserID, req.SessionID, req.Message)
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
|
||||||
|
for output := range outputs {
|
||||||
|
c.SSEvent("message", output)
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
|
if err, ok := <-errs; ok && err != nil {
|
||||||
|
c.SSEvent("error", err.Error())
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func success(data interface{}) Envelope {
|
||||||
|
return Envelope{Code: types.CodeSuccess, Info: types.InfoSuccess, Data: data}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(c *gin.Context, err error) {
|
||||||
|
var appErr *types.AppError
|
||||||
|
if errors.As(err, &appErr) {
|
||||||
|
c.JSON(http.StatusOK, Envelope{Code: appErr.Code, Info: appErr.Info})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, Envelope{Code: types.CodeUnknownError, Info: err.Error()})
|
||||||
|
}
|
||||||
322
backend/internal/handler/handler_test.go
Normal file
322
backend/internal/handler/handler_test.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ai-agent-scaffold-go/internal/model"
|
||||||
|
"ai-agent-scaffold-go/internal/service"
|
||||||
|
"ai-agent-scaffold-go/pkg/types"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Stub Runner for handler tests
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type stubRunner struct {
|
||||||
|
sessionID string
|
||||||
|
runResult []string
|
||||||
|
runErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubRunner) CreateSession(userID string) (string, error) {
|
||||||
|
if r.sessionID != "" {
|
||||||
|
return r.sessionID, nil
|
||||||
|
}
|
||||||
|
return "sess:" + userID + ":1", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubRunner) Run(userID, sessionID string, content model.ChatContent) ([]string, error) {
|
||||||
|
return r.runResult, r.runErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubRunner) Stream(userID, sessionID string, content model.ChatContent) (<-chan string, <-chan error) {
|
||||||
|
outputs := make(chan string, 4)
|
||||||
|
errs := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(outputs)
|
||||||
|
defer close(errs)
|
||||||
|
if r.runErr != nil {
|
||||||
|
errs <- r.runErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, s := range r.runResult {
|
||||||
|
outputs <- s
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return outputs, errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 辅助函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func setupRouter() (*gin.Engine, *model.InMemoryAgentRegistry) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
registry := model.NewInMemoryAgentRegistry()
|
||||||
|
sessions := model.NewInMemorySessionStore()
|
||||||
|
svc := service.NewChatService(registry, sessions)
|
||||||
|
router := gin.New()
|
||||||
|
RegisterRoutes(router, svc)
|
||||||
|
return router, registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func doRequest(router http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
||||||
|
var req *http.Request
|
||||||
|
if body != "" {
|
||||||
|
req = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
} else {
|
||||||
|
req = httptest.NewRequest(method, path, nil)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEnvelope(t *testing.T, w *httptest.ResponseRecorder) Envelope {
|
||||||
|
t.Helper()
|
||||||
|
var resp Envelope
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// healthz 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestHealthz_Returns200(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/healthz", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
w := doRequest(router, "GET", "/healthz", "")
|
||||||
|
assert.Equal(t, 200, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// queryAgentConfigList 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestQueryAgentConfigList_ReturnsList(t *testing.T) {
|
||||||
|
router, registry := setupRouter()
|
||||||
|
registry.Register(model.RegisteredAgent{AgentID: "1", AgentName: "a", AgentDesc: "desc a"})
|
||||||
|
registry.Register(model.RegisteredAgent{AgentID: "2", AgentName: "b", AgentDesc: "desc b"})
|
||||||
|
|
||||||
|
w := doRequest(router, "GET", "/api/v1/query_ai_agent_config_list", "")
|
||||||
|
assert.Equal(t, 200, w.Code)
|
||||||
|
|
||||||
|
resp := parseEnvelope(t, w)
|
||||||
|
assert.Equal(t, types.CodeSuccess, resp.Code)
|
||||||
|
|
||||||
|
data, _ := json.Marshal(resp.Data)
|
||||||
|
var agents []AiAgentConfigResponse
|
||||||
|
json.Unmarshal(data, &agents)
|
||||||
|
assert.Len(t, agents, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryAgentConfigList_Empty_ReturnsEmptyArray(t *testing.T) {
|
||||||
|
router, _ := setupRouter()
|
||||||
|
|
||||||
|
w := doRequest(router, "GET", "/api/v1/query_ai_agent_config_list", "")
|
||||||
|
assert.Equal(t, 200, w.Code)
|
||||||
|
|
||||||
|
resp := parseEnvelope(t, w)
|
||||||
|
assert.Equal(t, types.CodeSuccess, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// createSession 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestCreateSession_Success(t *testing.T) {
|
||||||
|
router, registry := setupRouter()
|
||||||
|
registry.Register(model.RegisteredAgent{
|
||||||
|
AgentID: "1",
|
||||||
|
Runner: &stubRunner{sessionID: "sess:1:1"},
|
||||||
|
})
|
||||||
|
|
||||||
|
body := `{"agentId":"1","userId":"user1"}`
|
||||||
|
w := doRequest(router, "POST", "/api/v1/create_session", body)
|
||||||
|
assert.Equal(t, 200, w.Code)
|
||||||
|
|
||||||
|
resp := parseEnvelope(t, w)
|
||||||
|
assert.Equal(t, types.CodeSuccess, resp.Code)
|
||||||
|
|
||||||
|
data, _ := json.Marshal(resp.Data)
|
||||||
|
var sessResp CreateSessionResponse
|
||||||
|
json.Unmarshal(data, &sessResp)
|
||||||
|
assert.Equal(t, "sess:1:1", sessResp.SessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSession_AgentNotFound_Returns0003(t *testing.T) {
|
||||||
|
router, _ := setupRouter()
|
||||||
|
|
||||||
|
body := `{"agentId":"nonexistent","userId":"user1"}`
|
||||||
|
w := doRequest(router, "POST", "/api/v1/create_session", body)
|
||||||
|
|
||||||
|
resp := parseEnvelope(t, w)
|
||||||
|
assert.Equal(t, types.CodeAgentNotFound, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSession_MissingParams_Returns0002(t *testing.T) {
|
||||||
|
router, _ := setupRouter()
|
||||||
|
|
||||||
|
// 空 body
|
||||||
|
w := doRequest(router, "POST", "/api/v1/create_session", "")
|
||||||
|
resp := parseEnvelope(t, w)
|
||||||
|
assert.Equal(t, types.CodeIllegalParameter, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSession_Query_Success(t *testing.T) {
|
||||||
|
router, registry := setupRouter()
|
||||||
|
registry.Register(model.RegisteredAgent{
|
||||||
|
AgentID: "1",
|
||||||
|
Runner: &stubRunner{sessionID: "sess:u1:1"},
|
||||||
|
})
|
||||||
|
|
||||||
|
w := doRequest(router, "GET", "/api/v1/create_session?agentId=1&userId=user1", "")
|
||||||
|
assert.Equal(t, 200, w.Code)
|
||||||
|
|
||||||
|
resp := parseEnvelope(t, w)
|
||||||
|
assert.Equal(t, types.CodeSuccess, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSession_Query_AgentNotFound_Returns0003(t *testing.T) {
|
||||||
|
router, _ := setupRouter()
|
||||||
|
|
||||||
|
w := doRequest(router, "GET", "/api/v1/create_session?agentId=x&userId=u", "")
|
||||||
|
resp := parseEnvelope(t, w)
|
||||||
|
assert.Equal(t, types.CodeAgentNotFound, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// chat 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestChat_Success(t *testing.T) {
|
||||||
|
router, registry := setupRouter()
|
||||||
|
registry.Register(model.RegisteredAgent{
|
||||||
|
AgentID: "1",
|
||||||
|
Runner: &stubRunner{sessionID: "s1", runResult: []string{"hello"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
body := `{"agentId":"1","userId":"u1","sessionId":"s1","message":"hi"}`
|
||||||
|
w := doRequest(router, "POST", "/api/v1/chat", body)
|
||||||
|
assert.Equal(t, 200, w.Code)
|
||||||
|
|
||||||
|
resp := parseEnvelope(t, w)
|
||||||
|
assert.Equal(t, types.CodeSuccess, resp.Code)
|
||||||
|
|
||||||
|
data, _ := json.Marshal(resp.Data)
|
||||||
|
var chatResp ChatResponse
|
||||||
|
json.Unmarshal(data, &chatResp)
|
||||||
|
assert.Equal(t, "hello", chatResp.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChat_AgentNotFound_Returns0003(t *testing.T) {
|
||||||
|
router, _ := setupRouter()
|
||||||
|
|
||||||
|
body := `{"agentId":"nonexistent","userId":"u1","message":"hi"}`
|
||||||
|
w := doRequest(router, "POST", "/api/v1/chat", body)
|
||||||
|
|
||||||
|
resp := parseEnvelope(t, w)
|
||||||
|
assert.Equal(t, types.CodeAgentNotFound, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChat_MissingBody_Returns0002(t *testing.T) {
|
||||||
|
router, _ := setupRouter()
|
||||||
|
|
||||||
|
w := doRequest(router, "POST", "/api/v1/chat", "")
|
||||||
|
resp := parseEnvelope(t, w)
|
||||||
|
assert.Equal(t, types.CodeIllegalParameter, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChat_RunnerError_ReturnsUnknownError(t *testing.T) {
|
||||||
|
router, registry := setupRouter()
|
||||||
|
registry.Register(model.RegisteredAgent{
|
||||||
|
AgentID: "1",
|
||||||
|
Runner: &stubRunner{sessionID: "s1", runErr: assert.AnError},
|
||||||
|
})
|
||||||
|
|
||||||
|
body := `{"agentId":"1","userId":"u1","sessionId":"s1","message":"hi"}`
|
||||||
|
w := doRequest(router, "POST", "/api/v1/chat", body)
|
||||||
|
|
||||||
|
resp := parseEnvelope(t, w)
|
||||||
|
assert.Equal(t, types.CodeUnknownError, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// chatStream 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestChatStream_SSEHeaders(t *testing.T) {
|
||||||
|
router, registry := setupRouter()
|
||||||
|
registry.Register(model.RegisteredAgent{
|
||||||
|
AgentID: "1",
|
||||||
|
Runner: &stubRunner{sessionID: "s1", runResult: []string{"chunk1", "chunk2"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
body := `{"agentId":"1","userId":"u1","sessionId":"s1","message":"hi"}`
|
||||||
|
w := doRequest(router, "POST", "/api/v1/chat_stream", body)
|
||||||
|
assert.Equal(t, 200, w.Code)
|
||||||
|
assert.Contains(t, w.Header().Get("Content-Type"), "text/event-stream")
|
||||||
|
assert.Equal(t, "no-cache", w.Header().Get("Cache-Control"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatStream_AgentNotFound_ReturnsError(t *testing.T) {
|
||||||
|
router, _ := setupRouter()
|
||||||
|
|
||||||
|
body := `{"agentId":"nonexistent","userId":"u1","message":"hi"}`
|
||||||
|
w := doRequest(router, "POST", "/api/v1/chat_stream", body)
|
||||||
|
|
||||||
|
// SSE 流式中错误通过 event 发送,HTTP 状态码仍为 200
|
||||||
|
assert.Equal(t, 200, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// writeError 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestWriteError_AppError(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
|
||||||
|
writeError(c, types.NewAppError(types.CodeAgentNotFound, "not found"))
|
||||||
|
|
||||||
|
var resp Envelope
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
assert.Equal(t, types.CodeAgentNotFound, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteError_UnknownError(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
|
||||||
|
writeError(c, assert.AnError)
|
||||||
|
|
||||||
|
var resp Envelope
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
assert.Equal(t, types.CodeUnknownError, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// success 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestSuccess_ReturnsEnvelope(t *testing.T) {
|
||||||
|
resp := success(map[string]string{"key": "val"})
|
||||||
|
assert.Equal(t, types.CodeSuccess, resp.Code)
|
||||||
|
assert.Equal(t, types.InfoSuccess, resp.Info)
|
||||||
|
assert.NotNil(t, resp.Data)
|
||||||
|
}
|
||||||
76
backend/internal/llm/chatmodel.go
Normal file
76
backend/internal/llm/chatmodel.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"ai-agent-scaffold-go/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatModelAdapter 适配器,将 OpenAIClient 包装为 model.ChatModel
|
||||||
|
type ChatModelAdapter struct {
|
||||||
|
client *OpenAIClient
|
||||||
|
tools []model.Tool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChatModelAdapter 创建 ChatModel 适配器
|
||||||
|
func NewChatModelAdapter(client *OpenAIClient, tools []model.Tool) *ChatModelAdapter {
|
||||||
|
return &ChatModelAdapter{client: client, tools: tools}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 实现 model.ChatModel 接口
|
||||||
|
func (m *ChatModelAdapter) Generate(ctx context.Context, messages []model.ChatMessage) (model.ChatReply, error) {
|
||||||
|
return m.client.Generate(ctx, messages, m.toolDefs())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream 实现 model.ChatModel 接口
|
||||||
|
func (m *ChatModelAdapter) Stream(ctx context.Context, messages []model.ChatMessage) (<-chan model.ChatStreamEvent, <-chan error) {
|
||||||
|
return m.client.Stream(ctx, messages, m.toolDefs())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools 返回注册的工具列表
|
||||||
|
func (m *ChatModelAdapter) Tools() []model.Tool {
|
||||||
|
return m.tools
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallTool 根据名称和参数调用对应的工具
|
||||||
|
func (m *ChatModelAdapter) CallTool(ctx context.Context, name, arguments string) (string, error) {
|
||||||
|
query := extractQuery(arguments)
|
||||||
|
for _, t := range m.tools {
|
||||||
|
if t.Name() == name {
|
||||||
|
return t.Call(ctx, query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("tool %q not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolDefs 将 model.Tool 转换为 ToolDef 列表
|
||||||
|
func (m *ChatModelAdapter) toolDefs() []ToolDef {
|
||||||
|
defs := make([]ToolDef, 0, len(m.tools))
|
||||||
|
for _, t := range m.tools {
|
||||||
|
defs = append(defs, ToolDef{Name: t.Name(), Description: t.Description()})
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractQuery 从工具调用参数 JSON 中提取 query 字段
|
||||||
|
func extractQuery(arguments string) string {
|
||||||
|
arguments = strings.TrimSpace(arguments)
|
||||||
|
if arguments == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if idx := strings.Index(arguments, `"query"`); idx >= 0 {
|
||||||
|
rest := arguments[idx+7:]
|
||||||
|
if colon := strings.Index(rest, `:`); colon >= 0 {
|
||||||
|
rest = strings.TrimSpace(rest[colon+1:])
|
||||||
|
if strings.HasPrefix(rest, `"`) {
|
||||||
|
rest = rest[1:]
|
||||||
|
if end := strings.Index(rest, `"`); end >= 0 {
|
||||||
|
return rest[:end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return arguments
|
||||||
|
}
|
||||||
328
backend/internal/llm/client.go
Normal file
328
backend/internal/llm/client.go
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ai-agent-scaffold-go/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenAIClient OpenAI 兼容 API 客户端
|
||||||
|
type OpenAIClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
completionsURL string
|
||||||
|
apiKey string
|
||||||
|
model string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolDef 工具定义,用于传给 LLM 的 tools 参数
|
||||||
|
type ToolDef struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOpenAIClient 创建 OpenAI 客户端
|
||||||
|
func NewOpenAIClient(completionsURL, apiKey, model string, requestTimeout time.Duration) *OpenAIClient {
|
||||||
|
if requestTimeout <= 0 {
|
||||||
|
requestTimeout = 5 * time.Minute
|
||||||
|
}
|
||||||
|
return &OpenAIClient{
|
||||||
|
httpClient: &http.Client{Timeout: requestTimeout},
|
||||||
|
completionsURL: completionsURL,
|
||||||
|
apiKey: apiKey,
|
||||||
|
model: model,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 同步调用 LLM,返回完整回复
|
||||||
|
func (c *OpenAIClient) Generate(ctx context.Context, messages []model.ChatMessage, tools []ToolDef) (model.ChatReply, error) {
|
||||||
|
body, err := buildRequestBody(c.model, messages, tools, false)
|
||||||
|
if err != nil {
|
||||||
|
return model.ChatReply{}, err
|
||||||
|
}
|
||||||
|
resp, err := c.do(ctx, body)
|
||||||
|
if err != nil {
|
||||||
|
return model.ChatReply{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return model.ChatReply{}, fmt.Errorf("openai read body: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return model.ChatReply{}, fmt.Errorf("openai upstream %d: %s", resp.StatusCode, truncate(string(raw), 400))
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed openaiCompletion
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
return model.ChatReply{}, fmt.Errorf("openai decode: %w", err)
|
||||||
|
}
|
||||||
|
if len(parsed.Choices) == 0 {
|
||||||
|
return model.ChatReply{}, fmt.Errorf("openai response has no choices")
|
||||||
|
}
|
||||||
|
choice := parsed.Choices[0].Message
|
||||||
|
reply := model.ChatReply{Content: choice.Content}
|
||||||
|
for _, tc := range choice.ToolCalls {
|
||||||
|
reply.ToolCalls = append(reply.ToolCalls, model.ChatToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Arguments: tc.Function.Arguments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream 流式调用 LLM,返回事件通道和错误通道
|
||||||
|
func (c *OpenAIClient) Stream(ctx context.Context, messages []model.ChatMessage, tools []ToolDef) (<-chan model.ChatStreamEvent, <-chan error) {
|
||||||
|
events := make(chan model.ChatStreamEvent, 8)
|
||||||
|
errs := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(events)
|
||||||
|
defer close(errs)
|
||||||
|
|
||||||
|
body, err := buildRequestBody(c.model, messages, tools, true)
|
||||||
|
if err != nil {
|
||||||
|
errs <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := c.do(ctx, body)
|
||||||
|
if err != nil {
|
||||||
|
errs <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
errs <- fmt.Errorf("openai upstream %d: %s", resp.StatusCode, truncate(string(raw), 400))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCallBuf := map[int]*model.ChatToolCall{}
|
||||||
|
reader := bufio.NewReader(resp.Body)
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
emitToolCalls(events, toolCallBuf)
|
||||||
|
events <- model.ChatStreamEvent{Done: true}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errs <- fmt.Errorf("openai stream read: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
if line == "" || !strings.HasPrefix(line, "data:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||||
|
if payload == "[DONE]" {
|
||||||
|
emitToolCalls(events, toolCallBuf)
|
||||||
|
events <- model.ChatStreamEvent{Done: true}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunk openaiStreamChunk
|
||||||
|
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
|
||||||
|
errs <- fmt.Errorf("openai stream decode: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(chunk.Choices) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delta := chunk.Choices[0].Delta
|
||||||
|
|
||||||
|
if delta.Content != "" {
|
||||||
|
select {
|
||||||
|
case events <- model.ChatStreamEvent{Delta: delta.Content}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
errs <- ctx.Err()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range delta.ToolCalls {
|
||||||
|
current, ok := toolCallBuf[tc.Index]
|
||||||
|
if !ok {
|
||||||
|
current = &model.ChatToolCall{}
|
||||||
|
toolCallBuf[tc.Index] = current
|
||||||
|
}
|
||||||
|
if tc.ID != "" {
|
||||||
|
current.ID = tc.ID
|
||||||
|
}
|
||||||
|
if tc.Function.Name != "" {
|
||||||
|
current.Name = tc.Function.Name
|
||||||
|
}
|
||||||
|
if tc.Function.Arguments != "" {
|
||||||
|
current.Arguments += tc.Function.Arguments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return events, errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitToolCalls 将缓冲区中的工具调用合并发送
|
||||||
|
func emitToolCalls(events chan<- model.ChatStreamEvent, buf map[int]*model.ChatToolCall) {
|
||||||
|
if len(buf) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
calls := make([]model.ChatToolCall, 0, len(buf))
|
||||||
|
for i := 0; i < len(buf); i++ {
|
||||||
|
if call, ok := buf[i]; ok && call != nil {
|
||||||
|
calls = append(calls, *call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(calls) > 0 {
|
||||||
|
events <- model.ChatStreamEvent{ToolCalls: calls}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// do 发送 HTTP 请求
|
||||||
|
func (c *OpenAIClient) do(ctx context.Context, body []byte) (*http.Response, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.completionsURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("openai build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if c.apiKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
|
}
|
||||||
|
return c.httpClient.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody 构建请求体 JSON
|
||||||
|
func buildRequestBody(modelName string, messages []model.ChatMessage, tools []ToolDef, stream bool) ([]byte, error) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"model": modelName,
|
||||||
|
"messages": encodeMessages(messages),
|
||||||
|
"stream": stream,
|
||||||
|
}
|
||||||
|
if len(tools) > 0 {
|
||||||
|
payload["tools"] = encodeTools(tools)
|
||||||
|
}
|
||||||
|
return json.Marshal(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeMessages 将 ChatMessage 转换为 OpenAI API 格式
|
||||||
|
func encodeMessages(messages []model.ChatMessage) []map[string]any {
|
||||||
|
encoded := make([]map[string]any, 0, len(messages))
|
||||||
|
for _, m := range messages {
|
||||||
|
entry := map[string]any{"role": string(m.Role)}
|
||||||
|
if m.Content != "" {
|
||||||
|
entry["content"] = m.Content
|
||||||
|
} else if m.Role != model.ChatRoleAssistant || len(m.ToolCalls) == 0 {
|
||||||
|
entry["content"] = ""
|
||||||
|
}
|
||||||
|
if m.Name != "" {
|
||||||
|
entry["name"] = m.Name
|
||||||
|
}
|
||||||
|
if m.ToolCallID != "" {
|
||||||
|
entry["tool_call_id"] = m.ToolCallID
|
||||||
|
}
|
||||||
|
if len(m.ToolCalls) > 0 {
|
||||||
|
calls := make([]map[string]any, 0, len(m.ToolCalls))
|
||||||
|
for _, tc := range m.ToolCalls {
|
||||||
|
calls = append(calls, map[string]any{
|
||||||
|
"id": tc.ID,
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]any{
|
||||||
|
"name": tc.Name,
|
||||||
|
"arguments": tc.Arguments,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
entry["tool_calls"] = calls
|
||||||
|
}
|
||||||
|
encoded = append(encoded, entry)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeTools 将工具定义转换为 OpenAI API 格式
|
||||||
|
func encodeTools(tools []ToolDef) []map[string]any {
|
||||||
|
out := make([]map[string]any, 0, len(tools))
|
||||||
|
for _, t := range tools {
|
||||||
|
desc := t.Description
|
||||||
|
if desc == "" {
|
||||||
|
desc = "external tool " + t.Name
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]any{
|
||||||
|
"name": t.Name,
|
||||||
|
"description": desc,
|
||||||
|
"parameters": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"query": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "text input for the tool",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate 截断字符串
|
||||||
|
func truncate(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// OpenAI API 响应结构体
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type openaiCompletion struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []openaiToolCallV1 `json:"tool_calls"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiStreamChunk struct {
|
||||||
|
Choices []struct {
|
||||||
|
Delta struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []openaiStreamToolCall `json:"tool_calls"`
|
||||||
|
} `json:"delta"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiToolCallV1 struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
} `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiStreamToolCall struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
} `json:"function"`
|
||||||
|
}
|
||||||
279
backend/internal/llm/client_test.go
Normal file
279
backend/internal/llm/client_test.go
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ai-agent-scaffold-go/internal/model"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 辅助函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func newTestClient(url string) *OpenAIClient {
|
||||||
|
return NewOpenAIClient(url, "test-key", "gpt-4", 5*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Generate 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestOpenAIClient_Generate_ReturnsContent(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "POST", r.Method)
|
||||||
|
assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization"))
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"choices": []map[string]any{
|
||||||
|
{"message": map[string]any{"content": "hello world"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newTestClient(srv.URL)
|
||||||
|
reply, err := client.Generate(context.Background(), []model.ChatMessage{
|
||||||
|
{Role: model.ChatRoleUser, Content: "hi"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "hello world", reply.Content)
|
||||||
|
assert.Empty(t, reply.ToolCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIClient_Generate_ToolCalls(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"choices": []map[string]any{
|
||||||
|
{"message": map[string]any{
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": []map[string]any{
|
||||||
|
{"id": "call_1", "type": "function", "function": map[string]any{
|
||||||
|
"name": "search",
|
||||||
|
"arguments": `{"query":"test"}`,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newTestClient(srv.URL)
|
||||||
|
reply, err := client.Generate(context.Background(), []model.ChatMessage{
|
||||||
|
{Role: model.ChatRoleUser, Content: "search for test"},
|
||||||
|
}, []ToolDef{{Name: "search", Description: "search tool"}})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, reply.ToolCalls, 1)
|
||||||
|
assert.Equal(t, "call_1", reply.ToolCalls[0].ID)
|
||||||
|
assert.Equal(t, "search", reply.ToolCalls[0].Name)
|
||||||
|
assert.Equal(t, `{"query":"test"}`, reply.ToolCalls[0].Arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIClient_Generate_Upstream500_ReturnsError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprint(w, "internal error")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newTestClient(srv.URL)
|
||||||
|
_, err := client.Generate(context.Background(), []model.ChatMessage{
|
||||||
|
{Role: model.ChatRoleUser, Content: "hi"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "500")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIClient_Generate_EmptyChoices_ReturnsError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"choices": []any{}})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newTestClient(srv.URL)
|
||||||
|
_, err := client.Generate(context.Background(), []model.ChatMessage{
|
||||||
|
{Role: model.ChatRoleUser, Content: "hi"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "no choices")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Stream 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestOpenAIClient_Stream_ReturnsChunks(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "POST", r.Method)
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n")
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n")
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newTestClient(srv.URL)
|
||||||
|
events, errs := client.Stream(context.Background(), []model.ChatMessage{
|
||||||
|
{Role: model.ChatRoleUser, Content: "hi"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
var texts []string
|
||||||
|
for ev := range events {
|
||||||
|
if ev.Delta != "" {
|
||||||
|
texts = append(texts, ev.Delta)
|
||||||
|
}
|
||||||
|
if ev.Done {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 检查错误通道
|
||||||
|
for err := range errs {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"hel", "lo"}, texts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIClient_Stream_ToolCallDeltas_MergesCorrectly(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
// 工具调用分多个 chunk 发送
|
||||||
|
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"search\"}}]}}]}\n\n")
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"q\"}}]}}]}\n\n")
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"uery\\\":\\\"test\\\"}\"}}]}}]}\n\n")
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newTestClient(srv.URL)
|
||||||
|
events, errs := client.Stream(context.Background(), []model.ChatMessage{
|
||||||
|
{Role: model.ChatRoleUser, Content: "hi"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
var toolCalls []model.ChatToolCall
|
||||||
|
for ev := range events {
|
||||||
|
if len(ev.ToolCalls) > 0 {
|
||||||
|
toolCalls = append(toolCalls, ev.ToolCalls...)
|
||||||
|
}
|
||||||
|
if ev.Done {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for range errs {
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, toolCalls, 1)
|
||||||
|
assert.Equal(t, "call_1", toolCalls[0].ID)
|
||||||
|
assert.Equal(t, "search", toolCalls[0].Name)
|
||||||
|
assert.Equal(t, `{"query":"test"}`, toolCalls[0].Arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIClient_Stream_Upstream500_ReturnsError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprint(w, "error")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := newTestClient(srv.URL)
|
||||||
|
events, errs := client.Stream(context.Background(), []model.ChatMessage{
|
||||||
|
{Role: model.ChatRoleUser, Content: "hi"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
// 消费通道直到关闭
|
||||||
|
var streamErr error
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for range events {
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
for err := range errs {
|
||||||
|
streamErr = err
|
||||||
|
}
|
||||||
|
<-done
|
||||||
|
|
||||||
|
assert.Error(t, streamErr)
|
||||||
|
assert.Contains(t, streamErr.Error(), "500")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// encodeMessages / encodeTools 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestEncodeMessages_WithToolCalls(t *testing.T) {
|
||||||
|
msgs := []model.ChatMessage{
|
||||||
|
{Role: model.ChatRoleAssistant, ToolCalls: []model.ChatToolCall{
|
||||||
|
{ID: "c1", Name: "search", Arguments: `{"q":"x"}`},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
encoded := encodeMessages(msgs)
|
||||||
|
assert.Len(t, encoded, 1)
|
||||||
|
assert.Equal(t, "assistant", encoded[0]["role"])
|
||||||
|
calls := encoded[0]["tool_calls"].([]map[string]any)
|
||||||
|
assert.Len(t, calls, 1)
|
||||||
|
assert.Equal(t, "c1", calls[0]["id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeTools_CorrectFormat(t *testing.T) {
|
||||||
|
tools := []ToolDef{{Name: "search", Description: "search tool"}}
|
||||||
|
encoded := encodeTools(tools)
|
||||||
|
assert.Len(t, encoded, 1)
|
||||||
|
assert.Equal(t, "function", encoded[0]["type"])
|
||||||
|
fn := encoded[0]["function"].(map[string]any)
|
||||||
|
assert.Equal(t, "search", fn["name"])
|
||||||
|
assert.Equal(t, "search tool", fn["description"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeTools_EmptyDescription_UsesFallback(t *testing.T) {
|
||||||
|
tools := []ToolDef{{Name: "mytool"}}
|
||||||
|
encoded := encodeTools(tools)
|
||||||
|
fn := encoded[0]["function"].(map[string]any)
|
||||||
|
assert.Contains(t, fn["description"], "mytool")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// truncate 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestTruncate_ShortString_Unchanged(t *testing.T) {
|
||||||
|
assert.Equal(t, "abc", truncate("abc", 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncate_LongString_Truncated(t *testing.T) {
|
||||||
|
result := truncate("abcdefghijk", 5)
|
||||||
|
assert.Equal(t, "abcde...", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// buildRequestBody 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestBuildRequestBody_Stream(t *testing.T) {
|
||||||
|
body, err := buildRequestBody("gpt-4", []model.ChatMessage{
|
||||||
|
{Role: model.ChatRoleUser, Content: "hi"},
|
||||||
|
}, nil, true)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, string(body), `"stream":true`)
|
||||||
|
assert.Contains(t, string(body), `"model":"gpt-4"`)
|
||||||
|
}
|
||||||
50
backend/internal/model/chat.go
Normal file
50
backend/internal/model/chat.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// ChatRole 消息角色
|
||||||
|
type ChatRole string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ChatRoleSystem ChatRole = "system"
|
||||||
|
ChatRoleUser ChatRole = "user"
|
||||||
|
ChatRoleAssistant ChatRole = "assistant"
|
||||||
|
ChatRoleTool ChatRole = "tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatMessage 聊天消息
|
||||||
|
type ChatMessage struct {
|
||||||
|
Role ChatRole
|
||||||
|
Content string
|
||||||
|
ToolCallID string
|
||||||
|
Name string
|
||||||
|
ToolCalls []ChatToolCall
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatToolCall 工具调用请求
|
||||||
|
type ChatToolCall struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Arguments string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatReply 聊天回复
|
||||||
|
type ChatReply struct {
|
||||||
|
Content string
|
||||||
|
ToolCalls []ChatToolCall
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatStreamEvent 流式事件
|
||||||
|
type ChatStreamEvent struct {
|
||||||
|
Delta string
|
||||||
|
ToolCalls []ChatToolCall
|
||||||
|
Done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatContent 聊天输入内容
|
||||||
|
type ChatContent struct {
|
||||||
|
Texts []TextPart
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextPart 文本片段
|
||||||
|
type TextPart struct {
|
||||||
|
Message string
|
||||||
|
}
|
||||||
67
backend/internal/model/config.go
Normal file
67
backend/internal/model/config.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// WorkflowType 工作流类型
|
||||||
|
type WorkflowType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
WorkflowTypeLoop WorkflowType = "loop"
|
||||||
|
WorkflowTypeParallel WorkflowType = "parallel"
|
||||||
|
WorkflowTypeSequential WorkflowType = "sequential"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AiAgentConfigTable 一个 Agent 配置表的顶层结构,对应 YAML 中 tables 下的每一项
|
||||||
|
type AiAgentConfigTable struct {
|
||||||
|
AppName string `yaml:"app-name" json:"appName"`
|
||||||
|
Agent AgentSummary `yaml:"agent" json:"agent"`
|
||||||
|
Module AgentModule `yaml:"module" json:"module"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentSummary Agent 摘要信息
|
||||||
|
type AgentSummary struct {
|
||||||
|
AgentID string `yaml:"agent-id" json:"agentId"`
|
||||||
|
AgentName string `yaml:"agent-name" json:"agentName"`
|
||||||
|
AgentDesc string `yaml:"agent-desc" json:"agentDesc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentModule Agent 模块配置,包含 API、模型、Agent 定义、工作流和 Runner
|
||||||
|
type AgentModule struct {
|
||||||
|
AiAPI AiAPIConfig `yaml:"ai-api" json:"aiApi"`
|
||||||
|
ChatModel ChatModelConfig `yaml:"chat-model" json:"chatModel"`
|
||||||
|
Agents []AgentConfig `yaml:"agents" json:"agents"`
|
||||||
|
AgentWorkflows []AgentWorkflowConfig `yaml:"agent-workflows" json:"agentWorkflows"`
|
||||||
|
Runner RunnerConfig `yaml:"runner" json:"runner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AiAPIConfig LLM API 连接配置
|
||||||
|
type AiAPIConfig struct {
|
||||||
|
BaseURL string `yaml:"base-url" json:"baseUrl"`
|
||||||
|
APIKey string `yaml:"api-key" json:"apiKey"`
|
||||||
|
CompletionsPath string `yaml:"completions-path" json:"completionsPath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatModelConfig 聊天模型配置
|
||||||
|
type ChatModelConfig struct {
|
||||||
|
Model string `yaml:"model" json:"model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentConfig 单个 Agent 的定义
|
||||||
|
type AgentConfig struct {
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
Instruction string `yaml:"instruction" json:"instruction"`
|
||||||
|
Description string `yaml:"description" json:"description"`
|
||||||
|
OutputKey string `yaml:"output-key" json:"outputKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentWorkflowConfig 工作流配置,支持 loop/parallel/sequential 三种类型
|
||||||
|
type AgentWorkflowConfig struct {
|
||||||
|
Type WorkflowType `yaml:"type" json:"type"`
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
SubAgents []string `yaml:"sub-agents" json:"subAgents"`
|
||||||
|
Description string `yaml:"description" json:"description"`
|
||||||
|
MaxIterations int `yaml:"max-iterations" json:"maxIterations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunnerConfig Runner 配置,指定入口 Agent 名称
|
||||||
|
type RunnerConfig struct {
|
||||||
|
AgentName string `yaml:"agent-name" json:"agentName"`
|
||||||
|
}
|
||||||
61
backend/internal/model/store.go
Normal file
61
backend/internal/model/store.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// InMemoryAgentRegistry 基于内存的 Agent 注册表
|
||||||
|
type InMemoryAgentRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
agents map[string]RegisteredAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInMemoryAgentRegistry() *InMemoryAgentRegistry {
|
||||||
|
return &InMemoryAgentRegistry{agents: make(map[string]RegisteredAgent)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InMemoryAgentRegistry) Register(agent RegisteredAgent) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.agents[agent.AgentID] = agent
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InMemoryAgentRegistry) Get(agentID string) (RegisteredAgent, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
agent, ok := r.agents[agentID]
|
||||||
|
return agent, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InMemoryAgentRegistry) List() []RegisteredAgent {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
agents := make([]RegisteredAgent, 0, len(r.agents))
|
||||||
|
for _, agent := range r.agents {
|
||||||
|
agents = append(agents, agent)
|
||||||
|
}
|
||||||
|
return agents
|
||||||
|
}
|
||||||
|
|
||||||
|
// InMemorySessionStore 基于内存的会话存储
|
||||||
|
type InMemorySessionStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInMemorySessionStore() *InMemorySessionStore {
|
||||||
|
return &InMemorySessionStore{sessions: make(map[string]string)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InMemorySessionStore) Get(userID, agentID string) (string, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
sessionID, ok := s.sessions[userID+":"+agentID]
|
||||||
|
return sessionID, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InMemorySessionStore) Set(userID, agentID, sessionID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sessions[userID+":"+agentID] = sessionID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
54
backend/internal/model/types.go
Normal file
54
backend/internal/model/types.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// 核心接口
|
||||||
|
// Tool 外部工具接口
|
||||||
|
type Tool interface {
|
||||||
|
Name() string
|
||||||
|
Description() string
|
||||||
|
Call(ctx context.Context, input string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatModel 聊天模型接口,支持同步生成和流式输出
|
||||||
|
type ChatModel interface {
|
||||||
|
Generate(ctx context.Context, messages []ChatMessage) (ChatReply, error)
|
||||||
|
Stream(ctx context.Context, messages []ChatMessage) (<-chan ChatStreamEvent, <-chan error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent 智能体接口
|
||||||
|
type Agent interface {
|
||||||
|
Name() string
|
||||||
|
Run(ctx context.Context, content ChatContent) (string, error)
|
||||||
|
Stream(ctx context.Context, content ChatContent, out chan<- string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner 运行器接口,管理会话并执行 Agent
|
||||||
|
type Runner interface {
|
||||||
|
CreateSession(userID string) (string, error)
|
||||||
|
Run(userID, sessionID string, content ChatContent) ([]string, error)
|
||||||
|
Stream(userID, sessionID string, content ChatContent) (<-chan string, <-chan error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册与存储接口
|
||||||
|
// RegisteredAgent 已注册的 Agent 信息
|
||||||
|
type RegisteredAgent struct {
|
||||||
|
AppName string
|
||||||
|
AgentID string
|
||||||
|
AgentName string
|
||||||
|
AgentDesc string
|
||||||
|
Runner Runner
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentRegistry Agent 注册表接口
|
||||||
|
type AgentRegistry interface {
|
||||||
|
Register(agent RegisteredAgent) error
|
||||||
|
Get(agentID string) (RegisteredAgent, bool)
|
||||||
|
List() []RegisteredAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionStore 会话存储接口
|
||||||
|
type SessionStore interface {
|
||||||
|
Get(userID, agentID string) (string, bool)
|
||||||
|
Set(userID, agentID, sessionID string) error
|
||||||
|
}
|
||||||
462
backend/internal/service/agent.go
Normal file
462
backend/internal/service/agent.go
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"ai-agent-scaffold-go/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxToolCallIterations = 4
|
||||||
|
|
||||||
|
// ChatModelWithTools 扩展接口,同时具备 ChatModel 和工具调用能力
|
||||||
|
type ChatModelWithTools interface {
|
||||||
|
model.ChatModel
|
||||||
|
CallTool(ctx context.Context, name, arguments string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LLMAgent — 基础 LLM Agent
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// LLMAgent 基于 LLM 的智能体,支持多轮工具调用
|
||||||
|
type LLMAgent struct {
|
||||||
|
name string
|
||||||
|
description string
|
||||||
|
instruction string
|
||||||
|
outputKey string
|
||||||
|
chatModel ChatModelWithTools
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLLMAgent 创建 LLM Agent
|
||||||
|
func NewLLMAgent(name, instruction, description, outputKey string, chatModel ChatModelWithTools) *LLMAgent {
|
||||||
|
return &LLMAgent{
|
||||||
|
name: name,
|
||||||
|
instruction: instruction,
|
||||||
|
description: description,
|
||||||
|
outputKey: outputKey,
|
||||||
|
chatModel: chatModel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *LLMAgent) Name() string { return a.name }
|
||||||
|
func (a *LLMAgent) OutputKey() string { return a.outputKey }
|
||||||
|
|
||||||
|
// Run 同步执行
|
||||||
|
func (a *LLMAgent) Run(ctx context.Context, content model.ChatContent) (string, error) {
|
||||||
|
return a.runWithVars(ctx, content, map[string]string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream 流式执行
|
||||||
|
func (a *LLMAgent) Stream(ctx context.Context, content model.ChatContent, out chan<- string) error {
|
||||||
|
return a.streamWithVars(ctx, content, out, map[string]string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runWithVars 同步执行,支持变量替换
|
||||||
|
func (a *LLMAgent) runWithVars(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error) {
|
||||||
|
messages := initialMessages(applyVars(a.instruction, vars), firstText(content))
|
||||||
|
|
||||||
|
for iter := 0; iter < maxToolCallIterations; iter++ {
|
||||||
|
reply, err := a.chatModel.Generate(ctx, messages)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(reply.ToolCalls) == 0 {
|
||||||
|
return reply.Content, nil
|
||||||
|
}
|
||||||
|
// 将 assistant 回复(含工具调用)加入消息历史
|
||||||
|
messages = append(messages, model.ChatMessage{
|
||||||
|
Role: model.ChatRoleAssistant,
|
||||||
|
Content: reply.Content,
|
||||||
|
ToolCalls: reply.ToolCalls,
|
||||||
|
})
|
||||||
|
// 执行工具调用,将结果加入消息历史
|
||||||
|
toolMessages, err := a.executeToolCalls(ctx, reply.ToolCalls)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
messages = append(messages, toolMessages...)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("agent %q exceeded tool-call iteration limit %d", a.name, maxToolCallIterations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamWithVars 流式执行,支持变量替换
|
||||||
|
func (a *LLMAgent) streamWithVars(ctx context.Context, content model.ChatContent, out chan<- string, vars map[string]string) error {
|
||||||
|
messages := initialMessages(applyVars(a.instruction, vars), firstText(content))
|
||||||
|
|
||||||
|
for iter := 0; iter < maxToolCallIterations; iter++ {
|
||||||
|
events, errs := a.chatModel.Stream(ctx, messages)
|
||||||
|
|
||||||
|
var (
|
||||||
|
finalText strings.Builder
|
||||||
|
toolCalls []model.ChatToolCall
|
||||||
|
done bool
|
||||||
|
)
|
||||||
|
streamErr := error(nil)
|
||||||
|
|
||||||
|
streamLoop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
streamErr = ctx.Err()
|
||||||
|
break streamLoop
|
||||||
|
case ev, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
break streamLoop
|
||||||
|
}
|
||||||
|
if ev.Done {
|
||||||
|
done = true
|
||||||
|
}
|
||||||
|
if ev.Delta != "" {
|
||||||
|
finalText.WriteString(ev.Delta)
|
||||||
|
select {
|
||||||
|
case out <- ev.Delta:
|
||||||
|
case <-ctx.Done():
|
||||||
|
streamErr = ctx.Err()
|
||||||
|
break streamLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ev.ToolCalls) > 0 {
|
||||||
|
toolCalls = append(toolCalls, ev.ToolCalls...)
|
||||||
|
}
|
||||||
|
case err, ok := <-errs:
|
||||||
|
if ok && err != nil {
|
||||||
|
streamErr = err
|
||||||
|
}
|
||||||
|
break streamLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if streamErr != nil {
|
||||||
|
return streamErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有工具调用,说明模型已完成回复
|
||||||
|
if len(toolCalls) == 0 {
|
||||||
|
if !done {
|
||||||
|
return fmt.Errorf("agent %q stream closed without completion", a.name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理工具调用
|
||||||
|
messages = append(messages, model.ChatMessage{
|
||||||
|
Role: model.ChatRoleAssistant,
|
||||||
|
Content: finalText.String(),
|
||||||
|
ToolCalls: toolCalls,
|
||||||
|
})
|
||||||
|
toolMessages, err := a.executeToolCalls(ctx, toolCalls)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
messages = append(messages, toolMessages...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("agent %q exceeded tool-call iteration limit %d", a.name, maxToolCallIterations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeToolCalls 执行一组工具调用
|
||||||
|
func (a *LLMAgent) executeToolCalls(ctx context.Context, calls []model.ChatToolCall) ([]model.ChatMessage, error) {
|
||||||
|
out := make([]model.ChatMessage, 0, len(calls))
|
||||||
|
for _, call := range calls {
|
||||||
|
result, err := a.chatModel.CallTool(ctx, call.Name, call.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tool %q: %w", call.Name, err)
|
||||||
|
}
|
||||||
|
out = append(out, model.ChatMessage{
|
||||||
|
Role: model.ChatRoleTool,
|
||||||
|
Content: result,
|
||||||
|
ToolCallID: call.ID,
|
||||||
|
Name: call.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 工作流子 Agent 接口
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// workflowSubAgent 工作流内部使用的 Agent 扩展接口
|
||||||
|
type workflowSubAgent interface {
|
||||||
|
model.Agent
|
||||||
|
OutputKey() string
|
||||||
|
runWithVars(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error)
|
||||||
|
streamWithVars(ctx context.Context, content model.ChatContent, out chan<- string, vars map[string]string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SequentialAgent — 顺序工作流
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// SequentialAgent 顺序执行子 Agent,前一个的输出可通过 OutputKey 传递给后一个
|
||||||
|
type SequentialAgent struct {
|
||||||
|
name string
|
||||||
|
description string
|
||||||
|
subAgents []workflowSubAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSequentialAgent 创建顺序工作流 Agent
|
||||||
|
func NewSequentialAgent(name, description string, subs []model.Agent) *SequentialAgent {
|
||||||
|
wrapped := make([]workflowSubAgent, 0, len(subs))
|
||||||
|
for _, s := range subs {
|
||||||
|
wrapped = append(wrapped, s.(workflowSubAgent))
|
||||||
|
}
|
||||||
|
return &SequentialAgent{name: name, description: description, subAgents: wrapped}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *SequentialAgent) Name() string { return a.name }
|
||||||
|
func (a *SequentialAgent) OutputKey() string { return "" }
|
||||||
|
|
||||||
|
func (a *SequentialAgent) Run(ctx context.Context, content model.ChatContent) (string, error) {
|
||||||
|
return a.runWithVars(ctx, content, map[string]string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *SequentialAgent) Stream(ctx context.Context, content model.ChatContent, out chan<- string) error {
|
||||||
|
return a.streamWithVars(ctx, content, out, map[string]string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *SequentialAgent) runWithVars(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error) {
|
||||||
|
scope := cloneVars(vars)
|
||||||
|
var last string
|
||||||
|
for _, sub := range a.subAgents {
|
||||||
|
text, err := sub.runWithVars(ctx, content, scope)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
last = text
|
||||||
|
if key := sub.OutputKey(); key != "" {
|
||||||
|
scope[key] = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *SequentialAgent) streamWithVars(ctx context.Context, content model.ChatContent, out chan<- string, vars map[string]string) error {
|
||||||
|
scope := cloneVars(vars)
|
||||||
|
for i, sub := range a.subAgents {
|
||||||
|
if i == len(a.subAgents)-1 {
|
||||||
|
return sub.streamWithVars(ctx, content, out, scope)
|
||||||
|
}
|
||||||
|
text, err := sub.runWithVars(ctx, content, scope)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if key := sub.OutputKey(); key != "" {
|
||||||
|
scope[key] = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ParallelAgent — 并行工作流
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// ParallelAgent 并行执行所有子 Agent 并汇总结果
|
||||||
|
type ParallelAgent struct {
|
||||||
|
name string
|
||||||
|
description string
|
||||||
|
subAgents []workflowSubAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewParallelAgent 创建并行工作流 Agent
|
||||||
|
func NewParallelAgent(name, description string, subs []model.Agent) *ParallelAgent {
|
||||||
|
wrapped := make([]workflowSubAgent, 0, len(subs))
|
||||||
|
for _, s := range subs {
|
||||||
|
wrapped = append(wrapped, s.(workflowSubAgent))
|
||||||
|
}
|
||||||
|
return &ParallelAgent{name: name, description: description, subAgents: wrapped}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ParallelAgent) Name() string { return a.name }
|
||||||
|
func (a *ParallelAgent) OutputKey() string { return "" }
|
||||||
|
|
||||||
|
func (a *ParallelAgent) Run(ctx context.Context, content model.ChatContent) (string, error) {
|
||||||
|
return a.runWithVars(ctx, content, map[string]string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ParallelAgent) Stream(ctx context.Context, content model.ChatContent, out chan<- string) error {
|
||||||
|
return a.streamWithVars(ctx, content, out, map[string]string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ParallelAgent) runWithVars(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error) {
|
||||||
|
type result struct {
|
||||||
|
index int
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]result, len(a.subAgents))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i, sub := range a.subAgents {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, s workflowSubAgent) {
|
||||||
|
defer wg.Done()
|
||||||
|
text, err := s.runWithVars(ctx, content, vars)
|
||||||
|
results[idx] = result{index: idx, name: s.Name(), text: text, err: err}
|
||||||
|
}(i, sub)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// 按原始顺序组装结果,遇到错误立即返回
|
||||||
|
parts := make([]string, 0, len(a.subAgents))
|
||||||
|
for _, r := range results {
|
||||||
|
if r.err != nil {
|
||||||
|
return "", r.err
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("[%s] %s", r.name, r.text))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ParallelAgent) streamWithVars(ctx context.Context, content model.ChatContent, out chan<- string, vars map[string]string) error {
|
||||||
|
errCh := make(chan error, len(a.subAgents))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, sub := range a.subAgents {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(s workflowSubAgent) {
|
||||||
|
defer wg.Done()
|
||||||
|
// 流式输出前缀标识
|
||||||
|
prefix := fmt.Sprintf("[%s] ", s.Name())
|
||||||
|
select {
|
||||||
|
case out <- prefix:
|
||||||
|
case <-ctx.Done():
|
||||||
|
errCh <- ctx.Err()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.streamWithVars(ctx, content, out, vars); err != nil {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}(sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
|
||||||
|
// 返回第一个错误(如有)
|
||||||
|
for err := range errCh {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LoopAgent — 循环工作流
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// LoopAgent 循环执行子 Agent,最多执行 maxIterations 次
|
||||||
|
type LoopAgent struct {
|
||||||
|
name string
|
||||||
|
description string
|
||||||
|
subAgents []workflowSubAgent
|
||||||
|
maxIterations int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLoopAgent 创建循环工作流 Agent
|
||||||
|
func NewLoopAgent(name, description string, subs []model.Agent, maxIterations int) *LoopAgent {
|
||||||
|
if maxIterations <= 0 {
|
||||||
|
maxIterations = 3
|
||||||
|
}
|
||||||
|
wrapped := make([]workflowSubAgent, 0, len(subs))
|
||||||
|
for _, s := range subs {
|
||||||
|
wrapped = append(wrapped, s.(workflowSubAgent))
|
||||||
|
}
|
||||||
|
return &LoopAgent{
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
subAgents: wrapped,
|
||||||
|
maxIterations: maxIterations,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *LoopAgent) Name() string { return a.name }
|
||||||
|
func (a *LoopAgent) OutputKey() string { return "" }
|
||||||
|
|
||||||
|
func (a *LoopAgent) Run(ctx context.Context, content model.ChatContent) (string, error) {
|
||||||
|
return a.runWithVars(ctx, content, map[string]string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *LoopAgent) Stream(ctx context.Context, content model.ChatContent, out chan<- string) error {
|
||||||
|
return a.streamWithVars(ctx, content, out, map[string]string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *LoopAgent) runWithVars(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error) {
|
||||||
|
var last string
|
||||||
|
for i := 0; i < a.maxIterations; i++ {
|
||||||
|
parts := make([]string, 0, len(a.subAgents))
|
||||||
|
for _, sub := range a.subAgents {
|
||||||
|
text, err := sub.runWithVars(ctx, content, vars)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("[%s] %s", sub.Name(), text))
|
||||||
|
}
|
||||||
|
last = strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
return last, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *LoopAgent) streamWithVars(ctx context.Context, content model.ChatContent, out chan<- string, vars map[string]string) error {
|
||||||
|
text, err := a.runWithVars(ctx, content, vars)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case out <- text:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 工具函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
var sessionCounter atomic.Uint64
|
||||||
|
|
||||||
|
// cloneVars 克隆变量映射
|
||||||
|
func cloneVars(vars map[string]string) map[string]string {
|
||||||
|
out := make(map[string]string, len(vars)+4)
|
||||||
|
for k, v := range vars {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyVars 替换模板中的 {key} 占位符
|
||||||
|
func applyVars(template string, vars map[string]string) string {
|
||||||
|
if template == "" || len(vars) == 0 {
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
out := template
|
||||||
|
for k, v := range vars {
|
||||||
|
out = strings.ReplaceAll(out, "{"+k+"}", v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialMessages 构建初始消息列表(system + user)
|
||||||
|
func initialMessages(instruction, userText string) []model.ChatMessage {
|
||||||
|
messages := make([]model.ChatMessage, 0, 2)
|
||||||
|
if strings.TrimSpace(instruction) != "" {
|
||||||
|
messages = append(messages, model.ChatMessage{Role: model.ChatRoleSystem, Content: instruction})
|
||||||
|
}
|
||||||
|
messages = append(messages, model.ChatMessage{Role: model.ChatRoleUser, Content: userText})
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstText 从 ChatContent 中提取第一段文本
|
||||||
|
func firstText(content model.ChatContent) string {
|
||||||
|
if len(content.Texts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return content.Texts[0].Message
|
||||||
|
}
|
||||||
499
backend/internal/service/agent_test.go
Normal file
499
backend/internal/service/agent_test.go
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ai-agent-scaffold-go/internal/model"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Stub 实现
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// stubChatModel 实现 ChatModelWithTools 接口
|
||||||
|
type stubChatModel struct {
|
||||||
|
generateReply model.ChatReply
|
||||||
|
generateErr error
|
||||||
|
streamDelta string
|
||||||
|
streamErr error
|
||||||
|
toolResult string
|
||||||
|
toolErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *stubChatModel) Generate(ctx context.Context, msgs []model.ChatMessage) (model.ChatReply, error) {
|
||||||
|
return m.generateReply, m.generateErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *stubChatModel) Stream(ctx context.Context, msgs []model.ChatMessage) (<-chan model.ChatStreamEvent, <-chan error) {
|
||||||
|
events := make(chan model.ChatStreamEvent, 4)
|
||||||
|
errs := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(events)
|
||||||
|
defer close(errs)
|
||||||
|
if m.streamErr != nil {
|
||||||
|
errs <- m.streamErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 模拟逐字输出
|
||||||
|
for _, ch := range m.streamDelta {
|
||||||
|
events <- model.ChatStreamEvent{Delta: string(ch)}
|
||||||
|
}
|
||||||
|
events <- model.ChatStreamEvent{Done: true}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return events, errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *stubChatModel) CallTool(ctx context.Context, name, arguments string) (string, error) {
|
||||||
|
return m.toolResult, m.toolErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubToolCallModel 每次都返回工具调用的 stub
|
||||||
|
type stubToolCallModel struct {
|
||||||
|
callCount int
|
||||||
|
maxCalls int // 达到此次数后返回文本
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *stubToolCallModel) Generate(ctx context.Context, msgs []model.ChatMessage) (model.ChatReply, error) {
|
||||||
|
m.callCount++
|
||||||
|
if m.callCount > m.maxCalls {
|
||||||
|
return model.ChatReply{Content: "done"}, nil
|
||||||
|
}
|
||||||
|
return model.ChatReply{ToolCalls: []model.ChatToolCall{
|
||||||
|
{ID: fmt.Sprintf("call_%d", m.callCount), Name: "tool1", Arguments: `{"query":"test"}`},
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *stubToolCallModel) Stream(ctx context.Context, msgs []model.ChatMessage) (<-chan model.ChatStreamEvent, <-chan error) {
|
||||||
|
events := make(chan model.ChatStreamEvent, 4)
|
||||||
|
errs := make(chan error, 1)
|
||||||
|
m.callCount++
|
||||||
|
if m.callCount > m.maxCalls {
|
||||||
|
events <- model.ChatStreamEvent{Delta: "done", Done: true}
|
||||||
|
} else {
|
||||||
|
events <- model.ChatStreamEvent{ToolCalls: []model.ChatToolCall{
|
||||||
|
{ID: fmt.Sprintf("call_%d", m.callCount), Name: "tool1", Arguments: `{"query":"test"}`},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
close(events)
|
||||||
|
close(errs)
|
||||||
|
return events, errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *stubToolCallModel) CallTool(ctx context.Context, name, arguments string) (string, error) {
|
||||||
|
return "tool-result", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 辅助函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func newTestLLMAgent(cm ChatModelWithTools) *LLMAgent {
|
||||||
|
return NewLLMAgent("test-agent", "you are a test agent", "test desc", "", cm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testContent(msg string) model.ChatContent {
|
||||||
|
return model.ChatContent{Texts: []model.TextPart{{Message: msg}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LLMAgent 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestLLMAgent_Run_ReturnsContent(t *testing.T) {
|
||||||
|
cm := &stubChatModel{generateReply: model.ChatReply{Content: "hello"}}
|
||||||
|
agent := newTestLLMAgent(cm)
|
||||||
|
|
||||||
|
result, err := agent.Run(context.Background(), testContent("hi"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "hello", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLLMAgent_Run_ToolCallLoop_TwoRounds(t *testing.T) {
|
||||||
|
cm := &stubToolCallModel{maxCalls: 1}
|
||||||
|
agent := newTestLLMAgent(cm)
|
||||||
|
|
||||||
|
result, err := agent.Run(context.Background(), testContent("hi"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "done", result)
|
||||||
|
assert.Equal(t, 2, cm.callCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLLMAgent_Run_ExceedsIterationLimit_ReturnsError(t *testing.T) {
|
||||||
|
cm := &stubToolCallModel{maxCalls: 100} // 永远返回工具调用
|
||||||
|
agent := newTestLLMAgent(cm)
|
||||||
|
|
||||||
|
_, err := agent.Run(context.Background(), testContent("hi"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "exceeded tool-call iteration limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLLMAgent_Run_GenerateError_ReturnsError(t *testing.T) {
|
||||||
|
cm := &stubChatModel{generateErr: fmt.Errorf("llm down")}
|
||||||
|
agent := newTestLLMAgent(cm)
|
||||||
|
|
||||||
|
_, err := agent.Run(context.Background(), testContent("hi"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "llm down")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLLMAgent_Run_ToolCallError_ReturnsError(t *testing.T) {
|
||||||
|
cm := &stubChatModel{
|
||||||
|
generateReply: model.ChatReply{ToolCalls: []model.ChatToolCall{
|
||||||
|
{ID: "c1", Name: "bad-tool", Arguments: "{}"},
|
||||||
|
}},
|
||||||
|
toolErr: fmt.Errorf("tool failed"),
|
||||||
|
}
|
||||||
|
agent := newTestLLMAgent(cm)
|
||||||
|
|
||||||
|
_, err := agent.Run(context.Background(), testContent("hi"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "tool failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLLMAgent_Stream_ReturnsChunks(t *testing.T) {
|
||||||
|
cm := &stubChatModel{streamDelta: "hello"}
|
||||||
|
agent := newTestLLMAgent(cm)
|
||||||
|
|
||||||
|
out := make(chan string, 10)
|
||||||
|
err := agent.Stream(context.Background(), testContent("hi"), out)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
close(out)
|
||||||
|
|
||||||
|
var texts []string
|
||||||
|
for s := range out {
|
||||||
|
texts = append(texts, s)
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{"h", "e", "l", "l", "o"}, texts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLLMAgent_Stream_Error_ReturnsError(t *testing.T) {
|
||||||
|
cm := &stubChatModel{streamErr: fmt.Errorf("stream failed")}
|
||||||
|
agent := newTestLLMAgent(cm)
|
||||||
|
|
||||||
|
out := make(chan string, 10)
|
||||||
|
err := agent.Stream(context.Background(), testContent("hi"), out)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLLMAgent_Name_ReturnsName(t *testing.T) {
|
||||||
|
cm := &stubChatModel{}
|
||||||
|
agent := NewLLMAgent("my-agent", "inst", "desc", "key", cm)
|
||||||
|
assert.Equal(t, "my-agent", agent.Name())
|
||||||
|
assert.Equal(t, "key", agent.OutputKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SequentialAgent 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestSequentialAgent_Run_ExecutesInOrder(t *testing.T) {
|
||||||
|
cm1 := &stubChatModel{generateReply: model.ChatReply{Content: "first"}}
|
||||||
|
cm2 := &stubChatModel{generateReply: model.ChatReply{Content: "second"}}
|
||||||
|
|
||||||
|
sub1 := NewLLMAgent("a1", "inst1", "", "out1", cm1)
|
||||||
|
sub2 := NewLLMAgent("a2", "inst2", "", "", cm2)
|
||||||
|
|
||||||
|
seq := NewSequentialAgent("seq", "desc", []model.Agent{sub1, sub2})
|
||||||
|
result, err := seq.Run(context.Background(), testContent("hi"))
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "second", result) // 返回最后一个的结果
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSequentialAgent_Run_PassesOutputKey(t *testing.T) {
|
||||||
|
// sub1 输出 "first",存入 vars["out1"]
|
||||||
|
// sub2 的 instruction 包含 {out1},应被替换
|
||||||
|
cm1 := &stubChatModel{generateReply: model.ChatReply{Content: "first"}}
|
||||||
|
cm2 := &stubChatModel{generateReply: model.ChatReply{Content: "got-first"}}
|
||||||
|
|
||||||
|
sub1 := NewLLMAgent("a1", "inst1", "", "out1", cm1)
|
||||||
|
sub2 := NewLLMAgent("a2", "instruction with {out1}", "", "", cm2)
|
||||||
|
|
||||||
|
seq := NewSequentialAgent("seq", "desc", []model.Agent{sub1, sub2})
|
||||||
|
result, err := seq.Run(context.Background(), testContent("hi"))
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "got-first", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSequentialAgent_Run_SubAgentError_StopsExecution(t *testing.T) {
|
||||||
|
cm1 := &stubChatModel{generateErr: fmt.Errorf("fail")}
|
||||||
|
cm2 := &stubChatModel{generateReply: model.ChatReply{Content: "second"}}
|
||||||
|
|
||||||
|
sub1 := NewLLMAgent("a1", "inst1", "", "", cm1)
|
||||||
|
sub2 := NewLLMAgent("a2", "inst2", "", "", cm2)
|
||||||
|
|
||||||
|
seq := NewSequentialAgent("seq", "desc", []model.Agent{sub1, sub2})
|
||||||
|
_, err := seq.Run(context.Background(), testContent("hi"))
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSequentialAgent_Stream_LastAgentStreams(t *testing.T) {
|
||||||
|
cm1 := &stubChatModel{generateReply: model.ChatReply{Content: "first"}}
|
||||||
|
cm2 := &stubChatModel{streamDelta: "stream"}
|
||||||
|
|
||||||
|
sub1 := NewLLMAgent("a1", "inst1", "", "out1", cm1)
|
||||||
|
sub2 := NewLLMAgent("a2", "inst2", "", "", cm2)
|
||||||
|
|
||||||
|
seq := NewSequentialAgent("seq", "desc", []model.Agent{sub1, sub2})
|
||||||
|
out := make(chan string, 10)
|
||||||
|
err := seq.Stream(context.Background(), testContent("hi"), out)
|
||||||
|
close(out)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
var texts []string
|
||||||
|
for s := range out {
|
||||||
|
texts = append(texts, s)
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{"s", "t", "r", "e", "a", "m"}, texts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ParallelAgent 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestParallelAgent_Run_ConcatenatesResults(t *testing.T) {
|
||||||
|
cm1 := &stubChatModel{generateReply: model.ChatReply{Content: "aaa"}}
|
||||||
|
cm2 := &stubChatModel{generateReply: model.ChatReply{Content: "bbb"}}
|
||||||
|
|
||||||
|
sub1 := NewLLMAgent("a1", "inst1", "", "", cm1)
|
||||||
|
sub2 := NewLLMAgent("a2", "inst2", "", "", cm2)
|
||||||
|
|
||||||
|
par := NewParallelAgent("par", "desc", []model.Agent{sub1, sub2})
|
||||||
|
result, err := par.Run(context.Background(), testContent("hi"))
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, result, "[a1] aaa")
|
||||||
|
assert.Contains(t, result, "[a2] bbb")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParallelAgent_Run_SubAgentError_ReturnsError(t *testing.T) {
|
||||||
|
cm1 := &stubChatModel{generateReply: model.ChatReply{Content: "ok"}}
|
||||||
|
cm2 := &stubChatModel{generateErr: fmt.Errorf("fail")}
|
||||||
|
|
||||||
|
sub1 := NewLLMAgent("a1", "inst1", "", "", cm1)
|
||||||
|
sub2 := NewLLMAgent("a2", "inst2", "", "", cm2)
|
||||||
|
|
||||||
|
par := NewParallelAgent("par", "desc", []model.Agent{sub1, sub2})
|
||||||
|
_, err := par.Run(context.Background(), testContent("hi"))
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParallelAgent_Run_ConcurrentExecution(t *testing.T) {
|
||||||
|
// 验证并发执行:两个 agent 都被调用
|
||||||
|
cm1 := &stubChatModel{generateReply: model.ChatReply{Content: "first"}}
|
||||||
|
cm2 := &stubChatModel{generateReply: model.ChatReply{Content: "second"}}
|
||||||
|
|
||||||
|
sub1 := NewLLMAgent("a1", "inst1", "", "", cm1)
|
||||||
|
sub2 := NewLLMAgent("a2", "inst2", "", "", cm2)
|
||||||
|
|
||||||
|
par := NewParallelAgent("par", "desc", []model.Agent{sub1, sub2})
|
||||||
|
result, err := par.Run(context.Background(), testContent("hi"))
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
// 结果应包含两个 agent 的输出
|
||||||
|
assert.True(t, strings.Contains(result, "first"))
|
||||||
|
assert.True(t, strings.Contains(result, "second"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParallelAgent_Stream_OutputsConcurrently(t *testing.T) {
|
||||||
|
// 使用单次输出的 stub 避免逐字符并发竞争
|
||||||
|
cm1 := &singleShotChatModel{content: "result-a"}
|
||||||
|
cm2 := &singleShotChatModel{content: "result-b"}
|
||||||
|
|
||||||
|
sub1 := NewLLMAgent("a1", "inst1", "", "", cm1)
|
||||||
|
sub2 := NewLLMAgent("a2", "inst2", "", "", cm2)
|
||||||
|
|
||||||
|
par := NewParallelAgent("par", "desc", []model.Agent{sub1, sub2})
|
||||||
|
out := make(chan string, 20)
|
||||||
|
err := par.Stream(context.Background(), testContent("hi"), out)
|
||||||
|
close(out)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
var texts []string
|
||||||
|
for s := range out {
|
||||||
|
texts = append(texts, s)
|
||||||
|
}
|
||||||
|
full := strings.Join(texts, "")
|
||||||
|
assert.Contains(t, full, "[a1]")
|
||||||
|
assert.Contains(t, full, "[a2]")
|
||||||
|
assert.Contains(t, full, "result-a")
|
||||||
|
assert.Contains(t, full, "result-b")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LoopAgent 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestLoopAgent_Run_RepeatsSubAgents(t *testing.T) {
|
||||||
|
cm := &stubChatModel{generateReply: model.ChatReply{Content: "tick"}}
|
||||||
|
sub := NewLLMAgent("a1", "inst1", "", "", cm)
|
||||||
|
|
||||||
|
loop := NewLoopAgent("loop", "desc", []model.Agent{sub}, 3)
|
||||||
|
result, err := loop.Run(context.Background(), testContent("hi"))
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, result, "[a1] tick")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopAgent_Run_DefaultMaxIterations(t *testing.T) {
|
||||||
|
cm := &stubChatModel{generateReply: model.ChatReply{Content: "ok"}}
|
||||||
|
sub := NewLLMAgent("a1", "inst1", "", "", cm)
|
||||||
|
|
||||||
|
loop := NewLoopAgent("loop", "desc", []model.Agent{sub}, 0) // 0 → 默认 3
|
||||||
|
assert.Equal(t, 3, loop.maxIterations)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopAgent_Run_SubAgentError_StopsLoop(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
errModel := &stubChatModel{}
|
||||||
|
errModel.generateErr = fmt.Errorf("fail on call")
|
||||||
|
|
||||||
|
// 用一个计数 stub
|
||||||
|
countModel := &countingChatModel{reply: model.ChatReply{Content: "ok"}, failAfter: 2}
|
||||||
|
sub := NewLLMAgent("a1", "inst1", "", "", countModel)
|
||||||
|
|
||||||
|
loop := NewLoopAgent("loop", "desc", []model.Agent{sub}, 5)
|
||||||
|
_, err := loop.Run(context.Background(), testContent("hi"))
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
_ = callCount
|
||||||
|
_ = errModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopAgent_Stream_ExecutesAndStreams(t *testing.T) {
|
||||||
|
cm := &stubChatModel{streamDelta: "loop"}
|
||||||
|
sub := NewLLMAgent("a1", "inst1", "", "", cm)
|
||||||
|
|
||||||
|
loop := NewLoopAgent("loop", "desc", []model.Agent{sub}, 2)
|
||||||
|
out := make(chan string, 20)
|
||||||
|
err := loop.Stream(context.Background(), testContent("hi"), out)
|
||||||
|
close(out)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
var texts []string
|
||||||
|
for s := range out {
|
||||||
|
texts = append(texts, s)
|
||||||
|
}
|
||||||
|
full := strings.Join(texts, "")
|
||||||
|
assert.Contains(t, full, "[a1]")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 工具函数测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestCloneVars_CreatesIndependentCopy(t *testing.T) {
|
||||||
|
orig := map[string]string{"a": "1", "b": "2"}
|
||||||
|
cloned := cloneVars(orig)
|
||||||
|
cloned["c"] = "3"
|
||||||
|
assert.NotContains(t, orig, "c")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyVars_ReplacesPlaceholders(t *testing.T) {
|
||||||
|
vars := map[string]string{"name": "world", "greeting": "hello"}
|
||||||
|
result := applyVars("{greeting} {name}!", vars)
|
||||||
|
assert.Equal(t, "hello world!", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyVars_EmptyTemplate_ReturnsEmpty(t *testing.T) {
|
||||||
|
result := applyVars("", map[string]string{"a": "1"})
|
||||||
|
assert.Equal(t, "", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyVars_NoVars_ReturnsOriginal(t *testing.T) {
|
||||||
|
result := applyVars("hello {name}", nil)
|
||||||
|
assert.Equal(t, "hello {name}", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitialMessages_WithInstruction(t *testing.T) {
|
||||||
|
msgs := initialMessages("system instruction", "user text")
|
||||||
|
assert.Len(t, msgs, 2)
|
||||||
|
assert.Equal(t, model.ChatRoleSystem, msgs[0].Role)
|
||||||
|
assert.Equal(t, "system instruction", msgs[0].Content)
|
||||||
|
assert.Equal(t, model.ChatRoleUser, msgs[1].Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitialMessages_EmptyInstruction_SkipsSystem(t *testing.T) {
|
||||||
|
msgs := initialMessages("", "user text")
|
||||||
|
assert.Len(t, msgs, 1)
|
||||||
|
assert.Equal(t, model.ChatRoleUser, msgs[0].Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstText_WithContent(t *testing.T) {
|
||||||
|
content := model.ChatContent{Texts: []model.TextPart{{Message: "hi"}, {Message: "bye"}}}
|
||||||
|
assert.Equal(t, "hi", firstText(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstText_EmptyContent(t *testing.T) {
|
||||||
|
assert.Equal(t, "", firstText(model.ChatContent{}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 辅助 stub
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// singleShotChatModel 一次性输出完整内容的 stub(适合并发测试)
|
||||||
|
type singleShotChatModel struct {
|
||||||
|
content string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *singleShotChatModel) Generate(ctx context.Context, msgs []model.ChatMessage) (model.ChatReply, error) {
|
||||||
|
return model.ChatReply{Content: m.content}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *singleShotChatModel) Stream(ctx context.Context, msgs []model.ChatMessage) (<-chan model.ChatStreamEvent, <-chan error) {
|
||||||
|
events := make(chan model.ChatStreamEvent, 2)
|
||||||
|
errs := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
events <- model.ChatStreamEvent{Delta: m.content, Done: true}
|
||||||
|
close(events)
|
||||||
|
close(errs)
|
||||||
|
}()
|
||||||
|
return events, errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *singleShotChatModel) CallTool(ctx context.Context, name, arguments string) (string, error) {
|
||||||
|
return "ok", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countingChatModel 记录调用次数,超过 failAfter 后返回错误
|
||||||
|
type countingChatModel struct {
|
||||||
|
reply model.ChatReply
|
||||||
|
failAfter int
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingChatModel) Generate(ctx context.Context, msgs []model.ChatMessage) (model.ChatReply, error) {
|
||||||
|
m.calls++
|
||||||
|
if m.calls > m.failAfter {
|
||||||
|
return model.ChatReply{}, fmt.Errorf("fail at call %d", m.calls)
|
||||||
|
}
|
||||||
|
return m.reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingChatModel) Stream(ctx context.Context, msgs []model.ChatMessage) (<-chan model.ChatStreamEvent, <-chan error) {
|
||||||
|
events := make(chan model.ChatStreamEvent, 4)
|
||||||
|
errs := make(chan error, 1)
|
||||||
|
m.calls++
|
||||||
|
if m.calls > m.failAfter {
|
||||||
|
errs <- fmt.Errorf("fail at call %d", m.calls)
|
||||||
|
} else {
|
||||||
|
events <- model.ChatStreamEvent{Delta: m.reply.Content, Done: true}
|
||||||
|
}
|
||||||
|
close(events)
|
||||||
|
close(errs)
|
||||||
|
return events, errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingChatModel) CallTool(ctx context.Context, name, arguments string) (string, error) {
|
||||||
|
return "ok", nil
|
||||||
|
}
|
||||||
110
backend/internal/service/assembler.go
Normal file
110
backend/internal/service/assembler.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ai-agent-scaffold-go/internal/config"
|
||||||
|
"ai-agent-scaffold-go/internal/llm"
|
||||||
|
"ai-agent-scaffold-go/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssembleAll 从配置表批量组装 Agent
|
||||||
|
func AssembleAll(ctx context.Context, tables map[string]model.AiAgentConfigTable, timeout time.Duration) ([]model.RegisteredAgent, error) {
|
||||||
|
var agents []model.RegisteredAgent
|
||||||
|
for _, table := range tables {
|
||||||
|
agent, err := assembleOne(ctx, table, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("assemble %s: %w", table.AppName, err)
|
||||||
|
}
|
||||||
|
agents = append(agents, *agent)
|
||||||
|
}
|
||||||
|
return agents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// assembleOne 组装单个 Agent 配置
|
||||||
|
func assembleOne(ctx context.Context, table model.AiAgentConfigTable, timeout time.Duration) (*model.RegisteredAgent, error) {
|
||||||
|
apiCfg := table.Module.AiAPI
|
||||||
|
completionsURL := strings.TrimRight(apiCfg.BaseURL, "/") + "/" + strings.TrimLeft(apiCfg.CompletionsPath, "/")
|
||||||
|
|
||||||
|
// 1. 创建 OpenAI 客户端
|
||||||
|
client := llm.NewOpenAIClient(completionsURL, apiCfg.APIKey, table.Module.ChatModel.Model, timeout)
|
||||||
|
|
||||||
|
// 2. 创建 ChatModel(当前无外部工具,后续可扩展)
|
||||||
|
chatModel := llm.NewChatModelAdapter(client, nil)
|
||||||
|
|
||||||
|
// 3. 构建 Agent 映射表
|
||||||
|
agentMap := map[string]model.Agent{}
|
||||||
|
for _, agentCfg := range table.Module.Agents {
|
||||||
|
agent := NewLLMAgent(agentCfg.Name, agentCfg.Instruction, agentCfg.Description, agentCfg.OutputKey, chatModel)
|
||||||
|
agentMap[agentCfg.Name] = agent
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 构建 Workflow Agent
|
||||||
|
for _, wfCfg := range table.Module.AgentWorkflows {
|
||||||
|
subs := make([]model.Agent, 0, len(wfCfg.SubAgents))
|
||||||
|
for _, subName := range wfCfg.SubAgents {
|
||||||
|
sub, ok := agentMap[subName]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("workflow %q references unknown agent %q", wfCfg.Name, subName)
|
||||||
|
}
|
||||||
|
subs = append(subs, sub)
|
||||||
|
}
|
||||||
|
var wfAgent model.Agent
|
||||||
|
switch wfCfg.Type {
|
||||||
|
case model.WorkflowTypeSequential:
|
||||||
|
wfAgent = NewSequentialAgent(wfCfg.Name, wfCfg.Description, subs)
|
||||||
|
case model.WorkflowTypeParallel:
|
||||||
|
wfAgent = NewParallelAgent(wfCfg.Name, wfCfg.Description, subs)
|
||||||
|
case model.WorkflowTypeLoop:
|
||||||
|
wfAgent = NewLoopAgent(wfCfg.Name, wfCfg.Description, subs, wfCfg.MaxIterations)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown workflow type: %s", wfCfg.Type)
|
||||||
|
}
|
||||||
|
agentMap[wfCfg.Name] = wfAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 解析入口 Agent
|
||||||
|
entryName := table.Module.Runner.AgentName
|
||||||
|
entryAgent, ok := agentMap[entryName]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("entry agent %q not found", entryName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 创建 Runner
|
||||||
|
runner := NewRunner(table.AppName, entryAgent)
|
||||||
|
|
||||||
|
return &model.RegisteredAgent{
|
||||||
|
AppName: table.AppName,
|
||||||
|
AgentID: table.Agent.AgentID,
|
||||||
|
AgentName: table.Agent.AgentName,
|
||||||
|
AgentDesc: table.Agent.AgentDesc,
|
||||||
|
Runner: runner,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAndAssemble 从配置文件路径列表加载并组装所有 Agent
|
||||||
|
func LoadAndAssemble(ctx context.Context, paths []string, timeout time.Duration) ([]model.RegisteredAgent, error) {
|
||||||
|
merged := make(map[string]model.AiAgentConfigTable)
|
||||||
|
for _, raw := range paths {
|
||||||
|
path := strings.TrimSpace(raw)
|
||||||
|
if path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expanded := os.ExpandEnv(path)
|
||||||
|
tables, err := config.LoadAgentTablesFile(expanded)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for name, table := range tables {
|
||||||
|
merged[name] = table
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(merged) == 0 {
|
||||||
|
return nil, fmt.Errorf("no agent tables loaded")
|
||||||
|
}
|
||||||
|
return AssembleAll(ctx, merged, timeout)
|
||||||
|
}
|
||||||
248
backend/internal/service/assembler_test.go
Normal file
248
backend/internal/service/assembler_test.go
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ai-agent-scaffold-go/internal/model"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 辅助函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func newTestTable() model.AiAgentConfigTable {
|
||||||
|
return model.AiAgentConfigTable{
|
||||||
|
AppName: "test-app",
|
||||||
|
Agent: model.AgentSummary{AgentID: "10001", AgentName: "test", AgentDesc: "test agent"},
|
||||||
|
Module: model.AgentModule{
|
||||||
|
AiAPI: model.AiAPIConfig{BaseURL: "http://localhost:8080", APIKey: "test-key", CompletionsPath: "v1/chat/completions"},
|
||||||
|
ChatModel: model.ChatModelConfig{Model: "gpt-4"},
|
||||||
|
Agents: []model.AgentConfig{{Name: "bot", Instruction: "hello"}},
|
||||||
|
Runner: model.RunnerConfig{AgentName: "bot"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeLLMServer 返回固定回复的模拟 LLM 服务
|
||||||
|
func fakeLLMServer(response string) *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(response))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// assembleOne 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestAssembleOne_SingleAgent_Success(t *testing.T) {
|
||||||
|
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
table := newTestTable()
|
||||||
|
table.Module.AiAPI.BaseURL = srv.URL
|
||||||
|
|
||||||
|
reg, err := assembleOne(context.Background(), table, 5*time.Second)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "10001", reg.AgentID)
|
||||||
|
assert.Equal(t, "test", reg.AgentName)
|
||||||
|
assert.Equal(t, "test-app", reg.AppName)
|
||||||
|
assert.NotNil(t, reg.Runner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleOne_WithWorkflow_Success(t *testing.T) {
|
||||||
|
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
table := newTestTable()
|
||||||
|
table.Module.AiAPI.BaseURL = srv.URL
|
||||||
|
table.Module.Agents = []model.AgentConfig{
|
||||||
|
{Name: "agent1", Instruction: "inst1"},
|
||||||
|
{Name: "agent2", Instruction: "inst2"},
|
||||||
|
}
|
||||||
|
table.Module.AgentWorkflows = []model.AgentWorkflowConfig{
|
||||||
|
{
|
||||||
|
Type: model.WorkflowTypeSequential,
|
||||||
|
Name: "seq",
|
||||||
|
SubAgents: []string{"agent1", "agent2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
table.Module.Runner.AgentName = "seq"
|
||||||
|
|
||||||
|
reg, err := assembleOne(context.Background(), table, 5*time.Second)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, reg.Runner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleOne_ParallelWorkflow_Success(t *testing.T) {
|
||||||
|
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
table := newTestTable()
|
||||||
|
table.Module.AiAPI.BaseURL = srv.URL
|
||||||
|
table.Module.Agents = []model.AgentConfig{
|
||||||
|
{Name: "a1", Instruction: "inst1"},
|
||||||
|
{Name: "a2", Instruction: "inst2"},
|
||||||
|
}
|
||||||
|
table.Module.AgentWorkflows = []model.AgentWorkflowConfig{
|
||||||
|
{
|
||||||
|
Type: model.WorkflowTypeParallel,
|
||||||
|
Name: "par",
|
||||||
|
SubAgents: []string{"a1", "a2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
table.Module.Runner.AgentName = "par"
|
||||||
|
|
||||||
|
reg, err := assembleOne(context.Background(), table, 5*time.Second)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, reg.Runner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleOne_LoopWorkflow_Success(t *testing.T) {
|
||||||
|
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
table := newTestTable()
|
||||||
|
table.Module.AiAPI.BaseURL = srv.URL
|
||||||
|
table.Module.Agents = []model.AgentConfig{
|
||||||
|
{Name: "a1", Instruction: "inst"},
|
||||||
|
}
|
||||||
|
table.Module.AgentWorkflows = []model.AgentWorkflowConfig{
|
||||||
|
{
|
||||||
|
Type: model.WorkflowTypeLoop,
|
||||||
|
Name: "loop",
|
||||||
|
SubAgents: []string{"a1"},
|
||||||
|
MaxIterations: 3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
table.Module.Runner.AgentName = "loop"
|
||||||
|
|
||||||
|
reg, err := assembleOne(context.Background(), table, 5*time.Second)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, reg.Runner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleOne_UnknownSubAgent_ReturnsError(t *testing.T) {
|
||||||
|
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
table := newTestTable()
|
||||||
|
table.Module.AiAPI.BaseURL = srv.URL
|
||||||
|
table.Module.AgentWorkflows = []model.AgentWorkflowConfig{
|
||||||
|
{
|
||||||
|
Type: model.WorkflowTypeSequential,
|
||||||
|
Name: "seq",
|
||||||
|
SubAgents: []string{"nonexistent"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
table.Module.Runner.AgentName = "seq"
|
||||||
|
|
||||||
|
_, err := assembleOne(context.Background(), table, 5*time.Second)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unknown agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleOne_UnknownWorkflowType_ReturnsError(t *testing.T) {
|
||||||
|
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
table := newTestTable()
|
||||||
|
table.Module.AiAPI.BaseURL = srv.URL
|
||||||
|
table.Module.AgentWorkflows = []model.AgentWorkflowConfig{
|
||||||
|
{
|
||||||
|
Type: "bad-type",
|
||||||
|
Name: "wf",
|
||||||
|
SubAgents: []string{"bot"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
table.Module.Runner.AgentName = "wf"
|
||||||
|
|
||||||
|
_, err := assembleOne(context.Background(), table, 5*time.Second)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unknown workflow type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleOne_EntryAgentNotFound_ReturnsError(t *testing.T) {
|
||||||
|
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
table := newTestTable()
|
||||||
|
table.Module.AiAPI.BaseURL = srv.URL
|
||||||
|
table.Module.Runner.AgentName = "nonexistent"
|
||||||
|
|
||||||
|
_, err := assembleOne(context.Background(), table, 5*time.Second)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// AssembleAll 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestAssembleAll_MultipleTables_Success(t *testing.T) {
|
||||||
|
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tables := map[string]model.AiAgentConfigTable{
|
||||||
|
"t1": func() model.AiAgentConfigTable {
|
||||||
|
t := newTestTable()
|
||||||
|
t.Module.AiAPI.BaseURL = srv.URL
|
||||||
|
t.AppName = "app1"
|
||||||
|
t.Agent.AgentID = "1"
|
||||||
|
return t
|
||||||
|
}(),
|
||||||
|
"t2": func() model.AiAgentConfigTable {
|
||||||
|
t := newTestTable()
|
||||||
|
t.Module.AiAPI.BaseURL = srv.URL
|
||||||
|
t.AppName = "app2"
|
||||||
|
t.Agent.AgentID = "2"
|
||||||
|
return t
|
||||||
|
}(),
|
||||||
|
}
|
||||||
|
|
||||||
|
agents, err := AssembleAll(context.Background(), tables, 5*time.Second)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, agents, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleAll_OneFails_ReturnsError(t *testing.T) {
|
||||||
|
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tables := map[string]model.AiAgentConfigTable{
|
||||||
|
"t1": func() model.AiAgentConfigTable {
|
||||||
|
t := newTestTable()
|
||||||
|
t.Module.AiAPI.BaseURL = srv.URL
|
||||||
|
return t
|
||||||
|
}(),
|
||||||
|
"t2": func() model.AiAgentConfigTable {
|
||||||
|
t := newTestTable()
|
||||||
|
t.Module.AiAPI.BaseURL = srv.URL
|
||||||
|
t.Module.Runner.AgentName = "nonexistent"
|
||||||
|
return t
|
||||||
|
}(),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := AssembleAll(context.Background(), tables, 5*time.Second)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LoadAndAssemble 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestLoadAndAssemble_NoPaths_ReturnsError(t *testing.T) {
|
||||||
|
_, err := LoadAndAssemble(context.Background(), []string{}, 5*time.Second)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "no agent tables loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAndAssemble_EmptyPaths_ReturnsError(t *testing.T) {
|
||||||
|
_, err := LoadAndAssemble(context.Background(), []string{"", " "}, 5*time.Second)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
110
backend/internal/service/chat.go
Normal file
110
backend/internal/service/chat.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"ai-agent-scaffold-go/internal/model"
|
||||||
|
"ai-agent-scaffold-go/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatService 聊天服务
|
||||||
|
type ChatService struct {
|
||||||
|
registry model.AgentRegistry
|
||||||
|
sessions model.SessionStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChatService 创建聊天服务
|
||||||
|
func NewChatService(registry model.AgentRegistry, sessions model.SessionStore) *ChatService {
|
||||||
|
return &ChatService{registry: registry, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryAgentConfigList 查询已注册的 Agent 列表
|
||||||
|
func (s *ChatService) QueryAgentConfigList() []model.AgentSummary {
|
||||||
|
registered := s.registry.List()
|
||||||
|
sort.Slice(registered, func(i, j int) bool {
|
||||||
|
return registered[i].AgentID < registered[j].AgentID
|
||||||
|
})
|
||||||
|
agents := make([]model.AgentSummary, 0, len(registered))
|
||||||
|
for _, agent := range registered {
|
||||||
|
agents = append(agents, model.AgentSummary{
|
||||||
|
AgentID: agent.AgentID,
|
||||||
|
AgentName: agent.AgentName,
|
||||||
|
AgentDesc: agent.AgentDesc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return agents
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession 为指定 Agent 和用户创建会话
|
||||||
|
func (s *ChatService) CreateSession(agentID, userID string) (string, error) {
|
||||||
|
if sessionID, ok := s.sessions.Get(userID, agentID); ok {
|
||||||
|
return sessionID, nil
|
||||||
|
}
|
||||||
|
registered, ok := s.registry.Get(agentID)
|
||||||
|
if !ok || registered.Runner == nil {
|
||||||
|
return "", types.NewAppError(types.CodeAgentNotFound, types.InfoAgentNotFound)
|
||||||
|
}
|
||||||
|
sessionID, err := registered.Runner.CreateSession(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := s.sessions.Set(userID, agentID, sessionID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return sessionID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMessage 处理同步聊天消息
|
||||||
|
func (s *ChatService) HandleMessage(agentID, userID, sessionID, message string) ([]string, error) {
|
||||||
|
content := model.ChatContent{Texts: []model.TextPart{{Message: message}}}
|
||||||
|
return s.handleCommand(agentID, userID, sessionID, message, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMessageStream 处理流式聊天消息
|
||||||
|
func (s *ChatService) HandleMessageStream(agentID, userID, sessionID, message string) (<-chan string, <-chan error) {
|
||||||
|
content := model.ChatContent{Texts: []model.TextPart{{Message: message}}}
|
||||||
|
return s.handleCommandStream(agentID, userID, sessionID, message, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatService) handleCommand(agentID, userID, sessionID, message string, content model.ChatContent) ([]string, error) {
|
||||||
|
registered, sessionID, err := s.resolveRunnerSession(agentID, userID, sessionID, message, content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return registered.Runner.Run(userID, sessionID, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatService) handleCommandStream(agentID, userID, sessionID, message string, content model.ChatContent) (<-chan string, <-chan error) {
|
||||||
|
registered, sessionID, err := s.resolveRunnerSession(agentID, userID, sessionID, message, content)
|
||||||
|
if err != nil {
|
||||||
|
outputs := make(chan string)
|
||||||
|
errs := make(chan error, 1)
|
||||||
|
errs <- err
|
||||||
|
close(outputs)
|
||||||
|
close(errs)
|
||||||
|
return outputs, errs
|
||||||
|
}
|
||||||
|
return registered.Runner.Stream(userID, sessionID, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatService) resolveRunnerSession(agentID, userID, sessionID, message string, content model.ChatContent) (model.RegisteredAgent, string, error) {
|
||||||
|
registered, ok := s.registry.Get(agentID)
|
||||||
|
if !ok || registered.Runner == nil {
|
||||||
|
return model.RegisteredAgent{}, "", types.NewAppError(types.CodeAgentNotFound, types.InfoAgentNotFound)
|
||||||
|
}
|
||||||
|
if sessionID == "" {
|
||||||
|
var err error
|
||||||
|
sessionID, err = s.CreateSession(agentID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return model.RegisteredAgent{}, "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(content.Texts) == 0 && message != "" {
|
||||||
|
content.Texts = []model.TextPart{{Message: message}}
|
||||||
|
}
|
||||||
|
if len(content.Texts) == 0 {
|
||||||
|
return model.RegisteredAgent{}, "", fmt.Errorf("chat content is required")
|
||||||
|
}
|
||||||
|
return registered, sessionID, nil
|
||||||
|
}
|
||||||
200
backend/internal/service/chat_test.go
Normal file
200
backend/internal/service/chat_test.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ai-agent-scaffold-go/internal/model"
|
||||||
|
"ai-agent-scaffold-go/pkg/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Stub Runner
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type stubRunner struct {
|
||||||
|
sessionID string
|
||||||
|
runResult []string
|
||||||
|
runErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubRunner) CreateSession(userID string) (string, error) {
|
||||||
|
if r.sessionID != "" {
|
||||||
|
return r.sessionID, nil
|
||||||
|
}
|
||||||
|
return "sess:" + userID + ":1", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubRunner) Run(userID, sessionID string, content model.ChatContent) ([]string, error) {
|
||||||
|
return r.runResult, r.runErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubRunner) Stream(userID, sessionID string, content model.ChatContent) (<-chan string, <-chan error) {
|
||||||
|
outputs := make(chan string, 4)
|
||||||
|
errs := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(outputs)
|
||||||
|
defer close(errs)
|
||||||
|
if r.runErr != nil {
|
||||||
|
errs <- r.runErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, s := range r.runResult {
|
||||||
|
outputs <- s
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return outputs, errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ChatService 测试
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func newTestChatService() (*ChatService, *model.InMemoryAgentRegistry, *model.InMemorySessionStore) {
|
||||||
|
registry := model.NewInMemoryAgentRegistry()
|
||||||
|
sessions := model.NewInMemorySessionStore()
|
||||||
|
svc := NewChatService(registry, sessions)
|
||||||
|
return svc, registry, sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatService_QueryAgentConfigList_ReturnsSorted(t *testing.T) {
|
||||||
|
svc, registry, _ := newTestChatService()
|
||||||
|
|
||||||
|
registry.Register(model.RegisteredAgent{AgentID: "2", AgentName: "b", AgentDesc: "desc b"})
|
||||||
|
registry.Register(model.RegisteredAgent{AgentID: "1", AgentName: "a", AgentDesc: "desc a"})
|
||||||
|
|
||||||
|
agents := svc.QueryAgentConfigList()
|
||||||
|
assert.Len(t, agents, 2)
|
||||||
|
assert.Equal(t, "1", agents[0].AgentID)
|
||||||
|
assert.Equal(t, "2", agents[1].AgentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatService_QueryAgentConfigList_Empty(t *testing.T) {
|
||||||
|
svc, _, _ := newTestChatService()
|
||||||
|
agents := svc.QueryAgentConfigList()
|
||||||
|
assert.Len(t, agents, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatService_CreateSession_NewSession(t *testing.T) {
|
||||||
|
svc, registry, _ := newTestChatService()
|
||||||
|
registry.Register(model.RegisteredAgent{
|
||||||
|
AgentID: "1",
|
||||||
|
Runner: &stubRunner{sessionID: "sess:u1:1"},
|
||||||
|
})
|
||||||
|
|
||||||
|
sessionID, err := svc.CreateSession("1", "user1")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "sess:u1:1", sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatService_CreateSession_ReusesExisting(t *testing.T) {
|
||||||
|
svc, registry, _ := newTestChatService()
|
||||||
|
registry.Register(model.RegisteredAgent{
|
||||||
|
AgentID: "1",
|
||||||
|
Runner: &stubRunner{sessionID: "sess:u1:1"},
|
||||||
|
})
|
||||||
|
|
||||||
|
id1, _ := svc.CreateSession("1", "user1")
|
||||||
|
id2, _ := svc.CreateSession("1", "user1")
|
||||||
|
assert.Equal(t, id1, id2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatService_CreateSession_AgentNotFound_ReturnsError(t *testing.T) {
|
||||||
|
svc, _, _ := newTestChatService()
|
||||||
|
|
||||||
|
_, err := svc.CreateSession("nonexistent", "user1")
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
var appErr *types.AppError
|
||||||
|
assert.ErrorAs(t, err, &appErr)
|
||||||
|
assert.Equal(t, types.CodeAgentNotFound, appErr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatService_HandleMessage_AgentNotFound_ReturnsError(t *testing.T) {
|
||||||
|
svc, _, _ := newTestChatService()
|
||||||
|
|
||||||
|
_, err := svc.HandleMessage("nonexistent", "user1", "", "hello")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatService_HandleMessage_NormalCall(t *testing.T) {
|
||||||
|
svc, registry, _ := newTestChatService()
|
||||||
|
registry.Register(model.RegisteredAgent{
|
||||||
|
AgentID: "1",
|
||||||
|
Runner: &stubRunner{sessionID: "s1", runResult: []string{"reply"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
outputs, err := svc.HandleMessage("1", "user1", "s1", "hello")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"reply"}, outputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatService_HandleMessage_EmptyMessage_StillPassesThrough(t *testing.T) {
|
||||||
|
svc, registry, _ := newTestChatService()
|
||||||
|
registry.Register(model.RegisteredAgent{
|
||||||
|
AgentID: "1",
|
||||||
|
Runner: &stubRunner{sessionID: "s1", runResult: []string{}},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 空消息仍会创建 TextPart,当前实现不校验空消息内容
|
||||||
|
outputs, err := svc.HandleMessage("1", "user1", "s1", "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Empty(t, outputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatService_HandleMessage_RunnerError_Propagates(t *testing.T) {
|
||||||
|
svc, registry, _ := newTestChatService()
|
||||||
|
registry.Register(model.RegisteredAgent{
|
||||||
|
AgentID: "1",
|
||||||
|
Runner: &stubRunner{sessionID: "s1", runErr: fmt.Errorf("run failed")},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := svc.HandleMessage("1", "user1", "s1", "hello")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "run failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatService_HandleMessageStream_AgentNotFound_ReturnsError(t *testing.T) {
|
||||||
|
svc, _, _ := newTestChatService()
|
||||||
|
|
||||||
|
outputs, errs := svc.HandleMessageStream("nonexistent", "user1", "", "hello")
|
||||||
|
// 消费通道
|
||||||
|
var streamErr error
|
||||||
|
for range outputs {
|
||||||
|
}
|
||||||
|
for err := range errs {
|
||||||
|
streamErr = err
|
||||||
|
}
|
||||||
|
assert.Error(t, streamErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatService_HandleMessageStream_NormalCall(t *testing.T) {
|
||||||
|
svc, registry, _ := newTestChatService()
|
||||||
|
registry.Register(model.RegisteredAgent{
|
||||||
|
AgentID: "1",
|
||||||
|
Runner: &stubRunner{sessionID: "s1", runResult: []string{"chunk1", "chunk2"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
outputs, errs := svc.HandleMessageStream("1", "user1", "s1", "hello")
|
||||||
|
var texts []string
|
||||||
|
for s := range outputs {
|
||||||
|
texts = append(texts, s)
|
||||||
|
}
|
||||||
|
for range errs {
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{"chunk1", "chunk2"}, texts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatService_CreateSession_AutoCreatesWhenSessionEmpty(t *testing.T) {
|
||||||
|
svc, registry, _ := newTestChatService()
|
||||||
|
registry.Register(model.RegisteredAgent{
|
||||||
|
AgentID: "1",
|
||||||
|
Runner: &stubRunner{sessionID: "auto-sess", runResult: []string{"ok"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
// sessionID 为空时自动创建
|
||||||
|
outputs, err := svc.HandleMessage("1", "user1", "", "hello")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"ok"}, outputs)
|
||||||
|
}
|
||||||
68
backend/internal/service/runner.go
Normal file
68
backend/internal/service/runner.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"ai-agent-scaffold-go/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunnerImpl Runner 的默认实现
|
||||||
|
type RunnerImpl struct {
|
||||||
|
appName string
|
||||||
|
agent model.Agent
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunner 创建 Runner
|
||||||
|
func NewRunner(appName string, agent model.Agent) *RunnerImpl {
|
||||||
|
return &RunnerImpl{
|
||||||
|
appName: appName,
|
||||||
|
agent: agent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession 创建会话 ID
|
||||||
|
func (r *RunnerImpl) CreateSession(userID string) (string, error) {
|
||||||
|
if strings.TrimSpace(userID) == "" {
|
||||||
|
return "", fmt.Errorf("user id is required")
|
||||||
|
}
|
||||||
|
next := sessionCounter.Add(1)
|
||||||
|
return fmt.Sprintf("%s:%s:%d", r.appName, userID, next), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 同步执行
|
||||||
|
func (r *RunnerImpl) Run(userID, sessionID string, content model.ChatContent) ([]string, error) {
|
||||||
|
if strings.TrimSpace(userID) == "" || strings.TrimSpace(sessionID) == "" {
|
||||||
|
return nil, fmt.Errorf("user id and session id are required")
|
||||||
|
}
|
||||||
|
output, err := r.agent.Run(context.Background(), content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if output == "" {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
return []string{output}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream 流式执行
|
||||||
|
func (r *RunnerImpl) Stream(userID, sessionID string, content model.ChatContent) (<-chan string, <-chan error) {
|
||||||
|
outputs := make(chan string, 8)
|
||||||
|
errs := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(outputs)
|
||||||
|
defer close(errs)
|
||||||
|
|
||||||
|
if strings.TrimSpace(userID) == "" || strings.TrimSpace(sessionID) == "" {
|
||||||
|
errs <- fmt.Errorf("user id and session id are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.agent.Stream(context.Background(), content, outputs); err != nil {
|
||||||
|
errs <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return outputs, errs
|
||||||
|
}
|
||||||
12
backend/pkg/types/codes.go
Normal file
12
backend/pkg/types/codes.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
const (
|
||||||
|
CodeSuccess = "0000"
|
||||||
|
InfoSuccess = "success"
|
||||||
|
CodeUnknownError = "0001"
|
||||||
|
InfoUnknownError = "unknown error"
|
||||||
|
CodeIllegalParameter = "0002"
|
||||||
|
InfoIllegalParameter = "illegal parameter"
|
||||||
|
CodeAgentNotFound = "0003"
|
||||||
|
InfoAgentNotFound = "agent not found"
|
||||||
|
)
|
||||||
17
backend/pkg/types/errors.go
Normal file
17
backend/pkg/types/errors.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
type AppError struct {
|
||||||
|
Code string
|
||||||
|
Info string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAppError(code, info string) *AppError {
|
||||||
|
return &AppError{Code: code, Info: info}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AppError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.Code + ": " + e.Info
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// TODO: 实现启动逻辑
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
app:
|
|
||||||
name: goloom
|
|
||||||
env: development
|
|
||||||
port: ${SERVER_PORT:-8091}
|
|
||||||
|
|
||||||
llm:
|
|
||||||
base_url: ${LLM_BASE_URL}
|
|
||||||
api_key: ${LLM_API_KEY}
|
|
||||||
model: ${LLM_MODEL:-deepseek-chat}
|
|
||||||
timeout: 30s
|
|
||||||
max_retries: 3
|
|
||||||
|
|
||||||
agent:
|
|
||||||
config_dir: configs/agent
|
|
||||||
7
frontend/next.config.ts
Normal file
7
frontend/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-agent-scaffold-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "16.1.6",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
3
frontend/public/env-config.js
Normal file
3
frontend/public/env-config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Runtime config injection — generated at container startup
|
||||||
|
// Uncomment and set the value for production deployment:
|
||||||
|
// window.__ENV = { NEXT_PUBLIC_API_BASE_URL: "http://your-backend:8091/api/v1" };
|
||||||
62
frontend/src/api/agent.ts
Normal file
62
frontend/src/api/agent.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { API_CONFIG } from "@/config/api-config";
|
||||||
|
import {
|
||||||
|
Response,
|
||||||
|
AiAgentConfigResponseDTO,
|
||||||
|
CreateSessionResponseDTO,
|
||||||
|
ChatRequestDTO,
|
||||||
|
ChatResponseDTO,
|
||||||
|
} from "@/types/api";
|
||||||
|
|
||||||
|
const handleResponse = async <T>(
|
||||||
|
response: globalThis.Response
|
||||||
|
): Promise<Response<T>> => {
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.code !== "0000") {
|
||||||
|
throw new Error(data.info || "Unknown API error");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const agentApi = {
|
||||||
|
/**
|
||||||
|
* 查询已注册的 AI Agent 列表
|
||||||
|
* GET /api/v1/query_ai_agent_config_list
|
||||||
|
*/
|
||||||
|
queryAgentList: async (): Promise<Response<AiAgentConfigResponseDTO[]>> => {
|
||||||
|
const resp = await fetch(`${API_CONFIG.BASE_URL}/query_ai_agent_config_list`);
|
||||||
|
return handleResponse<AiAgentConfigResponseDTO[]>(resp);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建聊天会话
|
||||||
|
* POST /api/v1/create_session
|
||||||
|
*/
|
||||||
|
createSession: async (
|
||||||
|
agentId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<Response<CreateSessionResponseDTO>> => {
|
||||||
|
const resp = await fetch(`${API_CONFIG.BASE_URL}/create_session`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ agentId, userId }),
|
||||||
|
});
|
||||||
|
return handleResponse<CreateSessionResponseDTO>(resp);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送聊天消息
|
||||||
|
* POST /api/v1/chat
|
||||||
|
*/
|
||||||
|
chat: async (data: ChatRequestDTO): Promise<Response<ChatResponseDTO>> => {
|
||||||
|
const resp = await fetch(`${API_CONFIG.BASE_URL}/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<ChatResponseDTO>(resp);
|
||||||
|
},
|
||||||
|
};
|
||||||
31
frontend/src/app/globals.css
Normal file
31
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义滚动条 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #94a3b8;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
19
frontend/src/app/layout.tsx
Normal file
19
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "AI Agent Chat",
|
||||||
|
description: "AI 智能体对话平台",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh">
|
||||||
|
<body className="antialiased">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
frontend/src/app/login/page.tsx
Normal file
119
frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { setUserInfo, getUserInfo, clearUserInfo } from '@/utils/cookie';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [msg, setMsg] = useState({ text: '', type: '' });
|
||||||
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
const [currentUser, setCurrentUser] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const info = getUserInfo();
|
||||||
|
if (info?.user) {
|
||||||
|
setIsLoggedIn(true);
|
||||||
|
setCurrentUser(info.user);
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleLogin = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMsg({ text: '', type: '' });
|
||||||
|
if (!username || !password) {
|
||||||
|
setMsg({ text: '请输入账号与密码。', type: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (username !== 'admin' || password !== 'admin') {
|
||||||
|
setMsg({ text: '账号或密码错误(演示:admin / admin)。', type: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUserInfo(username);
|
||||||
|
setMsg({ text: '登录成功,正在跳转…', type: 'info' });
|
||||||
|
setTimeout(() => router.push('/'), 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
clearUserInfo();
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
setCurrentUser('');
|
||||||
|
setMsg({ text: '已退出登录。', type: 'info' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||||
|
<div className="w-full max-w-md p-8 bg-slate-800/80 backdrop-blur rounded-2xl shadow-2xl border border-slate-700">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">AI 智能体工作台</h1>
|
||||||
|
<p className="text-slate-400">登录后开始与 AI 对话</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<p className="text-slate-300">
|
||||||
|
当前用户:<span className="text-emerald-400 font-semibold">{currentUser}</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full py-3 bg-slate-700 hover:bg-slate-600 text-white rounded-lg transition"
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleLogin} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-slate-400 mb-1">账号</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-emerald-400 transition"
|
||||||
|
placeholder="admin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-slate-400 mb-1">密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-emerald-400 transition"
|
||||||
|
placeholder="admin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-3 bg-emerald-500 hover:bg-emerald-600 text-white font-semibold rounded-lg transition"
|
||||||
|
>
|
||||||
|
登 录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setUsername('admin'); setPassword('admin'); }}
|
||||||
|
className="w-full py-2 text-sm text-slate-400 hover:text-slate-300 transition"
|
||||||
|
>
|
||||||
|
填充演示账号
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msg.text && (
|
||||||
|
<div
|
||||||
|
className={`mt-4 p-3 rounded-lg text-sm text-center ${
|
||||||
|
msg.type === 'error'
|
||||||
|
? 'bg-red-500/20 text-red-400'
|
||||||
|
: 'bg-emerald-500/20 text-emerald-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
367
frontend/src/app/page.tsx
Normal file
367
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { getUserInfo, clearUserInfo } from '@/utils/cookie';
|
||||||
|
import { agentApi } from '@/api/agent';
|
||||||
|
import { AiAgentConfigResponseDTO } from '@/types/api';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'agent';
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
id: string;
|
||||||
|
backendSessionId?: string;
|
||||||
|
title: string;
|
||||||
|
messages: Message[];
|
||||||
|
lastModified: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ai_agent_sessions';
|
||||||
|
|
||||||
|
function loadSessions(): Session[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSessions(sessions: Session[]) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const router = useRouter();
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 用户状态
|
||||||
|
const [currentUser, setCurrentUser] = useState('');
|
||||||
|
|
||||||
|
// Agent 状态
|
||||||
|
const [agents, setAgents] = useState<AiAgentConfigResponseDTO[]>([]);
|
||||||
|
const [selectedAgentId, setSelectedAgentId] = useState('');
|
||||||
|
|
||||||
|
// 会话状态
|
||||||
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
const [currentSessionId, setCurrentSessionId] = useState('');
|
||||||
|
|
||||||
|
// 聊天状态
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
|
||||||
|
// 检查登录状态 & 加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
const info = getUserInfo();
|
||||||
|
if (!info?.user) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentUser(info.user);
|
||||||
|
|
||||||
|
// 加载 Agent 列表
|
||||||
|
agentApi
|
||||||
|
.queryAgentList()
|
||||||
|
.then(res => {
|
||||||
|
setAgents(res.data);
|
||||||
|
// 恢复上次选择的 Agent
|
||||||
|
const last = localStorage.getItem('ai_agent_last_agent');
|
||||||
|
if (last && res.data.some(a => a.agentId === last)) {
|
||||||
|
setSelectedAgentId(last);
|
||||||
|
} else if (res.data.length > 0) {
|
||||||
|
setSelectedAgentId(res.data[0].agentId);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
|
||||||
|
// 加载本地会话
|
||||||
|
const saved = loadSessions();
|
||||||
|
setSessions(saved);
|
||||||
|
if (saved.length > 0) {
|
||||||
|
setCurrentSessionId(saved[0].id);
|
||||||
|
setMessages(saved[0].messages);
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// 自动滚动到最新消息
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// 会话变更时持久化
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessions.length > 0) saveSessions(sessions);
|
||||||
|
}, [sessions]);
|
||||||
|
|
||||||
|
const currentSession = sessions.find(s => s.id === currentSessionId);
|
||||||
|
|
||||||
|
const createSession = () => {
|
||||||
|
const id = Date.now().toString();
|
||||||
|
const newSession: Session = {
|
||||||
|
id,
|
||||||
|
title: `对话 ${sessions.length + 1}`,
|
||||||
|
messages: [],
|
||||||
|
lastModified: Date.now(),
|
||||||
|
};
|
||||||
|
setSessions(prev => [newSession, ...prev]);
|
||||||
|
setCurrentSessionId(id);
|
||||||
|
setMessages([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSession = (id: string) => {
|
||||||
|
setSessions(prev => {
|
||||||
|
const next = prev.filter(s => s.id !== id);
|
||||||
|
if (currentSessionId === id) {
|
||||||
|
if (next.length > 0) {
|
||||||
|
setCurrentSessionId(next[0].id);
|
||||||
|
setMessages(next[0].messages);
|
||||||
|
} else {
|
||||||
|
setCurrentSessionId('');
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchSession = (id: string) => {
|
||||||
|
setCurrentSessionId(id);
|
||||||
|
const s = sessions.find(s => s.id === id);
|
||||||
|
setMessages(s?.messages || []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSessionMessages = (sessionId: string, msgs: Message[]) => {
|
||||||
|
setSessions(prev =>
|
||||||
|
prev.map(s =>
|
||||||
|
s.id === sessionId
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
messages: msgs,
|
||||||
|
lastModified: Date.now(),
|
||||||
|
title:
|
||||||
|
msgs.length === 1
|
||||||
|
? msgs[0].content.slice(0, 20)
|
||||||
|
: s.title,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
|
if (!inputValue.trim() || !selectedAgentId || isSending) return;
|
||||||
|
if (!currentSessionId) createSession();
|
||||||
|
|
||||||
|
const userMsg: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: 'user',
|
||||||
|
content: inputValue.trim(),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const newMessages = [...messages, userMsg];
|
||||||
|
setMessages(newMessages);
|
||||||
|
setInputValue('');
|
||||||
|
setIsSending(true);
|
||||||
|
|
||||||
|
// 记住上次选择的 Agent
|
||||||
|
localStorage.setItem('ai_agent_last_agent', selectedAgentId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取或创建后端会话
|
||||||
|
let sessionId = currentSession?.backendSessionId;
|
||||||
|
if (!sessionId) {
|
||||||
|
const res = await agentApi.createSession(selectedAgentId, currentUser);
|
||||||
|
sessionId = res.data.sessionId;
|
||||||
|
setSessions(prev =>
|
||||||
|
prev.map(s =>
|
||||||
|
s.id === (currentSessionId || sessions[0]?.id)
|
||||||
|
? { ...s, backendSessionId: sessionId }
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
const res = await agentApi.chat({
|
||||||
|
agentId: selectedAgentId,
|
||||||
|
userId: currentUser,
|
||||||
|
sessionId: sessionId!,
|
||||||
|
message: userMsg.content,
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentMsg: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
role: 'agent',
|
||||||
|
content: res.data.content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = [...newMessages, agentMsg];
|
||||||
|
setMessages(updated);
|
||||||
|
updateSessionMessages(currentSessionId || sessions[0]?.id, updated);
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
role: 'agent',
|
||||||
|
content: `错误:${err instanceof Error ? err.message : '请求失败'}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
const updated = [...newMessages, errMsg];
|
||||||
|
setMessages(updated);
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
clearUserInfo();
|
||||||
|
router.push('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex bg-slate-900 text-white">
|
||||||
|
{/* 左侧栏 — 会话列表 */}
|
||||||
|
<div className="w-64 bg-slate-800 border-r border-slate-700 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-slate-700">
|
||||||
|
<button
|
||||||
|
onClick={createSession}
|
||||||
|
className="w-full py-2 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg transition text-sm"
|
||||||
|
>
|
||||||
|
+ 新建对话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||||
|
{sessions.map(s => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => switchSession(s.id)}
|
||||||
|
className={`group flex items-center justify-between p-3 rounded-lg cursor-pointer transition ${
|
||||||
|
s.id === currentSessionId
|
||||||
|
? 'bg-slate-700'
|
||||||
|
: 'hover:bg-slate-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm truncate flex-1">{s.title}</span>
|
||||||
|
<button
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteSession(s.id);
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-400 transition ml-2"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主聊天区域 */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{/* 顶部栏 */}
|
||||||
|
<div className="h-14 bg-slate-800 border-b border-slate-700 flex items-center justify-between px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-lg font-semibold">AI 智能体</span>
|
||||||
|
<select
|
||||||
|
value={selectedAgentId}
|
||||||
|
onChange={e => setSelectedAgentId(e.target.value)}
|
||||||
|
className="bg-slate-700 text-sm text-slate-300 px-3 py-1 rounded-lg border border-slate-600 focus:outline-none"
|
||||||
|
>
|
||||||
|
{agents.map(a => (
|
||||||
|
<option key={a.agentId} value={a.agentId}>
|
||||||
|
{a.agentName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-slate-400">{currentUser}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-sm text-slate-400 hover:text-white transition"
|
||||||
|
>
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息列表 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="text-center text-slate-500 mt-20">
|
||||||
|
<p className="text-4xl mb-4">💬</p>
|
||||||
|
<p>选择一个 Agent,开始对话</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map(msg => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`flex ${
|
||||||
|
msg.role === 'user' ? 'justify-end' : 'justify-start'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[70%] rounded-2xl px-4 py-3 ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-emerald-600 text-white'
|
||||||
|
: 'bg-slate-700 text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{msg.content}</p>
|
||||||
|
<p className="text-xs mt-1 opacity-50">
|
||||||
|
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isSending && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="bg-slate-700 rounded-2xl px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
||||||
|
<span className="animate-spin">⏳</span> 思考中…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 输入区域 */}
|
||||||
|
<div className="p-4 bg-slate-800 border-t border-slate-700">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<textarea
|
||||||
|
value={inputValue}
|
||||||
|
onChange={e => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
rows={1}
|
||||||
|
placeholder="输入消息… (Enter 发送, Shift+Enter 换行)"
|
||||||
|
className="flex-1 px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-emerald-400 transition resize-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={sendMessage}
|
||||||
|
disabled={isSending || !inputValue.trim()}
|
||||||
|
className="px-6 py-3 bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl transition font-medium"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend/src/config/api-config.ts
Normal file
7
frontend/src/config/api-config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const API_CONFIG = {
|
||||||
|
// 优先使用运行时注入的 window.__ENV,其次使用构建时的环境变量,最后回退到默认值
|
||||||
|
BASE_URL:
|
||||||
|
(typeof window !== "undefined" && window.__ENV?.NEXT_PUBLIC_API_BASE_URL)
|
||||||
|
? window.__ENV.NEXT_PUBLIC_API_BASE_URL
|
||||||
|
: (process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8091/api/v1"),
|
||||||
|
};
|
||||||
31
frontend/src/types/api.ts
Normal file
31
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export interface Response<T> {
|
||||||
|
code: string;
|
||||||
|
info: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiAgentConfigResponseDTO {
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
agentDesc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionRequestDTO {
|
||||||
|
agentId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionResponseDTO {
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatRequestDTO {
|
||||||
|
agentId: string;
|
||||||
|
userId: string;
|
||||||
|
sessionId: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatResponseDTO {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
9
frontend/src/types/env.d.ts
vendored
Normal file
9
frontend/src/types/env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__ENV?: {
|
||||||
|
NEXT_PUBLIC_API_BASE_URL: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
40
frontend/src/utils/cookie.ts
Normal file
40
frontend/src/utils/cookie.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const COOKIE_NAME = "ai_agent_login";
|
||||||
|
const COOKIE_DAYS = 7;
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
user: string;
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookie(name: string, value: string, days: number) {
|
||||||
|
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||||
|
document.cookie = `${name}=${encodeURIComponent(value)};expires=${expires};path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name: string): string | null {
|
||||||
|
const match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)"));
|
||||||
|
return match ? decodeURIComponent(match[2]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCookie(name: string) {
|
||||||
|
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUserInfo(user: string) {
|
||||||
|
const info: UserInfo = { user, ts: Date.now() };
|
||||||
|
setCookie(COOKIE_NAME, JSON.stringify(info), COOKIE_DAYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserInfo(): UserInfo | null {
|
||||||
|
const raw = getCookie(COOKIE_NAME);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as UserInfo;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearUserInfo() {
|
||||||
|
deleteCookie(COOKIE_NAME);
|
||||||
|
}
|
||||||
34
frontend/tsconfig.json
Normal file
34
frontend/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
package config
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package config
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package handler
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package llm
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package llm
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package model
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package model
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package service
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package service
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package service
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package service
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package types
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package types
|
|
||||||
Reference in New Issue
Block a user