refactor: 重构目录结构,后端代码统一到 backend/ 目录
All checks were successful
GoLoom CI / Lint (push) Successful in 3m7s
GoLoom CI / Test (push) Successful in 5s
GoLoom CI / Build (push) Successful in 24s

This commit is contained in:
hhs
2026-06-10 15:35:32 +08:00
parent caada15831
commit 8c515a8b3e
23 changed files with 13 additions and 4 deletions

2
backend/.env.example Normal file
View 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
View 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)
}
}
}

View 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"

View 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

40
backend/go.mod Normal file
View File

@@ -0,0 +1,40 @@
module ai-agent-scaffold-go
go 1.26.2
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/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.12.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/joho/godotenv v1.5.1 // 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/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
go.uber.org/zap v1.28.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
gopkg.in/yaml.v3 v3.0.1 // indirect
)

85
backend/go.sum Normal file
View File

@@ -0,0 +1,85 @@
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/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/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/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/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/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/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/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/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=
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/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=

View 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
}

View 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
}

View 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()})
}

View 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
}

View 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"`
}

View 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
}

View 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"`
}

View 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
}

View 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
}

View File

@@ -0,0 +1,422 @@
package service
import (
"context"
"fmt"
"strings"
"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) {
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))
}
return strings.Join(parts, "\n"), nil
}
func (a *ParallelAgent) 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()
}
}
// ============================================================
// 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
}

View 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)
}

View 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
}

View 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
}

View 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"
)

View 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
}