Compare commits
11 Commits
9d04b0b200
...
8c515a8b3e
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c515a8b3e | |||
| caada15831 | |||
| eb0061b644 | |||
| 60f183b8e4 | |||
| 5e3da508cf | |||
| 3041ce770f | |||
| a6667e0a33 | |||
| 0662165c77 | |||
| cdbaefecf8 | |||
| f532b21a7a | |||
| 658866a661 |
@@ -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:
|
||||
GO_VERSION: '1.26'
|
||||
GOPROXY: 'https://goproxy.cn,direct'
|
||||
GOLANGCI_LINT_VERSION: 'v1.57.2'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -22,19 +26,10 @@ jobs:
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Cache golangci-lint
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/local/bin/golangci-lint
|
||||
key: golangci-lint-${{ env.GOLANGCI_LINT_VERSION }}
|
||||
cache: false
|
||||
|
||||
- name: Install golangci-lint
|
||||
run: |
|
||||
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
|
||||
run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin ${{ env.GOLANGCI_LINT_VERSION }}
|
||||
|
||||
- name: Run golangci-lint
|
||||
run: golangci-lint run --timeout=5m --issues-exit-code=0
|
||||
@@ -42,6 +37,9 @@ jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -50,7 +48,7 @@ jobs:
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
cache: false
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
@@ -66,12 +64,15 @@ jobs:
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage.out
|
||||
path: backend/coverage.out
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -80,7 +81,7 @@ jobs:
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
cache: false
|
||||
|
||||
- name: Build binary
|
||||
run: |
|
||||
@@ -91,7 +92,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: goloom-server
|
||||
path: goloom-server
|
||||
path: backend/goloom-server
|
||||
|
||||
# TODO: 添加 Dockerfile 后启用
|
||||
# docker:
|
||||
|
||||
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/
|
||||
CLAUDE.md
|
||||
|
||||
# Claude
|
||||
CLAUDE.md
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
frontend/out/
|
||||
|
||||
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
|
||||
40
backend/go.mod
Normal file
40
backend/go.mod
Normal 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
85
backend/go.sum
Normal 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=
|
||||
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()})
|
||||
}
|
||||
422
backend/internal/service/agent.go
Normal file
422
backend/internal/service/agent.go
Normal 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
5
go.mod
5
go.mod
@@ -1,5 +0,0 @@
|
||||
module ai-agent-scaffold-go
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
3
go.sum
3
go.sum
@@ -1,3 +0,0 @@
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1 +0,0 @@
|
||||
package handler
|
||||
@@ -1 +0,0 @@
|
||||
package service
|
||||
@@ -1 +0,0 @@
|
||||
package service
|
||||
@@ -1 +0,0 @@
|
||||
package service
|
||||
@@ -1 +0,0 @@
|
||||
package service
|
||||
Reference in New Issue
Block a user