feat(project): 项目完结

This commit is contained in:
peakxy
2026-05-30 23:16:49 +08:00
commit 60e7777cbd
97 changed files with 15027 additions and 0 deletions

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# Application 基础环境
APP_ENV=local
SERVER_ADDR=:8091
# 本地基础设施(与 deployments/docker-compose.yml 默认端口一致)
MYSQL_DSN=root:123456@tcp(127.0.0.1:13306)/ai_agent_scaffold_go?charset=utf8mb4&parseTime=True&loc=Local
REDIS_ADDR=127.0.0.1:16379
# OpenAI 兼容上游(含中转站)
# base-url 末尾可带或不带斜杠,代码会自动处理
# OPENAI_BASE_URL=https://api.openai.com/
# OPENAI_API_KEY=replace-me
# 中转站推荐https://codez.zwenooo.link/register?aff=c3yW
OPENAI_BASE_URL=https://api-s.abcd.link/
OPENAI_API_KEY=sk-A7B5Wv41qabcdefg**********7yoCxlcU
# 百度搜索 MCPSSE 模式)
# base-uri保留到 /mcp/ 这一层sse-endpoint 是相对路径
# sse-endpoint可携带 ?api_key=... 这种 query保持原样从这里注入
# BAIDU_SEARCH_MCP_BASE_URI=http://appbuilder.baidu.com/v2/ai_search/mcp/
# BAIDU_SEARCH_MCP_SSE_ENDPOINT=sse?api_key=replace-me
BAIDU_SEARCH_MCP_BASE_URI=http://appbuilder.baidu.com/v2/ai_search/mcp/
BAIDU_SEARCH_MCP_SSE_ENDPOINT=sse?api_key=bce-v3/ALTAK-wSdZlzasdfghjkl**********

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# Local secret env files
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
# Build output
/bin/
*.test
*.out
# Frontend artefacts
frontend/.next/
frontend/node_modules/

394
README.md Normal file
View File

@@ -0,0 +1,394 @@
# AI Agent Scaffold Go
[![Go](https://img.shields.io/badge/Go-1.25.6+-00ADD8?style=flat-square&logo=go)](https://go.dev/)
[![Gin](https://img.shields.io/badge/Gin-HTTP-00ACD7?style=flat-square)](https://gin-gonic.com/)
[![Eino](https://img.shields.io/badge/Eino-Agent-111827?style=flat-square)](https://github.com/cloudwego/eino)
[![ADK Go](https://img.shields.io/badge/Google%20ADK-Go-4285F4?style=flat-square)](https://github.com/google/adk-go)
[![GORM](https://img.shields.io/badge/GORM-MySQL-2D3748?style=flat-square)](https://gorm.io/)
[![Redis](https://img.shields.io/badge/Redis-Session-DC382D?style=flat-square&logo=redis)](https://redis.io/)
[![Next.js](https://img.shields.io/badge/Next.js-Frontend-000000?style=flat-square&logo=nextdotjs)](https://nextjs.org/)
AI Agent Scaffold Go 是一个面向 Agent 应用开发的 Go 脚手架,围绕 Gin、Eino、Google ADK Go、GORM、MySQL、Redis 构建,重点提供清晰的 DDD 分层、配置驱动的 Agent 装配、多 Agent 工作流编排和 HTTP 运行时接口。仓库同时附带一个基于 Next.js 的 `frontend/` 子项目,作为对接后端 API 的示例前端这个场景是通过drawio组件搭建的旨在通过多轮对话来画出理想中的流程图。
这个项目适合作为 Agent 平台、智能助手后端、工作流 Agent 服务的起点。它把模型、工具、技能、Agent、Workflow、Runner 的装配逻辑收敛在领域层,通过端口隔离基础设施实现,让核心业务代码不直接依赖具体 SDK 或框架。
## 目录
- [核心特性](#核心特性)
- [技术栈](#技术栈)
- [项目结构](#项目结构)
- [架构设计](#架构设计)
- [Armory 装配流程](#armory-装配流程)
- [快速开始](#快速开始)
- [配置说明](#配置说明)
- [HTTP API](#http-api)
- [开发命令](#开发命令)
- [当前实现状态](#当前实现状态)
- [路线图](#路线图)
- [Star 趋势](#star-趋势)
- [许可证](#许可证)
## 核心特性
- **配置驱动 Agent**:通过 YAML 定义模型 API、ChatModel、工具、技能、单 Agent、Workflow 和 Runner。
- **多 Agent 工作流**:支持 `loop``parallel``sequential` 三类工作流 Agent 组装。
- **Armory 规则树装配**:使用 `Root -> AiApi -> ChatModel -> Agent -> AgentWorkflow -> Runner` 的显式节点链路构建运行时 Agent。
- **DDD 六层结构**:将 API 契约、应用启动、领域模型、触发器、基础设施和通用类型分离。
- **端口隔离基础设施**:领域层依赖本地 ports不直接耦合 Gin、GORM、Redis、Eino、ADK Go 等实现细节。
- **HTTP 运行时接口**:提供查询 Agent、创建会话POST/GET、同步对话、流式对话四类接口。
- **本地开发资产**包含示例配置、MySQL/Redis Docker Compose、环境变量模板和可一键启动的 Next.js 示例前端。
## 技术栈
| 领域 | 选型 |
| --- | --- |
| 后端语言 | Go 1.25.6+ |
| HTTP 框架 | Gin |
| Agent / 模型封装 | Eino |
| Agent 编排 | Google ADK Go |
| 持久化 | GORM + MySQL |
| 缓存 / 会话 | Redis |
| 配置 | YAML + 环境变量 |
| 日志 | zap |
| 前端 | Next.js + React + TailwindCSS |
## 项目结构
```text
ai-agent-scaffold-go
├── cmd/server # 服务入口,当前负责启动日志初始化
├── configs
│ ├── application.yaml # 应用、服务端口、MySQL、Redis、Agent 配置入口
│ └── agent
│ ├── only-one-agent.yaml # 单 Agent 示例配置(含 MCP 与 skills
│ ├── agent-draw-io.yaml # draw.io Agent 示例配置
│ └── skills/ # 内置 skill 资源battle-plan、pdf 等)
├── deployments
│ └── docker-compose.yml # 本地 MySQL / Redis
├── frontend # Next.js 示例前端,调用后端 /api/v1
├── internal
│ ├── api # DTO 与统一响应封装
│ ├── app # 应用装配与配置加载边界
│ ├── domain
│ │ ├── agent
│ │ │ ├── model # Agent 配置、聊天命令、Runner 等领域模型
│ │ │ ├── ports # 模型、工具、Agent、Runner、Registry 等领域端口
│ │ │ └── service
│ │ │ ├── armory # Agent 装配领域服务与核心节点
│ │ │ │ ├── factory # 默认装配工厂
│ │ │ │ └── workflow # loop / parallel / sequential 工作流节点
│ │ │ └── chat # 会话与聊天运行时服务
│ │ └── shared/tree # 泛型策略树路由框架
│ ├── infrastructure # Eino、ADK、MySQL、Redis、日志等适配器
│ └── trigger/http # Gin HTTP 入站适配器
├── pkg/types # 响应码与应用错误
├── .env.example # 环境变量示例
├── go.mod
└── README.md
```
## 架构设计
```mermaid
flowchart LR
Client["客户端 / frontend"] --> Trigger["trigger/http<br/>Gin 路由"]
Trigger --> ChatService["domain/service/chat<br/>聊天服务"]
ChatService --> Registry["AgentRegistry"]
Registry --> Runner["Runner"]
Config["configs/*.yaml"] --> App["internal/app<br/>配置加载与应用装配"]
App --> Armory["domain/service/armory<br/>Agent 装配"]
Armory --> Registry
Armory --> Ports["domain/agent/ports"]
ChatService --> Ports
Ports --> Infra["internal/infrastructure"]
Infra --> Eino["Eino"]
Infra --> ADK["Google ADK Go"]
Infra --> MySQL["MySQL / GORM"]
Infra --> Redis["Redis"]
```
### 分层边界
- `internal/api`:请求/响应 DTO 与统一响应结构。
- `internal/app`:应用启动、配置加载、服务装配边界。
- `internal/domain`领域模型、端口、Armory 装配、聊天运行时、策略树框架。
- `internal/trigger`HTTP 等入站触发器。
- `internal/infrastructure`数据库、缓存、AI SDK、日志等外部依赖适配。
- `pkg/types`:跨层可复用的错误码和应用错误。
领域层保持稳定,不直接导入 Gin、GORM、Redis、Eino、ADK Go 或 provider-specific 的基础设施包。
## Armory 装配流程
Armory 是项目里的 Agent 装配链路。它接收 Agent 配置表按节点顺序构建模型、工具、Agent、Workflow 和 Runner。
```mermaid
flowchart TD
Root["RootNode<br/>装配入口"] --> AiApi["AiAPINode<br/>创建模型 API"]
AiApi --> ChatModel["ChatModelNode<br/>创建 ChatModel 并挂载工具"]
ChatModel --> Agent["AgentNode<br/>创建单 Agent"]
Agent --> Workflow["workflow.AgentWorkflowNode<br/>创建工作流 Agent"]
Workflow --> Runner["RunnerNode<br/>创建并注册 Runner"]
```
最新代码已经按职责拆分:
```text
internal/domain/agent/service/armory
├── root_node.go
├── ai_api_node.go
├── chat_model_node.go
├── agent_node.go
├── runner_node.go
├── factory/factory.go
└── workflow
├── agent_workflow_node.go
├── loop_node.go
├── parallel_node.go
└── sequential_node.go
```
Workflow 支持三种编排方式:
- `loop`:循环执行子 Agent支持最大迭代次数配置。
- `parallel`:并行组合多个子 Agent。
- `sequential`:按顺序串联单 Agent 或已装配的 Workflow Agent。
## 快速开始
### 环境要求
- Go 1.25.6+
- Node.js 18+ 与 npm仅在启动 `frontend/` 时需要)
- Docker可选用于本地 MySQL / Redis
### 获取代码并编译
```bash
git clone <repo-url>
cd ai-agent-scaffold-go
go mod tidy
go build ./...
```
### 启动后端服务入口
```bash
go run ./cmd/server
```
当前 `cmd/server` 会完成日志初始化并输出启动日志。完整运行时装配、Armory 初始化、Gin 路由挂载等能力已经按包结构准备好,后续可以继续在入口层串接。
### 启动本地基础设施
```bash
docker compose -f deployments/docker-compose.yml up -d
```
默认端口:
- MySQL`127.0.0.1:13306`
- Redis`127.0.0.1:16379`
### 启动前端
仓库内置一个 Next.js 示例前端,默认调用后端 `http://localhost:8091/api/v1`。最小启动方式:
```bash
cd frontend
npm install
npm run dev
```
访问:
```text
http://localhost:3000
```
更多前端使用细节见 `frontend/README.md`
## 配置说明
应用主配置:
```text
configs/application.yaml
```
Agent 示例配置:
```text
configs/agent/only-one-agent.yaml
configs/agent/agent-draw-io.yaml
```
环境变量示例:
```text
.env.example
```
`configs/application.yaml` 会声明服务端口、本地数据库、Redis 和 Agent 配置路径:
```yaml
app:
name: ai-agent-scaffold-go
env: local
server:
addr: ":8091"
database:
required: false
dsn: "root:123456@tcp(127.0.0.1:13306)/ai_agent_scaffold_go?charset=utf8mb4&parseTime=True&loc=Local"
redis:
required: false
addr: "127.0.0.1:16379"
agent:
config-paths:
- configs/agent/only-one-agent.yaml
```
Agent 配置示例(节选自 `configs/agent/only-one-agent.yaml`
```yaml
ai:
agent:
config:
tables:
testAgent03:
app-name: testAgent03
agent:
agent-id: "100003"
agent-name: "single agent"
agent-desc: "single agent demo"
module:
ai-api:
base-url: "https://apis.itedus.cn"
api-key: "${OPENAI_API_KEY}"
completions-path: "v1/chat/completions"
embeddings-path: "v1/embeddings"
chat-model:
model: "gpt-4.1"
tool-mcp-list:
- sse:
name: baidu-search
base-uri: http://appbuilder.baidu.com
sse-endpoint: /v2/ai_search/mcp/sse?api_key=${BAIDU_SEARCH_MCP_API_KEY}
request-timeout: 500000
tool-skills-list:
- type: "resource"
path: "agent/skills"
agents:
- name: "onlyAgent"
description: "study plan helper"
instruction: |
Build a beginner-friendly study plan from the user's request.
runner:
agent-name: "onlyAgent"
plugin-name-list:
- "myTestPlugin"
- "myLogPlugin"
```
连接真实模型服务前,需要复制 `.env.example` 并设置真实的模型 API Key。
## HTTP API
基础路径:
```text
/api/v1
```
这些路由由 `internal/trigger/http/agent_handler.go` 中的 `RegisterAgentRoutes` 注册。当前服务入口还没有把 Gin 路由完整挂到 `cmd/server`,接入时可复用 `RegisterAgentRoutes(router, chatService)`
### 查询 Agent 配置
```bash
curl http://localhost:8091/api/v1/query_ai_agent_config_list
```
### 创建会话
```bash
curl -X POST http://localhost:8091/api/v1/create_session \
-H 'Content-Type: application/json' \
-d '{"agentId":"100003","userId":"u1001"}'
```
也支持 GET
```bash
curl 'http://localhost:8091/api/v1/create_session?agentId=100003&userId=u1001'
```
### 同步对话
```bash
curl -X POST http://localhost:8091/api/v1/chat \
-H 'Content-Type: application/json' \
-d '{"agentId":"100003","userId":"u1001","message":"帮我制定一个学习计划"}'
```
### 流式对话
`/chat_stream` 通过 SSE`text/event-stream`)推送结果,每个分片以 `event: message` 形式发送,错误以 `event: error` 结束。
```bash
curl -N -X POST http://localhost:8091/api/v1/chat_stream \
-H 'Content-Type: application/json' \
-d '{"agentId":"100003","userId":"u1001","sessionId":"session-u1001","message":"继续"}'
```
## 开发命令
格式化:
```bash
gofmt -w .
```
编译:
```bash
go build ./...
```
启动前端开发服务器:
```bash
cd frontend && npm run dev
```
## 当前实现状态
- DDD 包结构、领域模型、端口、策略树和 Armory 装配链路已经建立。
- Armory 核心节点、工厂、Workflow 节点已按职责拆分。
- ChatService、AgentRegistry、SessionStore 和 HTTP 路由边界已在代码结构中分离。
- Eino、ADK Go、GORM、Redis、zap 等依赖已经纳入模块,并通过本地端口和基础设施包隔离。
- `cmd/server` 已串接配置加载、Armory 装配、Gin 路由和插件链路,启动后即对外提供 `/api/v1` 接口。
- Runner 已接入真实 OpenAI 兼容 ChatModel`/api/v1/chat``chat/completions` 同步接口,`/api/v1/chat_stream``stream: true` SSE 推送 delta。
- SSE MCP 客户端可用:装配阶段连接 `tool-mcp-list[].sse`,运行时模型选中工具后真正发起 JSON-RPC `tools/call` 并把结果回灌给下一轮模型请求,工具调用循环上限为 4 轮。
- Local MCP、stdio MCP 仍返回 unsupported 错误Skill 工具当前只向模型暴露名字,不参与执行。
- `frontend/` 提供基于 Next.js 的 draw.io 示例前端,默认对接后端 `/api/v1`
## 路线图
- 接入更稳定的模型 backoff / 重试与请求级超时控制。
- 补齐 stdio MCP 客户端实现,扩展 local MCP 真实执行入口。
- 让 Skill 工具具备运行时调用能力,并把 skill 元数据注入模型 tool schema。
- 增加基于 MySQL 的 Agent 配置仓储。
- 增加基于 Redis 的分布式会话存储。
- 增加请求追踪、Token 用量、Agent Workflow 事件等可观测能力。
## Star 趋势
[![Star History Chart](https://api.star-history.com/svg?repos=peakxy/ai-agent-scaffold-go&type=Date)](https://star-history.com/#peakxy/ai-agent-scaffold-go&Date)
## 许可证
## 联系方式
- 邮箱: 2465549609@qq.com

87
cmd/server/main.go Normal file
View File

@@ -0,0 +1,87 @@
package main
import (
"context"
"flag"
"log"
"os"
"ai-agent-scaffold-go/internal/app/bootstrap"
"ai-agent-scaffold-go/internal/app/config"
"ai-agent-scaffold-go/internal/infrastructure/logging"
"github.com/joho/godotenv"
"go.uber.org/zap"
)
func main() {
envPath := flag.String("env", defaultEnv("APP_ENV_FILE", ".env"), "path to dotenv file (use empty string to skip)")
configPath := flag.String("config", defaultEnv("APP_CONFIG", "configs/application.yaml"), "path to application.yaml")
flag.Parse()
envLoaded := loadDotenv(*envPath)
appCfg, err := config.LoadApplication(*configPath)
if err != nil {
log.Fatalf("load application config: %v", err)
}
logger, err := logging.New(appCfg.App.Env)
if err != nil {
log.Fatalf("init logger: %v", err)
}
defer func() {
_ = logger.Sync()
}()
logger.Info("ai-agent-scaffold-go bootstrap",
zap.String("config", *configPath),
zap.String("env", appCfg.App.Env),
zap.String("addr", appCfg.Server.Addr),
zap.String("env_file", envLoaded),
)
engine, err := bootstrap.Build(context.Background(), appCfg, logger)
if err != nil {
logger.Fatal("bootstrap failed", zap.Error(err))
}
if err := engine.Run(); err != nil {
logger.Fatal("http server stopped", zap.Error(err))
}
}
func defaultEnv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
// loadDotenv loads variables from a dotenv file if it exists. It returns the
// resolved path (or "" if no file was loaded). Existing process environment
// variables always win over file values, so callers can override locally.
func loadDotenv(path string) string {
if path == "" {
return ""
}
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return ""
}
log.Fatalf("stat dotenv %s: %v", path, err)
}
pairs, err := godotenv.Read(path)
if err != nil {
log.Fatalf("read dotenv %s: %v", path, err)
}
for key, value := range pairs {
if _, exists := os.LookupEnv(key); exists {
continue
}
if err := os.Setenv(key, value); err != nil {
log.Fatalf("set env %s: %v", key, err)
}
}
return path
}

View File

@@ -0,0 +1,103 @@
ai:
agent:
config:
tables:
drawIoAgent:
app-name: drawIoAgent
agent:
agent-id: "300000"
agent-name: "AI交互式绘图智能体"
agent-desc: "人 + AI + Draw.io交互式对话完成 draw.io 绘图。"
module:
ai-api:
base-url: ${OPENAI_BASE_URL}
api-key: ${OPENAI_API_KEY}
completions-path: v1/chat/completions
embeddings-path: v1/embeddings
chat-model:
model: "gpt-5.5"
tool-mcp-list:
- sse:
name: baidu-search
base-uri: ${BAIDU_SEARCH_MCP_BASE_URI}
sse-endpoint: ${BAIDU_SEARCH_MCP_SSE_ENDPOINT}
request-timeout: 500000
tool-skills-list:
- type: "resource"
path: "agent/skills"
agents:
# 1. 需求分析与检索智能体
- name: agent_analyst
description: 负责理解用户意图,调用工具检索信息,并整理出可用于绘图的需求描述。
instruction: |
你是一个专业的绘图需求分析师。请基于用户最新的输入,输出一段中文文本,描述要绘制的图表。
要求:
1. 如有需要可调用可用工具MCP补充信息但不要把工具调用结果原样贴出。
2. 即便用户描述较简略,也要根据常识合理补全,不要要求用户继续补充信息。
3. 输出必须是单段纯文本,明确包含:图表类型(流程图 / 时序图 / 类图 等)、关键节点、节点之间的关系或调用顺序、必要的布局提示。
4. 严禁输出 JSON、Markdown、代码块或 XML只输出自然语言描述。
output-key: analysis_result
# 2. 绘图执行智能体
- name: agent_drawer
description: 根据分析结果生成 Draw.io 的 mxfile XML 数据。
instruction: |
你是一个 Draw.io 绘图专家。下面是上一步整理好的绘图需求:
---
{analysis_result}
---
请只输出一份合法的 draw.io XML要求
1. 必须以 `<mxfile` 开头,以 `</mxfile>` 结尾,包含 `<diagram>` 与 `<mxGraphModel>`。
2. 节点mxCell vertex="1")有合适的 `geometry` 坐标和大小,避免重叠。
3. 连线mxCell edge="1")使用 `source` 与 `target` 引用节点 id逻辑顺序清晰避免明显交叉。
4. 严禁输出任何 JSON、Markdown、代码块标记```),不要写解释文字,输出从 `<mxfile` 第一个字符开始。
output-key: draft_diagram
# 3. 检查与优化智能体
- name: agent_reviewer
description: 检查绘图结果,修正语法问题,输出最终的裸 XML。
instruction: |
你是一个 draw.io XML 质量检查员。下面是上一步生成的 XML
---
{draft_diagram}
---
请按以下规则输出最终结果:
1. 检查 XML 是否合法标签闭合、属性正确、id 与 source/target 一致),有问题就直接修正。
2. 如发现节点明显重叠或连线交叉,可微调 geometry 让布局更整洁,但不要新增/删除核心节点。
3. 最终输出必须只有这份合法的 draw.io XML必须以 `<mxfile` 开头,以 `</mxfile>` 结尾。
4. 严禁输出任何 JSON、Markdown、代码块标记或解释文字。如果发现输入完全不像 XML则按需求自行重写一份合法的最简 mxfile XML 后输出。
output-key: final_result
agent-workflows:
# 案例;定义一个循环工作流(示例),用于反复优化绘图(需要在 instruction 中支持反馈机制才能生效)
- type: loop
name: loop_refinement
description: 循环优化绘图结果
max-iterations: 3
sub-agents:
- agent_drawer
- agent_reviewer
# 案例;定义一个并行工作流(示例),可以并行生成多个方案(需要后续有 Agent 进行选择)
- type: parallel
name: parallel_generation
description: 并行生成多个绘图方案
sub-agents:
- agent_drawer
- agent_drawer
# 对外;定义一个串行工作流,按顺序执行分析、绘图、检查
- type: sequential
name: sequential_draw_process
description: 标准绘图流程:分析 -> 绘图 -> 检查
sub-agents:
- agent_analyst
- agent_drawer
- agent_reviewer
runner:
# 指定入口为串行工作流,这是最稳健的配置
agent-name: sequential_draw_process
plugin-name-list:
- myTestPlugin
- myLogPlugin

View File

@@ -0,0 +1,37 @@
ai:
agent:
config:
tables:
testAgent03:
app-name: testAgent03
agent:
agent-id: "100003"
agent-name: "single agent"
agent-desc: "single agent demo"
module:
ai-api:
base-url: ${OPENAI_BASE_URL}
api-key: ${OPENAI_API_KEY}
completions-path: "v1/chat/completions"
embeddings-path: "v1/embeddings"
chat-model:
model: "gpt-5.5"
tool-mcp-list:
- sse:
name: baidu-search
base-uri: ${BAIDU_SEARCH_MCP_BASE_URI}
sse-endpoint: ${BAIDU_SEARCH_MCP_SSE_ENDPOINT}
request-timeout: 500000
tool-skills-list:
- type: "resource"
path: "agent/skills"
agents:
- name: "onlyAgent"
description: "study plan helper"
instruction: |
Build a beginner-friendly study plan from the user's request.
runner:
agent-name: "onlyAgent"
plugin-name-list:
- "myTestPlugin"
- "myLogPlugin"

View File

@@ -0,0 +1,10 @@
---
name: battle-plan
description: 电脑性能优化
license: MIT
---
你需要执行 ```./scripts/get_system_info.sh``` 脚本获取当前电脑的配置包括用户名、操作系统、CPU、内存、磁盘等信息。之后执行 ```./scripts/check_cleanable_files.sh``` 脚本,列举出 macOS 系统中常见的垃圾文件和可清理内容,并显示占用空间大小。
在获取以上信息之后,你要以 reference.md 文档的话术结构,对当前电脑的用户名,下达优化建议命令。如,小傅哥你记一下(小傅哥,要用你获得的电脑用户名替换)我做xxx部署调整的文案让用户告知的方式来优化电脑性能。描述的话术一定要和 reference.md 文档的话术结构保持一致,有种下达命令的体验。

View File

@@ -0,0 +1 @@
刘亚楼,你记一下,我作如下部署调整——以四纵、十一纵加两个独立师,强化塔山防线;二、三、七、八、九,五个纵队加六纵十七师,包打锦州;十纵加一个师在黑山大虎山一线阻击廖耀湘兵团;十二纵加十二个独立师,围团长春;五纵、六纵两个师监视沈阳;一纵作总预备队。给我复述一遍!

View File

@@ -0,0 +1,100 @@
#!/bin/bash
# check_cleanable_files.sh
# 脚本功能:列举 macOS 系统中常见的垃圾文件和可清理内容,并显示占用空间大小。
# 注意:此脚本仅进行扫描和列举,不会删除任何文件。
echo "============================================================"
echo " macOS 系统可清理垃圾文件扫描 "
echo "============================================================"
echo "正在扫描,请稍候..."
echo ""
# 定义颜色
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 函数:检查并打印目录大小
check_dir_size() {
local name="$1"
local path="$2"
local desc="$3"
if [ -d "$path" ]; then
# 使用 du -sh 计算大小,并提取数值
# 2>/dev/null 屏蔽权限错误
size=$(du -sh "$path" 2>/dev/null | cut -f1)
# 如果目录为空或无法读取大小,可能显示为 0B 或空
if [ -n "$size" ]; then
echo -e "${YELLOW}[$name]${NC}"
echo -e " 路径: $path"
echo -e " 大小: ${RED}$size${NC}"
echo -e " 说明: $desc"
echo "------------------------------------------------------------"
fi
fi
}
# 1. 用户缓存
check_dir_size "用户缓存" "$HOME/Library/Caches" "应用程序产生的临时文件,通常可以安全清理(可能会导致应用重新加载数据变慢)。"
# 2. 系统日志
check_dir_size "用户日志" "$HOME/Library/Logs" "应用程序的日志文件,如果不需要排查问题,通常可以清理。"
check_dir_size "系统日志" "/private/var/log" "系统运行日志,通常由系统自动管理,但积压过多时可清理旧日志。"
# 3. 废纸篓
check_dir_size "废纸篓" "$HOME/.Trash" "已删除但未清空的文件。"
# 4. Xcode 开发垃圾 (如果存在)
check_dir_size "Xcode DerivedData" "$HOME/Library/Developer/Xcode/DerivedData" "Xcode 编译产生的中间文件和索引,删除后下次编译会重新生成(可解决很多 Xcode 报错问题)。"
check_dir_size "Xcode iOS DeviceSupport" "$HOME/Library/Developer/Xcode/iOS DeviceSupport" "连接过的旧 iOS 设备支持文件,如果不再调试旧版本 iOS可以清理。"
check_dir_size "Xcode Archives" "$HOME/Library/Developer/Xcode/Archives" "打包发布的 App 归档,如果确认不再需要旧版本的包,可以清理。"
# 5. 浏览器缓存 (部分示例)
check_dir_size "Chrome 缓存" "$HOME/Library/Caches/Google/Chrome" "Chrome 浏览器的缓存文件。"
# Firefox
check_dir_size "Firefox 缓存" "$HOME/Library/Caches/Firefox" "Firefox 浏览器的缓存文件。"
# 6. 包管理器缓存
# Homebrew
if command -v brew &> /dev/null; then
brew_cache=$(brew --cache)
check_dir_size "Homebrew 缓存" "$brew_cache" "Homebrew 下载的安装包缓存,可通过 'brew cleanup' 清理。"
fi
# 7. 语言环境缓存/依赖
check_dir_size "Yarn 缓存" "$HOME/Library/Caches/Yarn" "Yarn 包管理器缓存。"
check_dir_size "npm 缓存" "$HOME/.npm" "npm 包管理器缓存。"
check_dir_size "Maven 缓存" "$HOME/.m2/repository" "Maven 仓库,虽然不是垃圾,但如果很久不用,可能占用大量空间。"
check_dir_size "Gradle 缓存" "$HOME/.gradle/caches" "Gradle 构建缓存。"
check_dir_size "CocoaPods 缓存" "$HOME/Library/Caches/CocoaPods" "CocoaPods 依赖缓存。"
# 8. Docker (如果运行)
if command -v docker &> /dev/null; then
echo -e "${YELLOW}[Docker 未使用资源]${NC}"
echo -e " 说明: 停止的容器、未使用的镜像和网络。"
echo -e " 建议执行命令: ${GREEN}docker system df${NC} 查看详情"
# docker system df 可能需要 docker 正在运行
if docker info &> /dev/null; then
docker system df
else
echo " (Docker 服务未运行,无法获取大小)"
fi
echo "------------------------------------------------------------"
fi
# 9. 下载文件夹 (提醒)
check_dir_size "下载文件夹" "$HOME/Downloads" "下载的文件,通常包含很多不再需要的安装包和临时文件。"
echo ""
echo "============================================================"
echo "建议清理方式:"
echo "1. 使用 'rm -rf <路径>' 删除特定目录内容(请务必小心确认路径)。"
echo "2. 对于 Homebrew使用 'brew cleanup'。"
echo "3. 对于 Docker使用 'docker system prune'。"
echo "4. 对于 Xcode可以直接删除 DerivedData 目录。"
echo "5. 推荐使用专门的清理工具(如 CleanMyMac 或腾讯柠檬清理)进行更安全的清理。"
echo "============================================================"

View File

@@ -0,0 +1,70 @@
#!/bin/bash
# 脚本名称: get_system_info.sh
# 描述: 获取 macOS 系统配置信息的脚本
# 作者: Trae AI
echo "================================================"
echo " 系统配置信息概览"
echo "================================================"
# 1. 主机名
echo "【主机信息】"
echo " 主机名 : $(hostname)"
echo " 用户名 : $(whoami)"
echo ""
# 2. 操作系统版本
echo "【操作系统】"
PRODUCT_NAME=$(sw_vers -productName)
PRODUCT_VERSION=$(sw_vers -productVersion)
BUILD_VERSION=$(sw_vers -buildVersion)
echo " 系统名称 : $PRODUCT_NAME"
echo " 系统版本 : $PRODUCT_VERSION (Build $BUILD_VERSION)"
# 获取内核版本
echo " 内核版本 : $(uname -r)"
echo ""
# 3. CPU 信息
echo "【CPU 信息】"
CPU_BRAND=$(sysctl -n machdep.cpu.brand_string)
PHY_CORES=$(sysctl -n hw.physicalcpu)
LOG_CORES=$(sysctl -n hw.logicalcpu)
echo " 型号 : $CPU_BRAND"
echo " 物理核心 : $PHY_CORES"
echo " 逻辑核心 : $LOG_CORES"
# 尝试获取架构 (e.g. x86_64 or arm64)
ARCH=$(uname -m)
echo " 架构 : $ARCH"
echo ""
# 4. 内存信息
echo "【内存信息】"
MEM_BYTES=$(sysctl -n hw.memsize)
MEM_GB=$(echo "scale=2; $MEM_BYTES / 1024 / 1024 / 1024" | bc)
echo " 总内存 : ${MEM_GB} GB"
echo ""
# 5. 磁盘使用情况 (根目录)
echo "【磁盘信息 (根目录)】"
# 使用 df -h 获取根目录信息,并格式化输出
df -h / | awk 'NR==2 {printf " 总容量 : %s\n 已用 : %s\n 可用 : %s\n 使用率 : %s\n", $2, $3, $4, $5}'
echo ""
# 6. 网络信息
echo "【网络信息】"
# 获取默认接口的 IP (通常是 en0 Wi-Fi 或 en1)
IP_ADDR=$(ipconfig getifaddr en0)
if [ -z "$IP_ADDR" ]; then
IP_ADDR=$(ipconfig getifaddr en1)
fi
if [ -z "$IP_ADDR" ]; then
echo " IP 地址 : 未连接或无法获取"
else
echo " IP 地址 : $IP_ADDR"
fi
echo ""
echo "================================================"
echo "信息获取完成。"

View File

@@ -0,0 +1,30 @@
© 2025 Anthropic, PBC. All rights reserved.
LICENSE: Use of these materials (including all code, prompts, assets, files,
and other components of this Skill) is governed by your agreement with
Anthropic regarding use of Anthropic's services. If no separate agreement
exists, use is governed by Anthropic's Consumer Terms of Service or
Commercial Terms of Service, as applicable:
https://www.anthropic.com/legal/consumer-terms
https://www.anthropic.com/legal/commercial-terms
Your applicable agreement is referred to as the "Agreement." "Services" are
as defined in the Agreement.
ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the
contrary, users may not:
- Extract these materials from the Services or retain copies of these
materials outside the Services
- Reproduce or copy these materials, except for temporary copies created
automatically during authorized use of the Services
- Create derivative works based on these materials
- Distribute, sublicense, or transfer these materials to any third party
- Make, offer to sell, sell, or import any inventions embodied in these
materials
- Reverse engineer, decompile, or disassemble these materials
The receipt, viewing, or possession of these materials does not convey or
imply any license or right beyond those expressly granted above.
Anthropic retains all right, title, and interest in these materials,
including all copyrights, patents, and other intellectual property rights.

View File

@@ -0,0 +1,294 @@
---
name: pdf
description: Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. When Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale.
license: Proprietary. LICENSE.txt has complete terms
---
# PDF Processing Guide
## Overview
This guide covers essential PDF processing operations using Python libraries and command-line tools. For advanced features, JavaScript libraries, and detailed examples, see reference.md. If you need to fill out a PDF form, read forms.md and follow its instructions.
## Quick Start
```python
from pypdf import PdfReader, PdfWriter
# Read a PDF
reader = PdfReader("document.pdf")
print(f"Pages: {len(reader.pages)}")
# Extract text
text = ""
for page in reader.pages:
text += page.extract_text()
```
## Python Libraries
### pypdf - Basic Operations
#### Merge PDFs
```python
from pypdf import PdfWriter, PdfReader
writer = PdfWriter()
for pdf_file in ["doc1.pdf", "doc2.pdf", "doc3.pdf"]:
reader = PdfReader(pdf_file)
for page in reader.pages:
writer.add_page(page)
with open("merged.pdf", "wb") as output:
writer.write(output)
```
#### Split PDF
```python
reader = PdfReader("input.pdf")
for i, page in enumerate(reader.pages):
writer = PdfWriter()
writer.add_page(page)
with open(f"page_{i+1}.pdf", "wb") as output:
writer.write(output)
```
#### Extract Metadata
```python
reader = PdfReader("document.pdf")
meta = reader.metadata
print(f"Title: {meta.title}")
print(f"Author: {meta.author}")
print(f"Subject: {meta.subject}")
print(f"Creator: {meta.creator}")
```
#### Rotate Pages
```python
reader = PdfReader("input.pdf")
writer = PdfWriter()
page = reader.pages[0]
page.rotate(90) # Rotate 90 degrees clockwise
writer.add_page(page)
with open("rotated.pdf", "wb") as output:
writer.write(output)
```
### pdfplumber - Text and Table Extraction
#### Extract Text with Layout
```python
import pdfplumber
with pdfplumber.open("document.pdf") as pdf:
for page in pdf.pages:
text = page.extract_text()
print(text)
```
#### Extract Tables
```python
with pdfplumber.open("document.pdf") as pdf:
for i, page in enumerate(pdf.pages):
tables = page.extract_tables()
for j, table in enumerate(tables):
print(f"Table {j+1} on page {i+1}:")
for row in table:
print(row)
```
#### Advanced Table Extraction
```python
import pandas as pd
with pdfplumber.open("document.pdf") as pdf:
all_tables = []
for page in pdf.pages:
tables = page.extract_tables()
for table in tables:
if table: # Check if table is not empty
df = pd.DataFrame(table[1:], columns=table[0])
all_tables.append(df)
# Combine all tables
if all_tables:
combined_df = pd.concat(all_tables, ignore_index=True)
combined_df.to_excel("extracted_tables.xlsx", index=False)
```
### reportlab - Create PDFs
#### Basic PDF Creation
```python
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
c = canvas.Canvas("hello.pdf", pagesize=letter)
width, height = letter
# Add text
c.drawString(100, height - 100, "Hello World!")
c.drawString(100, height - 120, "This is a PDF created with reportlab")
# Add a line
c.line(100, height - 140, 400, height - 140)
# Save
c.save()
```
#### Create PDF with Multiple Pages
```python
from reportlab.lib.pagesizes import letter
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
from reportlab.lib.styles import getSampleStyleSheet
doc = SimpleDocTemplate("report.pdf", pagesize=letter)
styles = getSampleStyleSheet()
story = []
# Add content
title = Paragraph("Report Title", styles['Title'])
story.append(title)
story.append(Spacer(1, 12))
body = Paragraph("This is the body of the report. " * 20, styles['Normal'])
story.append(body)
story.append(PageBreak())
# Page 2
story.append(Paragraph("Page 2", styles['Heading1']))
story.append(Paragraph("Content for page 2", styles['Normal']))
# Build PDF
doc.build(story)
```
## Command-Line Tools
### pdftotext (poppler-utils)
```bash
# Extract text
pdftotext input.pdf output.txt
# Extract text preserving layout
pdftotext -layout input.pdf output.txt
# Extract specific pages
pdftotext -f 1 -l 5 input.pdf output.txt # Pages 1-5
```
### qpdf
```bash
# Merge PDFs
qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf
# Split pages
qpdf input.pdf --pages . 1-5 -- pages1-5.pdf
qpdf input.pdf --pages . 6-10 -- pages6-10.pdf
# Rotate pages
qpdf input.pdf output.pdf --rotate=+90:1 # Rotate page 1 by 90 degrees
# Remove password
qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdf
```
### pdftk (if available)
```bash
# Merge
pdftk file1.pdf file2.pdf cat output merged.pdf
# Split
pdftk input.pdf burst
# Rotate
pdftk input.pdf rotate 1east output rotated.pdf
```
## Common Tasks
### Extract Text from Scanned PDFs
```python
# Requires: pip install pytesseract pdf2image
import pytesseract
from pdf2image import convert_from_path
# Convert PDF to images
images = convert_from_path('scanned.pdf')
# OCR each page
text = ""
for i, image in enumerate(images):
text += f"Page {i+1}:\n"
text += pytesseract.image_to_string(image)
text += "\n\n"
print(text)
```
### Add Watermark
```python
from pypdf import PdfReader, PdfWriter
# Create watermark (or load existing)
watermark = PdfReader("watermark.pdf").pages[0]
# Apply to all pages
reader = PdfReader("document.pdf")
writer = PdfWriter()
for page in reader.pages:
page.merge_page(watermark)
writer.add_page(page)
with open("watermarked.pdf", "wb") as output:
writer.write(output)
```
### Extract Images
```bash
# Using pdfimages (poppler-utils)
pdfimages -j input.pdf output_prefix
# This extracts all images as output_prefix-000.jpg, output_prefix-001.jpg, etc.
```
### Password Protection
```python
from pypdf import PdfReader, PdfWriter
reader = PdfReader("input.pdf")
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
# Add password
writer.encrypt("userpassword", "ownerpassword")
with open("encrypted.pdf", "wb") as output:
writer.write(output)
```
## Quick Reference
| Task | Best Tool | Command/Code |
|------|-----------|--------------|
| Merge PDFs | pypdf | `writer.add_page(page)` |
| Split PDFs | pypdf | One page per file |
| Extract text | pdfplumber | `page.extract_text()` |
| Extract tables | pdfplumber | `page.extract_tables()` |
| Create PDFs | reportlab | Canvas or Platypus |
| Command line merge | qpdf | `qpdf --empty --pages ...` |
| OCR scanned PDFs | pytesseract | Convert to image first |
| Fill PDF forms | pdf-lib or pypdf (see forms.md) | See forms.md |
## Next Steps
- For advanced pypdfium2 usage, see reference.md
- For JavaScript libraries (pdf-lib), see reference.md
- If you need to fill out a PDF form, follow the instructions in forms.md
- For troubleshooting guides, see reference.md

View File

@@ -0,0 +1,205 @@
**CRITICAL: You MUST complete these steps in order. Do not skip ahead to writing code.**
If you need to fill out a PDF form, first check to see if the PDF has fillable form fields. Run this script from this file's directory:
`python scripts/check_fillable_fields <file.pdf>`, and depending on the result go to either the "Fillable fields" or "Non-fillable fields" and follow those instructions.
# Fillable fields
If the PDF has fillable form fields:
- Run this script from this file's directory: `python scripts/extract_form_field_info.py <input.pdf> <field_info.json>`. It will create a JSON file with a list of fields in this format:
```
[
{
"field_id": (unique ID for the field),
"page": (page number, 1-based),
"rect": ([left, bottom, right, top] bounding box in PDF coordinates, y=0 is the bottom of the page),
"type": ("text", "checkbox", "radio_group", or "choice"),
},
// Checkboxes have "checked_value" and "unchecked_value" properties:
{
"field_id": (unique ID for the field),
"page": (page number, 1-based),
"type": "checkbox",
"checked_value": (Set the field to this value to check the checkbox),
"unchecked_value": (Set the field to this value to uncheck the checkbox),
},
// Radio groups have a "radio_options" list with the possible choices.
{
"field_id": (unique ID for the field),
"page": (page number, 1-based),
"type": "radio_group",
"radio_options": [
{
"value": (set the field to this value to select this radio option),
"rect": (bounding box for the radio button for this option)
},
// Other radio options
]
},
// Multiple choice fields have a "choice_options" list with the possible choices:
{
"field_id": (unique ID for the field),
"page": (page number, 1-based),
"type": "choice",
"choice_options": [
{
"value": (set the field to this value to select this option),
"text": (display text of the option)
},
// Other choice options
],
}
]
```
- Convert the PDF to PNGs (one image for each page) with this script (run from this file's directory):
`python scripts/convert_pdf_to_images.py <file.pdf> <output_directory>`
Then analyze the images to determine the purpose of each form field (make sure to convert the bounding box PDF coordinates to image coordinates).
- Create a `field_values.json` file in this format with the values to be entered for each field:
```
[
{
"field_id": "last_name", // Must match the field_id from `extract_form_field_info.py`
"description": "The user's last name",
"page": 1, // Must match the "page" value in field_info.json
"value": "Simpson"
},
{
"field_id": "Checkbox12",
"description": "Checkbox to be checked if the user is 18 or over",
"page": 1,
"value": "/On" // If this is a checkbox, use its "checked_value" value to check it. If it's a radio button group, use one of the "value" values in "radio_options".
},
// more fields
]
```
- Run the `fill_fillable_fields.py` script from this file's directory to create a filled-in PDF:
`python scripts/fill_fillable_fields.py <input pdf> <field_values.json> <output pdf>`
This script will verify that the field IDs and values you provide are valid; if it prints error messages, correct the appropriate fields and try again.
# Non-fillable fields
If the PDF doesn't have fillable form fields, you'll need to visually determine where the data should be added and create text annotations. Follow the below steps *exactly*. You MUST perform all of these steps to ensure that the the form is accurately completed. Details for each step are below.
- Convert the PDF to PNG images and determine field bounding boxes.
- Create a JSON file with field information and validation images showing the bounding boxes.
- Validate the the bounding boxes.
- Use the bounding boxes to fill in the form.
## Step 1: Visual Analysis (REQUIRED)
- Convert the PDF to PNG images. Run this script from this file's directory:
`python scripts/convert_pdf_to_images.py <file.pdf> <output_directory>`
The script will create a PNG image for each page in the PDF.
- Carefully examine each PNG image and identify all form fields and areas where the user should enter data. For each form field where the user should enter text, determine bounding boxes for both the form field label, and the area where the user should enter text. The label and entry bounding boxes MUST NOT INTERSECT; the text entry box should only include the area where data should be entered. Usually this area will be immediately to the side, above, or below its label. Entry bounding boxes must be tall and wide enough to contain their text.
These are some examples of form structures that you might see:
*Label inside box*
```
┌────────────────────────┐
│ Name: │
└────────────────────────┘
```
The input area should be to the right of the "Name" label and extend to the edge of the box.
*Label before line*
```
Email: _______________________
```
The input area should be above the line and include its entire width.
*Label under line*
```
_________________________
Name
```
The input area should be above the line and include the entire width of the line. This is common for signature and date fields.
*Label above line*
```
Please enter any special requests:
________________________________________________
```
The input area should extend from the bottom of the label to the line, and should include the entire width of the line.
*Checkboxes*
```
Are you a US citizen? Yes □ No □
```
For checkboxes:
- Look for small square boxes (□) - these are the actual checkboxes to target. They may be to the left or right of their labels.
- Distinguish between label text ("Yes", "No") and the clickable checkbox squares.
- The entry bounding box should cover ONLY the small square, not the text label.
### Step 2: Create fields.json and validation images (REQUIRED)
- Create a file named `fields.json` with information for the form fields and bounding boxes in this format:
```
{
"pages": [
{
"page_number": 1,
"image_width": (first page image width in pixels),
"image_height": (first page image height in pixels),
},
{
"page_number": 2,
"image_width": (second page image width in pixels),
"image_height": (second page image height in pixels),
}
// additional pages
],
"form_fields": [
// Example for a text field.
{
"page_number": 1,
"description": "The user's last name should be entered here",
// Bounding boxes are [left, top, right, bottom]. The bounding boxes for the label and text entry should not overlap.
"field_label": "Last name",
"label_bounding_box": [30, 125, 95, 142],
"entry_bounding_box": [100, 125, 280, 142],
"entry_text": {
"text": "Johnson", // This text will be added as an annotation at the entry_bounding_box location
"font_size": 14, // optional, defaults to 14
"font_color": "000000", // optional, RRGGBB format, defaults to 000000 (black)
}
},
// Example for a checkbox. TARGET THE SQUARE for the entry bounding box, NOT THE TEXT
{
"page_number": 2,
"description": "Checkbox that should be checked if the user is over 18",
"entry_bounding_box": [140, 525, 155, 540], // Small box over checkbox square
"field_label": "Yes",
"label_bounding_box": [100, 525, 132, 540], // Box containing "Yes" text
// Use "X" to check a checkbox.
"entry_text": {
"text": "X",
}
}
// additional form field entries
]
}
```
Create validation images by running this script from this file's directory for each page:
`python scripts/create_validation_image.py <page_number> <path_to_fields.json> <input_image_path> <output_image_path>
The validation images will have red rectangles where text should be entered, and blue rectangles covering label text.
### Step 3: Validate Bounding Boxes (REQUIRED)
#### Automated intersection check
- Verify that none of bounding boxes intersect and that the entry bounding boxes are tall enough by checking the fields.json file with the `check_bounding_boxes.py` script (run from this file's directory):
`python scripts/check_bounding_boxes.py <JSON file>`
If there are errors, reanalyze the relevant fields, adjust the bounding boxes, and iterate until there are no remaining errors. Remember: label (blue) bounding boxes should contain text labels, entry (red) boxes should not.
#### Manual image inspection
**CRITICAL: Do not proceed without visually inspecting validation images**
- Red rectangles must ONLY cover input areas
- Red rectangles MUST NOT contain any text
- Blue rectangles should contain label text
- For checkboxes:
- Red rectangle MUST be centered on the checkbox square
- Blue rectangle should cover the text label for the checkbox
- If any rectangles look wrong, fix fields.json, regenerate the validation images, and verify again. Repeat this process until the bounding boxes are fully accurate.
### Step 4: Add annotations to the PDF
Run this script from this file's directory to create a filled-out PDF using the information in fields.json:
`python scripts/fill_pdf_form_with_annotations.py <input_pdf_path> <path_to_fields.json> <output_pdf_path>

View File

@@ -0,0 +1,612 @@
# PDF Processing Advanced Reference
This document contains advanced PDF processing features, detailed examples, and additional libraries not covered in the main skill instructions.
## pypdfium2 Library (Apache/BSD License)
### Overview
pypdfium2 is a Python binding for PDFium (Chromium's PDF library). It's excellent for fast PDF rendering, image generation, and serves as a PyMuPDF replacement.
### Render PDF to Images
```python
import pypdfium2 as pdfium
from PIL import Image
# Load PDF
pdf = pdfium.PdfDocument("document.pdf")
# Render page to image
page = pdf[0] # First page
bitmap = page.render(
scale=2.0, # Higher resolution
rotation=0 # No rotation
)
# Convert to PIL Image
img = bitmap.to_pil()
img.save("page_1.png", "PNG")
# Process multiple pages
for i, page in enumerate(pdf):
bitmap = page.render(scale=1.5)
img = bitmap.to_pil()
img.save(f"page_{i+1}.jpg", "JPEG", quality=90)
```
### Extract Text with pypdfium2
```python
import pypdfium2 as pdfium
pdf = pdfium.PdfDocument("document.pdf")
for i, page in enumerate(pdf):
text = page.get_text()
print(f"Page {i+1} text length: {len(text)} chars")
```
## JavaScript Libraries
### pdf-lib (MIT License)
pdf-lib is a powerful JavaScript library for creating and modifying PDF documents in any JavaScript environment.
#### Load and Manipulate Existing PDF
```javascript
import { PDFDocument } from 'pdf-lib';
import fs from 'fs';
async function manipulatePDF() {
// Load existing PDF
const existingPdfBytes = fs.readFileSync('input.pdf');
const pdfDoc = await PDFDocument.load(existingPdfBytes);
// Get page count
const pageCount = pdfDoc.getPageCount();
console.log(`Document has ${pageCount} pages`);
// Add new page
const newPage = pdfDoc.addPage([600, 400]);
newPage.drawText('Added by pdf-lib', {
x: 100,
y: 300,
size: 16
});
// Save modified PDF
const pdfBytes = await pdfDoc.save();
fs.writeFileSync('modified.pdf', pdfBytes);
}
```
#### Create Complex PDFs from Scratch
```javascript
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
import fs from 'fs';
async function createPDF() {
const pdfDoc = await PDFDocument.create();
// Add fonts
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
// Add page
const page = pdfDoc.addPage([595, 842]); // A4 size
const { width, height } = page.getSize();
// Add text with styling
page.drawText('Invoice #12345', {
x: 50,
y: height - 50,
size: 18,
font: helveticaBold,
color: rgb(0.2, 0.2, 0.8)
});
// Add rectangle (header background)
page.drawRectangle({
x: 40,
y: height - 100,
width: width - 80,
height: 30,
color: rgb(0.9, 0.9, 0.9)
});
// Add table-like content
const items = [
['Item', 'Qty', 'Price', 'Total'],
['Widget', '2', '$50', '$100'],
['Gadget', '1', '$75', '$75']
];
let yPos = height - 150;
items.forEach(row => {
let xPos = 50;
row.forEach(cell => {
page.drawText(cell, {
x: xPos,
y: yPos,
size: 12,
font: helveticaFont
});
xPos += 120;
});
yPos -= 25;
});
const pdfBytes = await pdfDoc.save();
fs.writeFileSync('created.pdf', pdfBytes);
}
```
#### Advanced Merge and Split Operations
```javascript
import { PDFDocument } from 'pdf-lib';
import fs from 'fs';
async function mergePDFs() {
// Create new document
const mergedPdf = await PDFDocument.create();
// Load source PDFs
const pdf1Bytes = fs.readFileSync('doc1.pdf');
const pdf2Bytes = fs.readFileSync('doc2.pdf');
const pdf1 = await PDFDocument.load(pdf1Bytes);
const pdf2 = await PDFDocument.load(pdf2Bytes);
// Copy pages from first PDF
const pdf1Pages = await mergedPdf.copyPages(pdf1, pdf1.getPageIndices());
pdf1Pages.forEach(page => mergedPdf.addPage(page));
// Copy specific pages from second PDF (pages 0, 2, 4)
const pdf2Pages = await mergedPdf.copyPages(pdf2, [0, 2, 4]);
pdf2Pages.forEach(page => mergedPdf.addPage(page));
const mergedPdfBytes = await mergedPdf.save();
fs.writeFileSync('merged.pdf', mergedPdfBytes);
}
```
### pdfjs-dist (Apache License)
PDF.js is Mozilla's JavaScript library for rendering PDFs in the browser.
#### Basic PDF Loading and Rendering
```javascript
import * as pdfjsLib from 'pdfjs-dist';
// Configure worker (important for performance)
pdfjsLib.GlobalWorkerOptions.workerSrc = './pdf.worker.js';
async function renderPDF() {
// Load PDF
const loadingTask = pdfjsLib.getDocument('document.pdf');
const pdf = await loadingTask.promise;
console.log(`Loaded PDF with ${pdf.numPages} pages`);
// Get first page
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 1.5 });
// Render to canvas
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
const renderContext = {
canvasContext: context,
viewport: viewport
};
await page.render(renderContext).promise;
document.body.appendChild(canvas);
}
```
#### Extract Text with Coordinates
```javascript
import * as pdfjsLib from 'pdfjs-dist';
async function extractText() {
const loadingTask = pdfjsLib.getDocument('document.pdf');
const pdf = await loadingTask.promise;
let fullText = '';
// Extract text from all pages
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items
.map(item => item.str)
.join(' ');
fullText += `\n--- Page ${i} ---\n${pageText}`;
// Get text with coordinates for advanced processing
const textWithCoords = textContent.items.map(item => ({
text: item.str,
x: item.transform[4],
y: item.transform[5],
width: item.width,
height: item.height
}));
}
console.log(fullText);
return fullText;
}
```
#### Extract Annotations and Forms
```javascript
import * as pdfjsLib from 'pdfjs-dist';
async function extractAnnotations() {
const loadingTask = pdfjsLib.getDocument('annotated.pdf');
const pdf = await loadingTask.promise;
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const annotations = await page.getAnnotations();
annotations.forEach(annotation => {
console.log(`Annotation type: ${annotation.subtype}`);
console.log(`Content: ${annotation.contents}`);
console.log(`Coordinates: ${JSON.stringify(annotation.rect)}`);
});
}
}
```
## Advanced Command-Line Operations
### poppler-utils Advanced Features
#### Extract Text with Bounding Box Coordinates
```bash
# Extract text with bounding box coordinates (essential for structured data)
pdftotext -bbox-layout document.pdf output.xml
# The XML output contains precise coordinates for each text element
```
#### Advanced Image Conversion
```bash
# Convert to PNG images with specific resolution
pdftoppm -png -r 300 document.pdf output_prefix
# Convert specific page range with high resolution
pdftoppm -png -r 600 -f 1 -l 3 document.pdf high_res_pages
# Convert to JPEG with quality setting
pdftoppm -jpeg -jpegopt quality=85 -r 200 document.pdf jpeg_output
```
#### Extract Embedded Images
```bash
# Extract all embedded images with metadata
pdfimages -j -p document.pdf page_images
# List image info without extracting
pdfimages -list document.pdf
# Extract images in their original format
pdfimages -all document.pdf images/img
```
### qpdf Advanced Features
#### Complex Page Manipulation
```bash
# Split PDF into groups of pages
qpdf --split-pages=3 input.pdf output_group_%02d.pdf
# Extract specific pages with complex ranges
qpdf input.pdf --pages input.pdf 1,3-5,8,10-end -- extracted.pdf
# Merge specific pages from multiple PDFs
qpdf --empty --pages doc1.pdf 1-3 doc2.pdf 5-7 doc3.pdf 2,4 -- combined.pdf
```
#### PDF Optimization and Repair
```bash
# Optimize PDF for web (linearize for streaming)
qpdf --linearize input.pdf optimized.pdf
# Remove unused objects and compress
qpdf --optimize-level=all input.pdf compressed.pdf
# Attempt to repair corrupted PDF structure
qpdf --check input.pdf
qpdf --fix-qdf damaged.pdf repaired.pdf
# Show detailed PDF structure for debugging
qpdf --show-all-pages input.pdf > structure.txt
```
#### Advanced Encryption
```bash
# Add password protection with specific permissions
qpdf --encrypt user_pass owner_pass 256 --print=none --modify=none -- input.pdf encrypted.pdf
# Check encryption status
qpdf --show-encryption encrypted.pdf
# Remove password protection (requires password)
qpdf --password=secret123 --decrypt encrypted.pdf decrypted.pdf
```
## Advanced Python Techniques
### pdfplumber Advanced Features
#### Extract Text with Precise Coordinates
```python
import pdfplumber
with pdfplumber.open("document.pdf") as pdf:
page = pdf.pages[0]
# Extract all text with coordinates
chars = page.chars
for char in chars[:10]: # First 10 characters
print(f"Char: '{char['text']}' at x:{char['x0']:.1f} y:{char['y0']:.1f}")
# Extract text by bounding box (left, top, right, bottom)
bbox_text = page.within_bbox((100, 100, 400, 200)).extract_text()
```
#### Advanced Table Extraction with Custom Settings
```python
import pdfplumber
import pandas as pd
with pdfplumber.open("complex_table.pdf") as pdf:
page = pdf.pages[0]
# Extract tables with custom settings for complex layouts
table_settings = {
"vertical_strategy": "lines",
"horizontal_strategy": "lines",
"snap_tolerance": 3,
"intersection_tolerance": 15
}
tables = page.extract_tables(table_settings)
# Visual debugging for table extraction
img = page.to_image(resolution=150)
img.save("debug_layout.png")
```
### reportlab Advanced Features
#### Create Professional Reports with Tables
```python
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib import colors
# Sample data
data = [
['Product', 'Q1', 'Q2', 'Q3', 'Q4'],
['Widgets', '120', '135', '142', '158'],
['Gadgets', '85', '92', '98', '105']
]
# Create PDF with table
doc = SimpleDocTemplate("report.pdf")
elements = []
# Add title
styles = getSampleStyleSheet()
title = Paragraph("Quarterly Sales Report", styles['Title'])
elements.append(title)
# Add table with advanced styling
table = Table(data)
table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 14),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
('GRID', (0, 0), (-1, -1), 1, colors.black)
]))
elements.append(table)
doc.build(elements)
```
## Complex Workflows
### Extract Figures/Images from PDF
#### Method 1: Using pdfimages (fastest)
```bash
# Extract all images with original quality
pdfimages -all document.pdf images/img
```
#### Method 2: Using pypdfium2 + Image Processing
```python
import pypdfium2 as pdfium
from PIL import Image
import numpy as np
def extract_figures(pdf_path, output_dir):
pdf = pdfium.PdfDocument(pdf_path)
for page_num, page in enumerate(pdf):
# Render high-resolution page
bitmap = page.render(scale=3.0)
img = bitmap.to_pil()
# Convert to numpy for processing
img_array = np.array(img)
# Simple figure detection (non-white regions)
mask = np.any(img_array != [255, 255, 255], axis=2)
# Find contours and extract bounding boxes
# (This is simplified - real implementation would need more sophisticated detection)
# Save detected figures
# ... implementation depends on specific needs
```
### Batch PDF Processing with Error Handling
```python
import os
import glob
from pypdf import PdfReader, PdfWriter
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def batch_process_pdfs(input_dir, operation='merge'):
pdf_files = glob.glob(os.path.join(input_dir, "*.pdf"))
if operation == 'merge':
writer = PdfWriter()
for pdf_file in pdf_files:
try:
reader = PdfReader(pdf_file)
for page in reader.pages:
writer.add_page(page)
logger.info(f"Processed: {pdf_file}")
except Exception as e:
logger.error(f"Failed to process {pdf_file}: {e}")
continue
with open("batch_merged.pdf", "wb") as output:
writer.write(output)
elif operation == 'extract_text':
for pdf_file in pdf_files:
try:
reader = PdfReader(pdf_file)
text = ""
for page in reader.pages:
text += page.extract_text()
output_file = pdf_file.replace('.pdf', '.txt')
with open(output_file, 'w', encoding='utf-8') as f:
f.write(text)
logger.info(f"Extracted text from: {pdf_file}")
except Exception as e:
logger.error(f"Failed to extract text from {pdf_file}: {e}")
continue
```
### Advanced PDF Cropping
```python
from pypdf import PdfWriter, PdfReader
reader = PdfReader("input.pdf")
writer = PdfWriter()
# Crop page (left, bottom, right, top in points)
page = reader.pages[0]
page.mediabox.left = 50
page.mediabox.bottom = 50
page.mediabox.right = 550
page.mediabox.top = 750
writer.add_page(page)
with open("cropped.pdf", "wb") as output:
writer.write(output)
```
## Performance Optimization Tips
### 1. For Large PDFs
- Use streaming approaches instead of loading entire PDF in memory
- Use `qpdf --split-pages` for splitting large files
- Process pages individually with pypdfium2
### 2. For Text Extraction
- `pdftotext -bbox-layout` is fastest for plain text extraction
- Use pdfplumber for structured data and tables
- Avoid `pypdf.extract_text()` for very large documents
### 3. For Image Extraction
- `pdfimages` is much faster than rendering pages
- Use low resolution for previews, high resolution for final output
### 4. For Form Filling
- pdf-lib maintains form structure better than most alternatives
- Pre-validate form fields before processing
### 5. Memory Management
```python
# Process PDFs in chunks
def process_large_pdf(pdf_path, chunk_size=10):
reader = PdfReader(pdf_path)
total_pages = len(reader.pages)
for start_idx in range(0, total_pages, chunk_size):
end_idx = min(start_idx + chunk_size, total_pages)
writer = PdfWriter()
for i in range(start_idx, end_idx):
writer.add_page(reader.pages[i])
# Process chunk
with open(f"chunk_{start_idx//chunk_size}.pdf", "wb") as output:
writer.write(output)
```
## Troubleshooting Common Issues
### Encrypted PDFs
```python
# Handle password-protected PDFs
from pypdf import PdfReader
try:
reader = PdfReader("encrypted.pdf")
if reader.is_encrypted:
reader.decrypt("password")
except Exception as e:
print(f"Failed to decrypt: {e}")
```
### Corrupted PDFs
```bash
# Use qpdf to repair
qpdf --check corrupted.pdf
qpdf --replace-input corrupted.pdf
```
### Text Extraction Issues
```python
# Fallback to OCR for scanned PDFs
import pytesseract
from pdf2image import convert_from_path
def extract_text_with_ocr(pdf_path):
images = convert_from_path(pdf_path)
text = ""
for i, image in enumerate(images):
text += pytesseract.image_to_string(image)
return text
```
## License Information
- **pypdf**: BSD License
- **pdfplumber**: MIT License
- **pypdfium2**: Apache/BSD License
- **reportlab**: BSD License
- **poppler-utils**: GPL-2 License
- **qpdf**: Apache License
- **pdf-lib**: MIT License
- **pdfjs-dist**: Apache License

View File

@@ -0,0 +1,86 @@
# flake8: noqa
# yapf: disable
import sys
from dataclasses import dataclass
import json
# Script to check that the `fields.json` file that Claude creates when analyzing PDFs
# does not have overlapping bounding boxes. See forms.md.
@dataclass
class RectAndField:
rect: list[float]
rect_type: str
field: dict
# Returns a list of messages that are printed to stdout for Claude to read.
def get_bounding_box_messages(fields_json_stream) -> list[str]:
messages = []
fields = json.load(fields_json_stream)
messages.append(f"Read {len(fields['form_fields'])} fields")
def rects_intersect(r1, r2):
disjoint_horizontal = r1[0] >= r2[2] or r1[2] <= r2[0]
disjoint_vertical = r1[1] >= r2[3] or r1[3] <= r2[1]
return not (disjoint_horizontal or disjoint_vertical)
rects_and_fields = []
for f in fields['form_fields']:
rects_and_fields.append(
RectAndField(f['label_bounding_box'], 'label', f))
rects_and_fields.append(
RectAndField(f['entry_bounding_box'], 'entry', f))
has_error = False
for i, ri in enumerate(rects_and_fields):
# This is O(N^2); we can optimize if it becomes a problem.
for j in range(i + 1, len(rects_and_fields)):
rj = rects_and_fields[j]
if ri.field['page_number'] == rj.field[
'page_number'] and rects_intersect(ri.rect, rj.rect):
has_error = True
if ri.field is rj.field:
messages.append(
f"FAILURE: intersection between label and entry bounding boxes for `{ri.field['description']}` ({ri.rect}, {rj.rect})"
)
else:
messages.append(
f"FAILURE: intersection between {ri.rect_type} bounding box for `{ri.field['description']}` ({ri.rect}) and {rj.rect_type} bounding box for `{rj.field['description']}` ({rj.rect})"
)
if len(messages) >= 20:
messages.append(
'Aborting further checks; fix bounding boxes and try again'
)
return messages
if ri.rect_type == 'entry':
if 'entry_text' in ri.field:
font_size = ri.field['entry_text'].get('font_size', 14)
entry_height = ri.rect[3] - ri.rect[1]
if entry_height < font_size:
has_error = True
messages.append(
f"FAILURE: entry bounding box height ({entry_height}) for `{ri.field['description']}` is too short for the text content (font size: {font_size}). Increase the box height or decrease the font size."
)
if len(messages) >= 20:
messages.append(
'Aborting further checks; fix bounding boxes and try again'
)
return messages
if not has_error:
messages.append('SUCCESS: All bounding boxes are valid')
return messages
if __name__ == '__main__':
if len(sys.argv) != 2:
print('Usage: check_bounding_boxes.py [fields.json]')
sys.exit(1)
# Input file should be in the `fields.json` format described in forms.md.
with open(sys.argv[1]) as f:
messages = get_bounding_box_messages(f)
for msg in messages:
print(msg)

View File

@@ -0,0 +1,224 @@
# flake8: noqa
# yapf: disable
import io
import unittest
import json
from check_bounding_boxes import get_bounding_box_messages
# Currently this is not run automatically in CI; it's just for documentation and manual checking.
class TestGetBoundingBoxMessages(unittest.TestCase):
def create_json_stream(self, data):
"""Helper to create a JSON stream from data"""
return io.StringIO(json.dumps(data))
def test_no_intersections(self):
"""Test case with no bounding box intersections"""
data = {
'form_fields': [{
'description': 'Name',
'page_number': 1,
'label_bounding_box': [10, 10, 50, 30],
'entry_bounding_box': [60, 10, 150, 30]
}, {
'description': 'Email',
'page_number': 1,
'label_bounding_box': [10, 40, 50, 60],
'entry_bounding_box': [60, 40, 150, 60]
}]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(any('SUCCESS' in msg for msg in messages))
self.assertFalse(any('FAILURE' in msg for msg in messages))
def test_label_entry_intersection_same_field(self):
"""Test intersection between label and entry of the same field"""
data = {
'form_fields': [{
'description': 'Name',
'page_number': 1,
'label_bounding_box': [10, 10, 60, 30],
'entry_bounding_box': [50, 10, 150, 30] # Overlaps with label
}]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(
any('FAILURE' in msg and 'intersection' in msg
for msg in messages))
self.assertFalse(any('SUCCESS' in msg for msg in messages))
def test_intersection_between_different_fields(self):
"""Test intersection between bounding boxes of different fields"""
data = {
'form_fields': [
{
'description': 'Name',
'page_number': 1,
'label_bounding_box': [10, 10, 50, 30],
'entry_bounding_box': [60, 10, 150, 30]
},
{
'description': 'Email',
'page_number': 1,
'label_bounding_box': [40, 20, 80,
40], # Overlaps with Name's boxes
'entry_bounding_box': [160, 10, 250, 30]
}
]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(
any('FAILURE' in msg and 'intersection' in msg
for msg in messages))
self.assertFalse(any('SUCCESS' in msg for msg in messages))
def test_different_pages_no_intersection(self):
"""Test that boxes on different pages don't count as intersecting"""
data = {
'form_fields': [
{
'description': 'Name',
'page_number': 1,
'label_bounding_box': [10, 10, 50, 30],
'entry_bounding_box': [60, 10, 150, 30]
},
{
'description': 'Email',
'page_number': 2,
'label_bounding_box':
[10, 10, 50, 30], # Same coordinates but different page
'entry_bounding_box': [60, 10, 150, 30]
}
]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(any('SUCCESS' in msg for msg in messages))
self.assertFalse(any('FAILURE' in msg for msg in messages))
def test_entry_height_too_small(self):
"""Test that entry box height is checked against font size"""
data = {
'form_fields': [{
'description': 'Name',
'page_number': 1,
'label_bounding_box': [10, 10, 50, 30],
'entry_bounding_box': [60, 10, 150, 20], # Height is 10
'entry_text': {
'font_size': 14 # Font size larger than height
}
}]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(
any('FAILURE' in msg and 'height' in msg for msg in messages))
self.assertFalse(any('SUCCESS' in msg for msg in messages))
def test_entry_height_adequate(self):
"""Test that adequate entry box height passes"""
data = {
'form_fields': [{
'description': 'Name',
'page_number': 1,
'label_bounding_box': [10, 10, 50, 30],
'entry_bounding_box': [60, 10, 150, 30], # Height is 20
'entry_text': {
'font_size': 14 # Font size smaller than height
}
}]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(any('SUCCESS' in msg for msg in messages))
self.assertFalse(any('FAILURE' in msg for msg in messages))
def test_default_font_size(self):
"""Test that default font size is used when not specified"""
data = {
'form_fields': [{
'description': 'Name',
'page_number': 1,
'label_bounding_box': [10, 10, 50, 30],
'entry_bounding_box': [60, 10, 150, 20], # Height is 10
'entry_text':
{} # No font_size specified, should use default 14
}]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(
any('FAILURE' in msg and 'height' in msg for msg in messages))
self.assertFalse(any('SUCCESS' in msg for msg in messages))
def test_no_entry_text(self):
"""Test that missing entry_text doesn't cause height check"""
data = {
'form_fields': [{
'description': 'Name',
'page_number': 1,
'label_bounding_box': [10, 10, 50, 30],
'entry_bounding_box': [60, 10, 150,
20] # Small height but no entry_text
}]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(any('SUCCESS' in msg for msg in messages))
self.assertFalse(any('FAILURE' in msg for msg in messages))
def test_multiple_errors_limit(self):
"""Test that error messages are limited to prevent excessive output"""
fields = []
# Create many overlapping fields
for i in range(25):
fields.append({
'description': f'Field{i}',
'page_number': 1,
'label_bounding_box': [10, 10, 50, 30], # All overlap
'entry_bounding_box': [20, 15, 60, 35] # All overlap
})
data = {'form_fields': fields}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
# Should abort after ~20 messages
self.assertTrue(any('Aborting' in msg for msg in messages))
# Should have some FAILURE messages but not hundreds
failure_count = sum(1 for msg in messages if 'FAILURE' in msg)
self.assertGreater(failure_count, 0)
self.assertLess(len(messages), 30) # Should be limited
def test_edge_touching_boxes(self):
"""Test that boxes touching at edges don't count as intersecting"""
data = {
'form_fields': [{
'description': 'Name',
'page_number': 1,
'label_bounding_box': [10, 10, 50, 30],
'entry_bounding_box': [50, 10, 150, 30] # Touches at x=50
}]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(any('SUCCESS' in msg for msg in messages))
self.assertFalse(any('FAILURE' in msg for msg in messages))
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,15 @@
# flake8: noqa
# yapf: disable
import sys
from pypdf import PdfReader
# Script for Claude to run to determine whether a PDF has fillable form fields. See forms.md.
reader = PdfReader(sys.argv[1])
if (reader.get_fields()):
print('This PDF has fillable form fields')
else:
print(
'This PDF does not have fillable form fields; you will need to visually determine where to enter data'
)

View File

@@ -0,0 +1,36 @@
# flake8: noqa
# yapf: disable
import os
import sys
from pdf2image import convert_from_path
# Converts each page of a PDF to a PNG image.
def convert(pdf_path, output_dir, max_dim=1000):
images = convert_from_path(pdf_path, dpi=200)
for i, image in enumerate(images):
# Scale image if needed to keep width/height under `max_dim`
width, height = image.size
if width > max_dim or height > max_dim:
scale_factor = min(max_dim / width, max_dim / height)
new_width = int(width * scale_factor)
new_height = int(height * scale_factor)
image = image.resize((new_width, new_height))
image_path = os.path.join(output_dir, f'page_{i+1}.png')
image.save(image_path)
print(f'Saved page {i+1} as {image_path} (size: {image.size})')
print(f'Converted {len(images)} pages to PNG images')
if __name__ == '__main__':
if len(sys.argv) != 3:
print('Usage: convert_pdf_to_images.py [input pdf] [output directory]')
sys.exit(1)
pdf_path = sys.argv[1]
output_directory = sys.argv[2]
convert(pdf_path, output_directory)

View File

@@ -0,0 +1,48 @@
# flake8: noqa
# yapf: disable
import sys
import json
from PIL import Image, ImageDraw
# Creates "validation" images with rectangles for the bounding box information that
# Claude creates when determining where to add text annotations in PDFs. See forms.md.
def create_validation_image(page_number, fields_json_path, input_path,
output_path):
# Input file should be in the `fields.json` format described in forms.md.
with open(fields_json_path, 'r') as f:
data = json.load(f)
img = Image.open(input_path)
draw = ImageDraw.Draw(img)
num_boxes = 0
for field in data['form_fields']:
if field['page_number'] == page_number:
entry_box = field['entry_bounding_box']
label_box = field['label_bounding_box']
# Draw red rectangle over entry bounding box and blue rectangle over the label.
draw.rectangle(entry_box, outline='red', width=2)
draw.rectangle(label_box, outline='blue', width=2)
num_boxes += 2
img.save(output_path)
print(
f'Created validation image at {output_path} with {num_boxes} bounding boxes'
)
if __name__ == '__main__':
if len(sys.argv) != 5:
print(
'Usage: create_validation_image.py [page number] [fields.json file] [input image path] [output image path]'
)
sys.exit(1)
page_number = int(sys.argv[1])
fields_json_path = sys.argv[2]
input_image_path = sys.argv[3]
output_image_path = sys.argv[4]
create_validation_image(page_number, fields_json_path, input_image_path,
output_image_path)

View File

@@ -0,0 +1,160 @@
# flake8: noqa
# yapf: disable
import sys
import json
from pypdf import PdfReader
# Extracts data for the fillable form fields in a PDF and outputs JSON that
# Claude uses to fill the fields. See forms.md.
# This matches the format used by PdfReader `get_fields` and `update_page_form_field_values` methods.
def get_full_annotation_field_id(annotation):
components = []
while annotation:
field_name = annotation.get('/T')
if field_name:
components.append(field_name)
annotation = annotation.get('/Parent')
return '.'.join(reversed(components)) if components else None
def make_field_dict(field, field_id):
field_dict = {'field_id': field_id}
ft = field.get('/FT')
if ft == '/Tx':
field_dict['type'] = 'text'
elif ft == '/Btn':
field_dict['type'] = 'checkbox' # radio groups handled separately
states = field.get('/_States_', [])
if len(states) == 2:
# "/Off" seems to always be the unchecked value, as suggested by
# https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf#page=448
# It can be either first or second in the "/_States_" list.
if '/Off' in states:
field_dict['checked_value'] = states[
0] if states[0] != '/Off' else states[1]
field_dict['unchecked_value'] = '/Off'
else:
print(
f"Unexpected state values for checkbox `${field_id}`. Its checked and unchecked values may not be correct; if you're trying to check it, visually verify the results."
)
field_dict['checked_value'] = states[0]
field_dict['unchecked_value'] = states[1]
elif ft == '/Ch':
field_dict['type'] = 'choice'
states = field.get('/_States_', [])
field_dict['choice_options'] = [{
'value': state[0],
'text': state[1],
} for state in states]
else:
field_dict['type'] = f'unknown ({ft})'
return field_dict
# Returns a list of fillable PDF fields:
# [
# {
# "field_id": "name",
# "page": 1,
# "type": ("text", "checkbox", "radio_group", or "choice")
# // Per-type additional fields described in forms.md
# },
# ]
def get_field_info(reader: PdfReader):
fields = reader.get_fields()
field_info_by_id = {}
possible_radio_names = set()
for field_id, field in fields.items():
# Skip if this is a container field with children, except that it might be
# a parent group for radio button options.
if field.get('/Kids'):
if field.get('/FT') == '/Btn':
possible_radio_names.add(field_id)
continue
field_info_by_id[field_id] = make_field_dict(field, field_id)
# Bounding rects are stored in annotations in page objects.
# Radio button options have a separate annotation for each choice;
# all choices have the same field name.
# See https://westhealth.github.io/exploring-fillable-forms-with-pdfrw.html
radio_fields_by_id = {}
for page_index, page in enumerate(reader.pages):
annotations = page.get('/Annots', [])
for ann in annotations:
field_id = get_full_annotation_field_id(ann)
if field_id in field_info_by_id:
field_info_by_id[field_id]['page'] = page_index + 1
field_info_by_id[field_id]['rect'] = ann.get('/Rect')
elif field_id in possible_radio_names:
try:
# ann['/AP']['/N'] should have two items. One of them is '/Off',
# the other is the active value.
on_values = [v for v in ann['/AP']['/N'] if v != '/Off']
except KeyError:
continue
if len(on_values) == 1:
rect = ann.get('/Rect')
if field_id not in radio_fields_by_id:
radio_fields_by_id[field_id] = {
'field_id': field_id,
'type': 'radio_group',
'page': page_index + 1,
'radio_options': [],
}
# Note: at least on macOS 15.7, Preview.app doesn't show selected
# radio buttons correctly. (It does if you remove the leading slash
# from the value, but that causes them not to appear correctly in
# Chrome/Firefox/Acrobat/etc).
radio_fields_by_id[field_id]['radio_options'].append({
'value':
on_values[0],
'rect':
rect,
})
# Some PDFs have form field definitions without corresponding annotations,
# so we can't tell where they are. Ignore these fields for now.
fields_with_location = []
for field_info in field_info_by_id.values():
if 'page' in field_info:
fields_with_location.append(field_info)
else:
print(
f"Unable to determine location for field id: {field_info.get('field_id')}, ignoring"
)
# Sort by page number, then Y position (flipped in PDF coordinate system), then X.
def sort_key(f):
if 'radio_options' in f:
rect = f['radio_options'][0]['rect'] or [0, 0, 0, 0]
else:
rect = f.get('rect') or [0, 0, 0, 0]
adjusted_position = [-rect[1], rect[0]]
return [f.get('page'), adjusted_position]
sorted_fields = fields_with_location + list(radio_fields_by_id.values())
sorted_fields.sort(key=sort_key)
return sorted_fields
def write_field_info(pdf_path: str, json_output_path: str):
reader = PdfReader(pdf_path)
field_info = get_field_info(reader)
with open(json_output_path, 'w') as f:
json.dump(field_info, f, indent=2)
print(f'Wrote {len(field_info)} fields to {json_output_path}')
if __name__ == '__main__':
if len(sys.argv) != 3:
print('Usage: extract_form_field_info.py [input pdf] [output json]')
sys.exit(1)
write_field_info(sys.argv[1], sys.argv[2])

View File

@@ -0,0 +1,122 @@
# flake8: noqa
# yapf: disable
import sys
import json
from extract_form_field_info import get_field_info
from pypdf import PdfReader, PdfWriter
# Fills fillable form fields in a PDF. See forms.md.
def fill_pdf_fields(input_pdf_path: str, fields_json_path: str,
output_pdf_path: str):
with open(fields_json_path) as f:
fields = json.load(f)
# Group by page number.
fields_by_page = {}
for field in fields:
if 'value' in field:
field_id = field['field_id']
page = field['page']
if page not in fields_by_page:
fields_by_page[page] = {}
fields_by_page[page][field_id] = field['value']
reader = PdfReader(input_pdf_path)
has_error = False
field_info = get_field_info(reader)
fields_by_ids = {f['field_id']: f for f in field_info}
for field in fields:
existing_field = fields_by_ids.get(field['field_id'])
if not existing_field:
has_error = True
print(f"ERROR: `{field['field_id']}` is not a valid field ID")
elif field['page'] != existing_field['page']:
has_error = True
print(
f"ERROR: Incorrect page number for `{field['field_id']}` (got {field['page']}, expected {existing_field['page']})"
)
else:
if 'value' in field:
err = validation_error_for_field_value(existing_field,
field['value'])
if err:
print(err)
has_error = True
if has_error:
sys.exit(1)
writer = PdfWriter(clone_from=reader)
for page, field_values in fields_by_page.items():
writer.update_page_form_field_values(
writer.pages[page - 1], field_values, auto_regenerate=False)
# This seems to be necessary for many PDF viewers to format the form values correctly.
# It may cause the viewer to show a "save changes" dialog even if the user doesn't make any changes.
writer.set_need_appearances_writer(True)
with open(output_pdf_path, 'wb') as f:
writer.write(f)
def validation_error_for_field_value(field_info, field_value):
field_type = field_info['type']
field_id = field_info['field_id']
if field_type == 'checkbox':
checked_val = field_info['checked_value']
unchecked_val = field_info['unchecked_value']
if field_value != checked_val and field_value != unchecked_val:
return f'ERROR: Invalid value "{field_value}" for checkbox field "{field_id}". The checked value is "{checked_val}" and the unchecked value is "{unchecked_val}"'
elif field_type == 'radio_group':
option_values = [opt['value'] for opt in field_info['radio_options']]
if field_value not in option_values:
return f'ERROR: Invalid value "{field_value}" for radio group field "{field_id}". Valid values are: {option_values}'
elif field_type == 'choice':
choice_values = [opt['value'] for opt in field_info['choice_options']]
if field_value not in choice_values:
return f'ERROR: Invalid value "{field_value}" for choice field "{field_id}". Valid values are: {choice_values}'
return None
# pypdf (at least version 5.7.0) has a bug when setting the value for a selection list field.
# In _writer.py around line 966:
#
# if field.get(FA.FT, "/Tx") == "/Ch" and field_flags & FA.FfBits.Combo == 0:
# txt = "\n".join(annotation.get_inherited(FA.Opt, []))
#
# The problem is that for selection lists, `get_inherited` returns a list of two-element lists like
# [["value1", "Text 1"], ["value2", "Text 2"], ...]
# This causes `join` to throw a TypeError because it expects an iterable of strings.
# The horrible workaround is to patch `get_inherited` to return a list of the value strings.
# We call the original method and adjust the return value only if the argument to `get_inherited`
# is `FA.Opt` and if the return value is a list of two-element lists.
def monkeypatch_pydpf_method():
from pypdf.generic import DictionaryObject
from pypdf.constants import FieldDictionaryAttributes
original_get_inherited = DictionaryObject.get_inherited
def patched_get_inherited(self, key: str, default=None):
result = original_get_inherited(self, key, default)
if key == FieldDictionaryAttributes.Opt:
if isinstance(result, list) and all(
isinstance(v, list) and len(v) == 2 for v in result):
result = [r[0] for r in result]
return result
DictionaryObject.get_inherited = patched_get_inherited
if __name__ == '__main__':
if len(sys.argv) != 4:
print(
'Usage: fill_fillable_fields.py [input pdf] [field_values.json] [output pdf]'
)
sys.exit(1)
monkeypatch_pydpf_method()
input_pdf = sys.argv[1]
fields_json = sys.argv[2]
output_pdf = sys.argv[3]
fill_pdf_fields(input_pdf, fields_json, output_pdf)

View File

@@ -0,0 +1,111 @@
# flake8: noqa
# yapf: disable
import sys
import json
from pypdf import PdfReader, PdfWriter
from pypdf.annotations import FreeText
# Fills a PDF by adding text annotations defined in `fields.json`. See forms.md.
def transform_coordinates(bbox, image_width, image_height, pdf_width,
pdf_height):
"""Transform bounding box from image coordinates to PDF coordinates"""
# Image coordinates: origin at top-left, y increases downward
# PDF coordinates: origin at bottom-left, y increases upward
x_scale = pdf_width / image_width
y_scale = pdf_height / image_height
left = bbox[0] * x_scale
right = bbox[2] * x_scale
# Flip Y coordinates for PDF
top = pdf_height - (bbox[1] * y_scale)
bottom = pdf_height - (bbox[3] * y_scale)
return left, bottom, right, top
def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path):
"""Fill the PDF form with data from fields.json"""
# `fields.json` format described in forms.md.
with open(fields_json_path, 'r') as f:
fields_data = json.load(f)
# Open the PDF
reader = PdfReader(input_pdf_path)
writer = PdfWriter()
# Copy all pages to writer
writer.append(reader)
# Get PDF dimensions for each page
pdf_dimensions = {}
for i, page in enumerate(reader.pages):
mediabox = page.mediabox
pdf_dimensions[i + 1] = [mediabox.width, mediabox.height]
# Process each form field
annotations = []
for field in fields_data['form_fields']:
page_num = field['page_number']
# Get page dimensions and transform coordinates.
page_info = next(p for p in fields_data['pages']
if p['page_number'] == page_num)
image_width = page_info['image_width']
image_height = page_info['image_height']
pdf_width, pdf_height = pdf_dimensions[page_num]
transformed_entry_box = transform_coordinates(
field['entry_bounding_box'], image_width, image_height, pdf_width,
pdf_height)
# Skip empty fields
if 'entry_text' not in field or 'text' not in field['entry_text']:
continue
entry_text = field['entry_text']
text = entry_text['text']
if not text:
continue
font_name = entry_text.get('font', 'Arial')
font_size = str(entry_text.get('font_size', 14)) + 'pt'
font_color = entry_text.get('font_color', '000000')
# Font size/color seems to not work reliably across viewers:
# https://github.com/py-pdf/pypdf/issues/2084
annotation = FreeText(
text=text,
rect=transformed_entry_box,
font=font_name,
font_size=font_size,
font_color=font_color,
border_color=None,
background_color=None,
)
annotations.append(annotation)
# page_number is 0-based for pypdf
writer.add_annotation(page_number=page_num - 1, annotation=annotation)
# Save the filled PDF
with open(output_pdf_path, 'wb') as output:
writer.write(output)
print(f'Successfully filled PDF form and saved to {output_pdf_path}')
print(f'Added {len(annotations)} text annotations')
if __name__ == '__main__':
if len(sys.argv) != 4:
print(
'Usage: fill_pdf_form_with_annotations.py [input pdf] [fields.json] [output pdf]'
)
sys.exit(1)
input_pdf = sys.argv[1]
fields_json = sys.argv[2]
output_pdf = sys.argv[3]
fill_pdf_form(input_pdf, fields_json, output_pdf)

21
configs/application.yaml Normal file
View File

@@ -0,0 +1,21 @@
app:
name: ai-agent-scaffold-go
env: local
server:
addr: ":8091"
database:
required: false
dsn: "root:123456@tcp(127.0.0.1:13306)/ai-agent-scaffold-go?charset=utf8mb4&parseTime=True&loc=Local"
redis:
required: false
addr: "127.0.0.1:16379"
password: ""
db: 0
llm:
# OpenAI 兼容上游每次请求的整体超时;画图等多步 Agent 流程建议放宽
# 支持 Go time.ParseDuration 格式30s / 2m / 5m / 1h留空走默认 5m
request-timeout: 5m
agent:
config-paths:
- configs/agent/only-one-agent.yaml
- configs/agent/agent-draw-io.yaml

View File

@@ -0,0 +1,43 @@
services:
mysql:
image: mysql:8.0.32
container_name: mysql
command: --default-authentication-plugin=mysql_native_password
restart: always
environment:
TZ: Asia/Shanghai
MYSQL_ROOT_PASSWORD: 123456
MYSQL_DATABASE: ai-agent-scaffold
ports:
- "13306:3306"
volumes:
- ./mysql/my.cnf:/etc/mysql/conf.d/mysql.cnf:ro
healthcheck:
test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ]
interval: 5s
timeout: 10s
retries: 10
start_period: 15s
networks:
- ai-agent-scaffold
redis:
image: redis:6.2
container_name: redis
restart: always
ports:
- "16379:6379"
volumes:
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
command: redis-server /usr/local/etc/redis/redis.conf
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 3
networks:
- voice-input-net
networks:
ai-agent-scaffold:
driver: bridge

30
deployments/mysql/my.cnf Normal file
View File

@@ -0,0 +1,30 @@
[client]
port = 3306
default-character-set = utf8mb4
[mysqld]
user = mysql
port = 3306
sql_mode = NO_ENGINE_SUBSTITUTION,STRICT_TRANS_TABLES
default-storage-engine = InnoDB
default-authentication-plugin = mysql_native_password
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
init_connect = 'SET NAMES utf8mb4'
log-bin = mysql-bin
binlog-format = row
server-id = 1
binlog-do-db = big_market_01
binlog-do-db = big_market_02
slow_query_log
#long_query_time = 3
slow-query-log-file = /var/log/mysql/mysql.slow.log
log-error = /var/log/mysql/mysql.error.log
default-time-zone = '+8:00'
[mysql]
default-character-set = utf8mb4

View File

@@ -0,0 +1,2 @@
bind 0.0.0.0
port 6379

43
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/.idea/
/.local-config

60
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,60 @@
# Use Node.js 20 Alpine as the base image
FROM registry.cn-hangzhou.aliyuncs.com/xfg-studio/node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
# Install dependencies
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Accept build argument for API Base URL
ARG NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
# Build the application
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Don't run as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Copy entrypoint script
COPY entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENTRYPOINT ["./entrypoint.sh"]
CMD ["node", "server.js"]

56
frontend/README.md Normal file
View File

@@ -0,0 +1,56 @@
# Draw.io Agent Frontend
This directory contains the migrated Next.js draw.io intelligent drawing frontend for `ai-agent-scaffold-go`.
## Backend API
The frontend talks to the Go backend through:
```text
http://localhost:8091/api/v1
```
Override the API base URL with:
```bash
NEXT_PUBLIC_API_BASE_URL=http://localhost:8091/api/v1
```
Used endpoints:
- `GET /query_ai_agent_config_list`
- `POST /create_session`
- `POST /chat`
The Go chat endpoint returns:
```json
{
"code": "0000",
"info": "success",
"data": {
"content": "<agent output or draw.io xml>"
}
}
```
The frontend treats `data.content` as draw.io XML when it looks like `mxfile` or `mxGraphModel`; otherwise it renders the content as a normal Agent message.
## Development
```bash
npm install
npm run dev
```
Open:
```text
http://localhost:3000
```
## Build
```bash
npm run build
```

15
frontend/build.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
# Default API URL if not provided
DEFAULT_API_URL="http://localhost:8091/api/v1"
API_URL=${1:-$DEFAULT_API_URL}
echo "Building Docker image with API Base URL: $API_URL"
# Build the Docker image
# We pass the NEXT_PUBLIC_API_BASE_URL as a build argument
docker build --platform linux/amd64,linux/arm64 \
--build-arg NEXT_PUBLIC_API_BASE_URL="$API_URL" \
--load -t fuzhengwei/ai-draw-io-front:1.1 .
echo "Build complete. Image tagged as ai-draw-io-front:latest"

View File

@@ -0,0 +1,13 @@
version: '3.8'
services:
ai-draw-io-front:
container_name: ai-draw-io-front
image: fuzhengwei/ai-draw-io-front:1.0
restart: always
ports:
- "3000:3000"
environment:
# Also set it as runtime environment variable (for server-side rendering if applicable)
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8091/api/v1}
- NODE_ENV=production

View File

@@ -0,0 +1,156 @@
# react-drawio
[![npm](https://img.shields.io/npm/v/react-drawio.svg?style=flat)](https://www.npmjs.com/package/react-drawio)
[![Build](https://github.com/marcveens/react-drawio/actions/workflows/build.yml/badge.svg)](https://github.com/marcveens/react-drawio/actions/workflows/build.yml)
[![Storybook demo](https://img.shields.io/badge/-Demo-FF4785?style=flat&logo=storybook&logoColor=white)](https://marcveens.github.io/react-drawio)
React component for integrating the <a href="https://app.diagrams.net">Diagrams</a> (<a href="https://www.drawio.com/">draw.io</a>) embed iframe.
This is an unofficial best-effort package based on the embedding documentation that can be found at https://www.drawio.com/doc/faq/embed-mode.
## Table of Contents
* [Demo](https://marcveens.github.io/react-drawio)
* [Installation](#installation)
* [Examples](#examples)
* [API documentation](#api-documentation)
## Installation
Install this library:
```bash
pnpm add react-drawio
# or
yarn add react-drawio
# or
npm i react-drawio
```
## Examples
### Simple rendering
```tsx
import { DrawIoEmbed } from 'react-drawio';
function App() {
return (
<DrawIoEmbed />
);
}
```
### Start with a few settings enabled
```tsx
import { DrawIoEmbed } from 'react-drawio';
function App() {
return (
<DrawIoEmbed urlParameters={{
ui: 'kennedy',
spin: true,
libraries: true,
saveAndExit: true
}} />
);
}
```
### Start with existing diagram
```tsx
import { DrawIoEmbed } from 'react-drawio';
function App() {
return (
<DrawIoEmbed xml="..." />
);
}
```
### Export diagram programmatically
```tsx
import { DrawIoEmbed, DrawIoEmbedRef } from 'react-drawio';
import { useRef, useState } from 'react';
function App() {
const [imgData, setImgData] = useState<string | null>(null);
const drawioRef = useRef<DrawIoEmbedRef>(null);
const export = () => {
if (drawioRef.current) {
drawioRef.current.exportDiagram({
format: 'xmlsvg'
});
}
};
return (
<>
<button onClick={export}>Export</button>
<DrawIoEmbed
ref={drawioRef}
onExport={(data) => setImgData(data.data)}
/>
{imgData && <img src={imgData} />}
</>
);
}
```
## API Documentation
All options are based on the documentation at <a href="https://www.drawio.com/doc/faq/embed-mode">draw.io/doc/faq/embed-mode</a>. If something is off, please let me know by creating an <a href="https://github.com/marcveens/react-drawio/issues/new">issue</a>.
### `props`
- `autosave` (`boolean`, default: `false`)\
When enabled, it will call `onAutoSave` for all changes made
- `urlParameters` (`UrlParameters`, default: `undefined`)\
Parameters documented at https://www.drawio.com/doc/faq/embed-mode
- `xml` (`string`, default: `undefined`)\
XML structure for prefilling the editor
- `csv` (`string`, default: `undefined`)\
CSV structure for prefilling the editor
- `configuration` (`Object`, default: `undefined`)\
For configuration options, see https://www.drawio.com/doc/faq/configure-diagram-editor
- `exportFormat` (`'html' | 'html2' | 'svg' | 'xmlsvg' | 'png' | 'xmlpng'`, default: `xmlsvg`)\
Set export format
- `baseUrl` (`string`, default: `https://embed.diagrams.net`)\
For self hosted instances of draw.io, insert your URL here
- `onLoad` (`(data: EventLoad) => void`, optional)
- `onAutoSave` (`(data: EventAutoSave) => void`, optional)\
This will only trigger when the `autosave` property is `true`
- `onSave` (`(data: EventSave) => void`, optional)
- `onClose` (`(data: EventExit) => void`, optional)
- `onConfigure` (`(data: EventConfigure) => void`, optional)
- `onMerge` (`(data: EventMerge) => void`, optional)
- `onPrompt` (`(data: EventPrompt) => void`, optional)
- `onTemplate` (`(data: EventTemplate) => void`, optional)
- `onDraft` (`(data: EventDraft) => void`, optional)
- `onExport` (`(data: EventExport) => void`, optional)
### Actions
It is possible to send actions to the Diagrams iframe. These actions are available as functions bound to the `ref` of the component, see [examples](#examples).
- `load` (`(obj: ActionLoad) => void`)\
Load the contents of a diagram
- `configure` (`(obj: ActionConfigure) => void`)\
Send configuration option to the iframe. Read more about it at https://www.drawio.com/doc/faq/configure-diagram-editor
- `merge` (`(obj: ActionMerge) => void`)\
Merge the contents of the given XML into the current file
- `dialog` (`(obj: ActionDialog) => void`)\
Display a dialog in the editor window
- `prompt` (`(obj: ActionPrompt) => void`)\
Display a prompt in the editor window
- `template` (`(obj: ActionTemplate) => void`)\
Show the template dialog
- `layout` (`(obj: ActionLayout) => void`)\
Runs an array of layouts using the same format as Arrange > Layout > Apply.
- `draft` (`(obj: ActionDraft) => void`)\
Show a draft dialog
- `status` (`(obj: ActionStatus) => void`)\
Display a message in the status bar
- `spinner` (`(obj: ActionSpinner) => void`)\
Display a spinner with a message or hide the current spinner if show is set to false
- `exportDiagram` (`(obj: ActionExport) => void`)

21
frontend/entrypoint.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/bin/sh
# Default to the build-time variable or localhost if not set
API_URL=${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8091/api/v1}
echo "Generating env-config.js with API_URL: $API_URL"
# Write the environment configuration to a public file
cat <<EOF > /app/public/env-config.js
window.__ENV = {
NEXT_PUBLIC_API_BASE_URL: "$API_URL",
};
EOF
# Also update the standalone server public folder if it exists (for production)
if [ -d "/app/.next/standalone/public" ]; then
cp /app/public/env-config.js /app/.next/standalone/public/env-config.js
fi
# Execute the passed command
exec "$@"

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

8
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;

6548
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "ai-agent-scaffold-draw-io-front",
"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",
"react-drawio": "^1.0.7"
},
"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"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
frontend/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
frontend/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

41
frontend/push.sh Normal file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
# https://cr.console.aliyun.com/cn-hangzhou/instance/credentials
# Ensure the script exits if any command fails
set -e
# Define variables for the registry and image
ALIYUN_REGISTRY="registry.cn-hangzhou.aliyuncs.com"
NAMESPACE="fuzhengwei"
IMAGE_NAME="ai-draw-io-front"
IMAGE_TAG="1.1"
# 读取本地配置文件
if [ -f ".local-config" ]; then
source .local-config
else
echo ".local-config 文件不存在,请创建并填写 ALIYUN_USERNAME 和 ALIYUN_PASSWORD"
exit 1
fi
# Login to Aliyun Docker Registry
echo "Logging into Aliyun Docker Registry..."
docker login --username="${ALIYUN_USERNAME}" --password="${ALIYUN_PASSWORD}" $ALIYUN_REGISTRY
# Tag the Docker image
echo "Tagging the Docker image..."
docker tag ${NAMESPACE}/${IMAGE_NAME}:${IMAGE_TAG} ${ALIYUN_REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${IMAGE_TAG}
# Push the Docker image to Aliyun
echo "Pushing the Docker image to Aliyun..."
docker push ${ALIYUN_REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${IMAGE_TAG}
echo "Docker image pushed successfully! "
echo "检出地址docker pull ${ALIYUN_REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${IMAGE_TAG}"
echo "标签设置docker tag ${ALIYUN_REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${IMAGE_TAG} ${NAMESPACE}/${IMAGE_NAME}:${IMAGE_TAG}"
# Logout from Aliyun Docker Registry
echo "Logging out from Aliyun Docker Registry..."
docker logout $ALIYUN_REGISTRY

66
frontend/src/api/agent.ts Normal file
View File

@@ -0,0 +1,66 @@
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 error! status: ${response.status}, message: ${errorText}`);
}
const data = await response.json();
if (data.code !== "0000") {
throw new Error(data.info || 'Unknown API error');
}
return data;
};
export const agentApi = {
/**
* Query AI Agent Config List
* Path: /api/v1/query_ai_agent_config_list
*/
queryAiAgentConfigList: async (): Promise<Response<AiAgentConfigResponseDTO[]>> => {
const response = await fetch(`${API_CONFIG.BASE_URL}/query_ai_agent_config_list`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return handleResponse<AiAgentConfigResponseDTO[]>(response);
},
/**
* Create Session
* Path: /api/v1/create_session
*/
createSession: async (agentId: string, userId: string): Promise<Response<CreateSessionResponseDTO>> => {
const response = await fetch(`${API_CONFIG.BASE_URL}/create_session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ agentId, userId }),
});
return handleResponse<CreateSessionResponseDTO>(response);
},
/**
* Chat
* Path: /api/v1/chat
*/
chat: async (data: ChatRequestDTO): Promise<Response<ChatResponseDTO>> => {
const response = await fetch(`${API_CONFIG.BASE_URL}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
return handleResponse<ChatResponseDTO>(response);
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,96 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
/* Custom Theme Variables from login.html/index.html */
--bg0: #070a12;
--bg1: #0b1022;
--card: rgba(255, 255, 255, 0.06);
--card2: rgba(255, 255, 255, 0.08);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.72);
--muted2: rgba(255, 255, 255, 0.56);
--border: rgba(255, 255, 255, 0.12);
--primary: #62f6c7;
--primary2: #5aa9ff;
--danger: #ff5a7a;
--shadow: 0 22px 60px rgba(0, 0, 0, 0.55);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: Arial, Helvetica, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* Utility classes for the custom theme */
.theme-bg-gradient {
background: radial-gradient(1200px 600px at 20% 18%, rgba(98, 246, 199, 0.24), rgba(98, 246, 199, 0) 55%),
radial-gradient(900px 560px at 78% 20%, rgba(90, 169, 255, 0.22), rgba(90, 169, 255, 0) 55%),
radial-gradient(900px 640px at 55% 78%, rgba(255, 90, 122, 0.12), rgba(255, 90, 122, 0) 55%),
linear-gradient(180deg, var(--bg0), var(--bg1));
}
.theme-card {
border: 1px solid var(--border);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03));
box-shadow: var(--shadow);
}
.theme-input {
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.18);
color: var(--text);
}
.theme-input:focus {
border-color: rgba(98, 246, 199, 0.55);
box-shadow: 0 0 0 4px rgba(98, 246, 199, 0.12);
}
.theme-btn {
background: linear-gradient(135deg, var(--primary), var(--primary2));
color: rgba(7, 10, 18, 0.92);
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.35);
}
.theme-btn-secondary {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
border: 1px solid rgba(255, 255, 255, 0.14);
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1; /* slate-300 */
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8; /* slate-400 */
}

View File

@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Agent-Draw-IO",
description: "智能体交互绘图 @小傅哥",
};
import Script from "next/script";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<head>
<Script src="/env-config.js" strategy="beforeInteractive" />
</head>
<body className="antialiased">
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,178 @@
'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 userInfo = getUserInfo();
if (userInfo && userInfo.user) {
setIsLoggedIn(true);
setCurrentUser(userInfo.user);
// If already logged in, redirect to home
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 handleFillDemo = () => {
setUsername('admin');
setPassword('admin');
setMsg({ text: '已填充演示账号。', type: 'info' });
};
const handleLogout = () => {
clearUserInfo();
setIsLoggedIn(false);
setCurrentUser('');
setMsg({ text: '已退出登录cookie 已清除。', type: 'info' });
};
return (
<div className="min-h-screen flex justify-center items-stretch p-7 theme-bg-gradient">
<div className="w-full max-w-[1120px] grid grid-cols-1 lg:grid-cols-[1.25fr_0.75fr] gap-[18px]">
{/* Hero Section */}
<section className="theme-card rounded-[18px] overflow-hidden relative flex flex-col gap-[18px] p-[28px_28px_22px_28px]">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-[14px] grid place-items-center bg-gradient-to-br from-[#62f6c7] to-[#5aa9ff] shadow-[0_10px_24px_rgba(0,0,0,0.4)] text-[rgba(7,10,18,0.92)] font-extrabold text-lg tracking-[0.5px]">
AI
</div>
<div className="flex flex-col gap-1">
<strong className="text-base leading-[1.1] tracking-[0.2px] text-[rgba(255,255,255,0.92)]">
AI By Ai Agent Scaffold - @小傅哥
</strong>
<span className="text-xs text-[rgba(255,255,255,0.56)]"> · · </span>
</div>
</div>
<h1 className="mt-[6px] text-[30px] leading-[1.2] tracking-[0.2px] text-[rgba(255,255,255,0.92)] font-bold">
</h1>
<p className="m-0 text-[rgba(255,255,255,0.72)] leading-[1.7] max-w-[52ch] text-sm">
<b>admin</b> <b>admin</b> cookie
</p>
<div className="grid grid-cols-2 gap-3 mt-[6px]">
{[
{ title: '工具调用', desc: '支持 API / Shell / 文件等执行链路编排' },
{ title: '记忆与上下文', desc: '可配置可审计,减少重复沟通成本' },
{ title: '多模型路由', desc: '按场景选择最合适的模型与策略' },
{ title: '可观测性', desc: '链路、成本、失败原因都能追踪' },
].map((item, idx) => (
<div key={idx} className="border border-[rgba(255,255,255,0.08)] bg-[rgba(255,255,255,0.04)] rounded-[14px] p-3 flex gap-[10px] items-start">
<div className="w-[10px] h-[10px] rounded-full mt-[5px] flex-shrink-0 bg-gradient-to-br from-[#62f6c7] to-[#5aa9ff] shadow-[0_0_0_4px_rgba(98,246,199,0.08)]"></div>
<div>
<b className="block text-[13px] mb-[3px] text-[rgba(255,255,255,0.92)]">{item.title}</b>
<span className="block text-xs text-[rgba(255,255,255,0.56)] leading-[1.5]">{item.desc}</span>
</div>
</div>
))}
</div>
<div className="mt-[10px] rounded-[16px] overflow-hidden border border-[rgba(255,255,255,0.10)] bg-[rgba(0,0,0,0.24)] h-[340px] relative">
{/* Placeholder for Hero Image - mimicking the original svg placeholder */}
<div className="w-full h-full flex items-center justify-center text-[rgba(255,255,255,0.2)] text-sm">
AI
</div>
</div>
</section>
{/* Login Form Section */}
<section className="p-[28px] flex flex-col justify-center gap-[14px]">
<div className="theme-card rounded-[16px] p-5">
<h2 className="m-0 mb-[6px] text-[18px] text-[rgba(255,255,255,0.92)] font-bold"></h2>
<p className="m-0 mb-4 text-[rgba(255,255,255,0.56)] text-xs leading-[1.5]">
admin / admin
</p>
{!isLoggedIn ? (
<form onSubmit={handleLogin} autoComplete="on">
<div className="flex flex-col gap-2 mb-3">
<label htmlFor="username" className="text-xs text-[rgba(255,255,255,0.72)] tracking-[0.2px]"></label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入账号"
autoComplete="username"
className="w-full rounded-[12px] theme-input p-3 outline-none transition-all duration-180 text-sm"
/>
</div>
<div className="flex flex-col gap-2 mb-3">
<label htmlFor="password" className="text-xs text-[rgba(255,255,255,0.72)] tracking-[0.2px]"></label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
autoComplete="current-password"
className="w-full rounded-[12px] theme-input p-3 outline-none transition-all duration-180 text-sm"
/>
</div>
<div className="flex gap-[10px] items-center justify-between mt-[6px]">
<button type="submit" className="theme-btn rounded-[12px] p-[11px_14px] font-bold cursor-pointer border-0 transition-transform active:translate-y-[1px] active:brightness-[0.98] text-sm">
Cookie
</button>
<button type="button" onClick={handleFillDemo} className="theme-btn-secondary rounded-[12px] p-[11px_14px] font-semibold cursor-pointer transition-transform active:translate-y-[1px] active:brightness-[0.98] text-sm">
</button>
</div>
</form>
) : (
<div className="flex gap-[10px] items-center justify-between p-3 border border-dashed border-[rgba(255,255,255,0.18)] rounded-[12px] bg-[rgba(255,255,255,0.04)] mt-3">
<div>
<strong className="block text-[13px] text-[rgba(255,255,255,0.92)]">{currentUser}</strong>
<span className="block text-xs text-[rgba(255,255,255,0.56)] mt-[2px]"></span>
</div>
<button onClick={handleLogout} className="theme-btn-secondary rounded-[12px] p-[8px_12px] font-semibold cursor-pointer text-xs">
退
</button>
</div>
)}
<div className={`min-h-[18px] text-xs mt-2 ${msg.type === 'error' ? 'text-[#ff5a7a]' : 'text-[rgba(255,255,255,0.56)]'}`}>
{msg.text}
</div>
</div>
<div className="mt-[14px] text-[rgba(255,255,255,0.35)] text-xs text-center">
© AI Agent Scaffold · Next.js
</div>
</section>
</div>
</div>
);
}

998
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,998 @@
'use client';
import { DrawIoEmbed, DrawIoEmbedRef } from 'react-drawio';
import { useRef, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { getUserInfo, clearUserInfo } from '@/utils/cookie';
import { agentApi } from '@/api/agent';
import { AiAgentConfigResponseDTO } from '@/types/api';
// Message type definition
type Message = {
id: string;
role: 'user' | 'agent';
content: string;
timestamp: number;
};
const isDrawIoXmlContent = (content: string) => {
const trimmed = content.trim();
return trimmed.startsWith('<mxfile') ||
trimmed.startsWith('<mxGraphModel') ||
trimmed.includes('<mxCell') ||
trimmed.includes('<diagram');
};
// Elegant SVG Icons with consistent styling
const Icons = {
Chat: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
),
Close: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
),
Send: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
),
User: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
),
Bot: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M12 2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2 2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"></path>
<path d="M4 11v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2z"></path>
<path d="M9 22v-3"></path>
<path d="M15 22v-3"></path>
</svg>
),
Download: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
),
Sparkles: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
</svg>
),
Logout: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
),
Layers: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
<polyline points="2 17 12 22 22 17"></polyline>
<polyline points="2 12 12 17 22 12"></polyline>
</svg>
),
Loader: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`animate-spin ${className}`}>
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
</svg>
),
Plus: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
),
Trash: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
),
MessageSquare: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
)
};
interface Session {
id: string;
backendSessionId?: string;
title: string;
messages: Message[];
drawIoXml: string | null;
lastModified: number;
}
export default function Home() {
const router = useRouter();
const [imgData, setImgData] = useState<string | null>(null);
const drawioRef = useRef<DrawIoEmbedRef>(null);
// User State
const [currentUser, setCurrentUser] = useState('');
// Chat State
const [isChatOpen, setIsChatOpen] = useState(true);
const [messages, setMessages] = useState<Message[]>([
{
id: '1',
role: 'agent',
content: '你好!我是你的智能架构助手。请选择一个智能体开始对话。',
timestamp: Date.now()
}
]);
const [inputValue, setInputValue] = useState('');
const [isSending, setIsSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Context State
const [useHistoryContext, setUseHistoryContext] = useState(false);
const [lastExportedData, setLastExportedData] = useState<{data: string, timestamp: number} | null>(null);
const isExportingForChatRef = useRef(false);
const isAutosaveRef = useRef(false);
const pendingMessageRef = useRef('');
const [isDrawIoReady, setIsDrawIoReady] = useState(false);
const initialLoadDoneRef = useRef(false);
// Agent State
const [agents, setAgents] = useState<AiAgentConfigResponseDTO[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState('');
const [sessionId, setSessionId] = useState('');
// Rename State
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
const [renamingSessionId, setRenamingSessionId] = useState<string | null>(null);
const [newSessionTitle, setNewSessionTitle] = useState('');
// Session Management State
const [sessions, setSessions] = useState<Session[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const currentSessionRef = useRef(currentSessionId);
// Update ref
useEffect(() => {
currentSessionRef.current = currentSessionId;
}, [currentSessionId]);
// Handle Initial Load
useEffect(() => {
if (!initialLoadDoneRef.current && isDrawIoReady && currentSessionId && sessions.length > 0) {
const session = sessions.find(s => s.id === currentSessionId);
if (session && session.drawIoXml && drawioRef.current) {
drawioRef.current.load({ xml: session.drawIoXml });
}
initialLoadDoneRef.current = true;
}
}, [isDrawIoReady, currentSessionId, sessions]);
// Load sessions from localStorage
useEffect(() => {
const savedSessions = localStorage.getItem('drawio_sessions');
if (savedSessions) {
try {
const parsed = JSON.parse(savedSessions);
setSessions(parsed);
if (parsed.length > 0) {
// Load the most recent session (first one if sorted by lastModified desc)
const mostRecent = parsed.sort((a: Session, b: Session) => b.lastModified - a.lastModified)[0];
setCurrentSessionId(mostRecent.id);
setMessages(mostRecent.messages);
// Note: Draw.io XML loading happens after drawioRef is ready or when we switch
} else {
createNewSession(true);
}
} catch (e) {
console.error('Failed to parse sessions:', e);
createNewSession(true);
}
} else {
createNewSession(true);
}
}, []);
// Save sessions to localStorage whenever they change
useEffect(() => {
if (sessions.length > 0) {
try {
localStorage.setItem('drawio_sessions', JSON.stringify(sessions));
} catch (e) {
console.error('Failed to save sessions to localStorage:', e);
}
}
}, [sessions]);
// Update current session messages and backendSessionId when they change
useEffect(() => {
if (currentSessionId) {
setSessions(prev => prev.map(session => {
if (session.id === currentSessionId) {
return {
...session,
messages,
backendSessionId: sessionId,
// Update title if it's the default "New Chat" and we have a user message
title: session.title === 'New Chat' && messages.find(m => m.role === 'user')
? (messages.find(m => m.role === 'user')?.content.slice(0, 20) || 'New Chat')
: session.title
};
}
return session;
}));
}
}, [messages, currentSessionId, sessionId]);
const createNewSession = (isInitial = false, backendId = '') => {
const newSession: Session = {
id: Date.now().toString(),
backendSessionId: backendId,
title: 'New Chat',
messages: [{
id: Date.now().toString(),
role: 'agent',
content: '你好!我是你的智能架构助手。请选择一个智能体开始对话。',
timestamp: Date.now()
}],
drawIoXml: null,
lastModified: Date.now()
};
setSessions(prev => [newSession, ...prev]);
setCurrentSessionId(newSession.id);
setMessages(newSession.messages);
setSessionId(backendId);
if (!isInitial && drawioRef.current) {
drawioRef.current.load({ xml: '' }); // Clear diagram
}
};
const handleSwitchSession = (targetSessionId: string) => {
if (targetSessionId === currentSessionId) return;
loadSession(targetSessionId);
};
const loadSession = (targetSessionId: string) => {
const session = sessions.find(s => s.id === targetSessionId);
if (session) {
setCurrentSessionId(targetSessionId);
setMessages(session.messages);
setSessionId(session.backendSessionId || '');
if (drawioRef.current && session.drawIoXml) {
drawioRef.current.load({ xml: session.drawIoXml });
} else if (drawioRef.current) {
drawioRef.current.load({ xml: '' });
}
}
};
const handleDeleteSession = (e: React.MouseEvent, sessionIdToDelete: string) => {
e.stopPropagation();
const newSessions = sessions.filter(s => s.id !== sessionIdToDelete);
setSessions(newSessions);
localStorage.setItem('drawio_sessions', JSON.stringify(newSessions));
if (currentSessionId === sessionIdToDelete) {
if (newSessions.length > 0) {
loadSession(newSessions[0].id);
} else {
createNewSession();
}
}
};
const handleDoubleClickSession = (session: Session) => {
setRenamingSessionId(session.id);
setNewSessionTitle(session.title);
setIsRenameModalOpen(true);
};
const handleRenameSave = () => {
if (renamingSessionId && newSessionTitle.trim()) {
setSessions(prev => prev.map(s =>
s.id === renamingSessionId ? { ...s, title: newSessionTitle.trim() } : s
));
setIsRenameModalOpen(false);
setRenamingSessionId(null);
setNewSessionTitle('');
}
};
const exportDiagram = () => {
if (drawioRef.current) {
drawioRef.current.exportDiagram({
format: 'xmlsvg'
});
}
};
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages, isChatOpen]);
// Check Login & Load Agents
useEffect(() => {
const userInfo = getUserInfo();
if (!userInfo || !userInfo.user) {
router.push('/login');
return;
}
setCurrentUser(userInfo.user);
// Load Agents
const loadAgents = async () => {
try {
const res = await agentApi.queryAiAgentConfigList();
setAgents(res.data || []);
if (res.data && res.data.length > 0) {
// Try to restore last agent or default to first
const lastAgentId = localStorage.getItem('ai_agent_last_agent');
if (lastAgentId && res.data.find(a => a.agentId === lastAgentId)) {
setSelectedAgentId(lastAgentId);
} else {
setSelectedAgentId(res.data[0].agentId);
}
}
} catch (error) {
console.error('Failed to load agents:', error);
setMessages(prev => [...prev, {
id: Date.now().toString(),
role: 'agent',
content: '加载智能体列表失败,请检查后端服务是否启动。',
timestamp: Date.now()
}]);
}
};
loadAgents();
}, [router]);
const handleLogout = () => {
clearUserInfo();
router.push('/login');
};
const handleAgentChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newAgentId = e.target.value;
setSelectedAgentId(newAgentId);
setSessionId(''); // Reset session when agent changes
localStorage.setItem('ai_agent_last_agent', newAgentId);
};
const finalizeNewChat = async () => {
if (!selectedAgentId || !currentUser) return;
try {
const res = await agentApi.createSession(selectedAgentId, currentUser);
createNewSession(false, res.data.sessionId);
setInputValue('');
} catch (error) {
console.error('Failed to create new session:', error);
}
};
const handleNewChat = async () => {
finalizeNewChat();
};
const handleRestartSession = async () => {
if (!selectedAgentId || !currentUser) return;
if (!currentSessionId) {
finalizeNewChat();
return;
}
try {
const res = await agentApi.createSession(selectedAgentId, currentUser);
const newBackendId = res.data.sessionId;
const initialMsg: Message = {
id: Date.now().toString(),
role: 'agent',
content: '你好!我是你的智能架构助手。请选择一个智能体开始对话。',
timestamp: Date.now()
};
setSessionId(newBackendId);
setMessages([initialMsg]);
setInputValue('');
setSessions(prev => prev.map(session => {
if (session.id === currentSessionId) {
return {
...session,
backendSessionId: newBackendId,
messages: [initialMsg],
lastModified: Date.now()
};
}
return session;
}));
} catch (error) {
console.error('Failed to restart session:', error);
}
};
const performSendMessage = async (displayContent: string, apiContent: string) => {
if (!selectedAgentId) {
setMessages(prev => [...prev, {
id: Date.now().toString(),
role: 'agent',
content: '请先选择一个智能体。',
timestamp: Date.now()
}]);
setIsSending(false);
return;
}
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
content: displayContent,
timestamp: Date.now()
};
setMessages(prev => [...prev, userMsg]);
try {
// 1. Ensure Session
let activeBackendSessionId = sessionId;
if (!activeBackendSessionId) {
const sessionRes = await agentApi.createSession(selectedAgentId, currentUser);
activeBackendSessionId = sessionRes.data.sessionId;
setSessionId(activeBackendSessionId);
}
// Update session lastModified
setSessions(prev => prev.map(session => {
if (session.id === currentSessionId) {
return { ...session, lastModified: Date.now() };
}
return session;
}));
// 2. Send Message
const chatRes = await agentApi.chat({
agentId: selectedAgentId,
userId: currentUser,
sessionId: activeBackendSessionId,
message: apiContent
});
const { content } = chatRes.data;
// Go backend returns { content }; infer whether the content is draw.io XML.
if (!isDrawIoXmlContent(content)) {
const agentMsg: Message = {
id: (Date.now() + 1).toString(),
role: 'agent',
content: content,
timestamp: Date.now()
};
setMessages(prev => [...prev, agentMsg]);
} else {
// Save to session immediately (always update the session that initiated the request)
setSessions(prev => prev.map(session => {
if (session.id === currentSessionId) {
return {
...session,
drawIoXml: content,
lastModified: Date.now()
};
}
return session;
}));
// Render only if still on the same session
if (drawioRef.current && currentSessionId === currentSessionRef.current) {
try {
drawioRef.current.load({
xml: content
});
} catch (e) {
console.error('Failed to load diagram:', e);
}
}
}
} catch (error) {
console.error('Chat error:', error);
const errorMsg: Message = {
id: (Date.now() + 1).toString(),
role: 'agent',
content: error instanceof Error ? `Error: ${error.message}` : '发送失败,请重试。',
timestamp: Date.now()
};
setMessages(prev => [...prev, errorMsg]);
} finally {
setIsSending(false);
}
};
const handleSendMessage = async () => {
if (!inputValue.trim() || isSending) return;
const content = inputValue;
setInputValue('');
setIsSending(true);
if (useHistoryContext && drawioRef.current && isDrawIoReady) {
isExportingForChatRef.current = true;
pendingMessageRef.current = content;
try {
drawioRef.current.exportDiagram({
format: 'xml' as any
});
} catch (e) {
console.error("Export failed", e);
performSendMessage(content, content);
}
} else {
performSendMessage(content, content);
}
};
useEffect(() => {
if (!lastExportedData) return;
if (isExportingForChatRef.current) {
isExportingForChatRef.current = false;
const xml = lastExportedData.data;
const content = pendingMessageRef.current;
const apiContent = `[Context: Current Draw.io XML]\n\`\`\`xml\n${xml}\n\`\`\`\n\n${content}`;
performSendMessage(content, apiContent);
return;
}
// Autosave handling
if (isAutosaveRef.current) {
isAutosaveRef.current = false;
const xml = lastExportedData.data;
setSessions(prev => prev.map(s => {
if (s.id === currentSessionId) {
return { ...s, drawIoXml: xml };
}
return s;
}));
return;
}
// Manual Export
setImgData(lastExportedData.data);
}, [lastExportedData]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSendMessage();
}
};
const quickActions = [
{ label: '绘制h5端登录流程图', text: '请帮我绘制一个H5端的登录流程图包含用户输入手机号、获取验证码、验证登录等步骤。' },
{ label: '绘制电商购物流程图', text: '请帮我绘制一个电商购物流程图,包含商品浏览、加入购物车、下单、支付、发货等环节。' }
];
return (
<div className="flex flex-col h-screen w-full overflow-hidden bg-slate-50 text-slate-900 font-sans">
{/* Header - Minimal & Clean */}
<div className="h-14 px-6 bg-white border-b border-slate-200 flex items-center justify-between shrink-0 z-40 relative">
<div className="flex items-center gap-3">
<div className="bg-indigo-600 p-1.5 rounded-lg shadow-sm shadow-indigo-200">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</div>
<h1 className="text-lg font-bold text-slate-800 tracking-tight">ai + draw.io <span className="text-slate-400 font-normal text-sm ml-2">@小傅哥</span></h1>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-full border border-slate-200 shadow-sm">
<div className="w-2 h-2 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.4)]"></div>
<span className="text-xs font-semibold text-slate-600">{currentUser || 'Guest'}</span>
</div>
<div className="h-6 w-px bg-slate-200 mx-1"></div>
<button
onClick={exportDiagram}
className="flex items-center gap-2 px-4 py-1.5 bg-white border border-slate-200 text-slate-600 rounded-lg hover:bg-slate-50 hover:border-slate-300 hover:text-slate-900 transition-all text-sm font-medium shadow-sm active:scale-95"
>
<Icons.Download className="w-4 h-4" />
Export
</button>
<button
onClick={handleLogout}
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Logout"
>
<Icons.Logout />
</button>
{!isChatOpen && (
<button
onClick={() => setIsChatOpen(true)}
className="p-2 text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-lg transition-colors border border-indigo-100"
title="Open Assistant"
>
<Icons.Chat />
</button>
)}
</div>
</div>
{/* Main Layout */}
<div className="flex flex-1 w-full overflow-hidden relative">
{/* Sessions Sidebar */}
<div className="w-64 bg-white text-slate-600 flex flex-col border-r border-slate-200 shrink-0 z-30">
<div className="h-14 px-4 flex items-center justify-between border-b border-slate-100 shrink-0">
<span className="font-semibold text-slate-800 flex items-center gap-2">
<Icons.MessageSquare className="w-4 h-4 text-indigo-600" />
</span>
<button
onClick={handleNewChat}
className="p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-md transition-all"
title="New Chat"
>
<Icons.Plus className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1 scrollbar-thin scrollbar-thumb-slate-200 scrollbar-track-transparent">
{[...sessions].sort((a, b) => b.lastModified - a.lastModified).map(session => (
<div
key={session.id}
onClick={() => handleSwitchSession(session.id)}
onDoubleClick={(e) => { e.stopPropagation(); handleDoubleClickSession(session); }}
className={`
group flex items-center gap-3 px-3 py-3 rounded-lg cursor-pointer transition-all border border-transparent
${currentSessionId === session.id
? 'bg-indigo-50 text-indigo-700 border-indigo-100 shadow-sm'
: 'hover:bg-slate-50 text-slate-600 hover:text-slate-900'
}
`}
>
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${currentSessionId === session.id ? 'text-indigo-700' : 'text-slate-700 group-hover:text-slate-900'}`}>
{session.title}
</div>
<div className={`text-[10px] mt-0.5 ${currentSessionId === session.id ? 'text-indigo-400' : 'text-slate-400'}`}>
{new Date(session.lastModified).toLocaleDateString()} {new Date(session.lastModified).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</div>
</div>
<button
onClick={(e) => handleDeleteSession(e, session.id)}
className={`
p-1.5 rounded-md transition-all opacity-0 group-hover:opacity-100
${currentSessionId === session.id
? 'hover:bg-indigo-100 text-indigo-400 hover:text-indigo-700'
: 'hover:bg-red-50 text-slate-400 hover:text-red-500'
}
`}
title="Delete"
>
<Icons.Trash className="w-4 h-4" />
</button>
</div>
))}
{sessions.length === 0 && (
<div className="text-center py-10 text-xs text-slate-400">
No history yet
</div>
)}
</div>
</div>
{/* Draw.io Canvas Area */}
<div className="flex-1 relative bg-slate-50 h-full flex flex-col">
<div className="flex-1 m-3 rounded-2xl overflow-hidden border border-slate-200 shadow-sm bg-white ring-1 ring-slate-100">
<DrawIoEmbed
ref={drawioRef}
autosave={true}
onAutoSave={(data) => {
if (currentSessionId && isDrawIoReady && !isExportingForChatRef.current) {
// Prefer using the XML directly from the autosave event if available
if (data && typeof data === 'object' && 'xml' in data) {
const xmlContent = (data as any).xml;
setSessions(prev => prev.map(s => {
if (s.id === currentSessionId) {
return { ...s, drawIoXml: xmlContent };
}
return s;
}));
} else {
// Fallback to export if no XML provided in event
isAutosaveRef.current = true;
drawioRef.current?.exportDiagram({ format: 'xml' as any });
}
}
}}
onLoad={() => setIsDrawIoReady(true)}
onExport={(data) => setLastExportedData({ data: data.data, timestamp: Date.now() })}
urlParameters={{
ui: 'atlas', // More modern UI theme for draw.io
spin: true,
libraries: true,
saveAndExit: false,
noSaveBtn: true,
noExitBtn: true
}}
/>
</div>
</div>
{/* Chat Sidebar - Modern & Elegant */}
<div
className={`
border-l border-slate-200 bg-white flex flex-col transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)]
${isChatOpen ? 'w-[380px] translate-x-0' : 'w-0 translate-x-full opacity-0 overflow-hidden'}
shadow-xl z-20
`}
>
{/* Chat Header */}
<div className="h-14 px-5 border-b border-slate-100 flex items-center justify-between shrink-0 bg-white/80 backdrop-blur-sm sticky top-0 z-10">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white shadow-md shadow-indigo-200 shrink-0 ring-2 ring-white">
<Icons.Sparkles className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<select
value={selectedAgentId}
onChange={handleAgentChange}
className="w-full bg-transparent text-sm font-bold text-slate-800 focus:outline-none cursor-pointer truncate appearance-none pr-4"
style={{ backgroundImage: 'none' }}
>
{agents.length === 0 && <option value="">Loading agents...</option>}
{agents.map(agent => (
<option key={agent.agentId} value={agent.agentId}>
{agent.agentName}
</option>
))}
</select>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
<span className="text-[10px] text-slate-500 font-medium leading-tight">AI Assistant Online</span>
</div>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsChatOpen(false)}
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-all shrink-0"
>
<Icons.Close className="w-5 h-5" />
</button>
</div>
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-5 space-y-6 bg-slate-50/50 scrollbar-thin scrollbar-thumb-slate-200 scrollbar-track-transparent">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}
>
<div className={`
shrink-0 w-8 h-8 rounded-full flex items-center justify-center shadow-sm mt-1 ring-2 ring-white
${msg.role === 'user'
? 'bg-indigo-100 text-indigo-600'
: 'bg-white text-indigo-500 border border-slate-100'
}
`}>
{msg.role === 'user' ? <Icons.User className="w-5 h-5" /> : <Icons.Bot className="w-5 h-5" />}
</div>
<div className="flex flex-col max-w-[85%]">
<span className={`text-[10px] mb-1.5 font-medium ${msg.role === 'user' ? 'text-right text-slate-400' : 'text-left text-slate-400'}`}>
{msg.role === 'user' ? 'You' : 'Agent'}
</span>
<div
className={`
p-3.5 text-sm leading-relaxed shadow-sm whitespace-pre-wrap
${msg.role === 'user'
? 'bg-indigo-600 text-white rounded-2xl rounded-tr-sm shadow-indigo-200'
: 'bg-white border border-slate-200 text-slate-700 rounded-2xl rounded-tl-sm shadow-sm'
}
`}
>
{msg.content}
</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-4 bg-white border-t border-slate-100 shrink-0 relative z-20 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.02)]">
{/* Quick Actions - Only show when chat is empty (just greeting) */}
{messages.length <= 1 && (
<div className="flex flex-wrap gap-2 mb-3 px-1 animate-in fade-in slide-in-from-bottom-2 duration-300">
{quickActions.map((action, idx) => (
<button
key={idx}
onClick={() => setInputValue(action.text)}
className="text-xs px-3 py-1.5 bg-indigo-50 text-indigo-600 rounded-full hover:bg-indigo-100 transition-colors border border-indigo-100 font-medium shadow-sm"
>
{action.label}
</button>
))}
</div>
)}
{/* Context Toolbar */}
<div className="flex items-center gap-2 mb-2 px-1">
<button
onClick={() => setUseHistoryContext(!useHistoryContext)}
className={`
flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-xs font-medium transition-all border shadow-sm
${useHistoryContext
? 'bg-indigo-50 text-indigo-600 border-indigo-200 ring-1 ring-indigo-100'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50 hover:text-slate-700'
}
`}
>
<Icons.Layers className={`w-3.5 h-3.5 ${useHistoryContext ? 'text-indigo-500' : 'text-slate-400'}`} />
<span></span>
</button>
<span className="text-[10px] text-slate-400 ml-auto hidden sm:inline-block">
Press <kbd className="font-sans px-1 py-0.5 bg-slate-100 border border-slate-200 rounded text-slate-500">Ctrl/Command</kbd> + <kbd className="font-sans px-1 py-0.5 bg-slate-100 border border-slate-200 rounded text-slate-500">Enter</kbd>
</span>
</div>
<div className="relative flex items-end gap-2 bg-slate-50 p-1.5 rounded-xl border border-slate-200 focus-within:border-indigo-300 focus-within:ring-4 focus-within:ring-indigo-50/50 focus-within:bg-white transition-all shadow-inner">
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isSending ? "AI 正在思考中..." : "输入您的问题,描述您的需求..."}
disabled={isSending}
className="flex-1 px-3 py-2 bg-transparent border-none focus:ring-0 text-sm text-slate-800 placeholder:text-slate-400 resize-none max-h-60 min-h-[50px] scrollbar-thin scrollbar-thumb-slate-200 scrollbar-track-transparent"
rows={1}
style={{ height: 'auto', minHeight: '50px' }}
/>
<div className="flex gap-1 mb-0.5 shrink-0">
<button
onClick={handleSendMessage}
disabled={!inputValue.trim() || isSending}
className={`
p-2.5 rounded-lg transition-all duration-200 flex items-center justify-center
${inputValue.trim() && !isSending
? 'bg-indigo-600 text-white shadow-md shadow-indigo-200 hover:bg-indigo-700 hover:scale-105 active:scale-95'
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
}
`}
>
{isSending ? <Icons.Loader className="w-4 h-4" /> : <Icons.Send className="w-4 h-4" />}
</button>
<button
onClick={handleRestartSession}
className="p-2.5 rounded-lg bg-white text-slate-400 hover:bg-slate-50 hover:text-indigo-600 transition-all duration-200 border border-slate-200 hover:border-indigo-100 shadow-sm"
title="Restart Session"
>
<Icons.Plus className="w-4 h-4" />
</button>
</div>
</div>
<div className="text-center mt-2.5">
<p className="text-[10px] text-slate-400 font-medium">
{isSending ? 'AI is generating response...' : 'AI can make mistakes. Please verify important info.'}
</p>
</div>
</div>
</div>
</div>
{/* Export Modal - Polished */}
{imgData && (
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-50 p-6 animate-in fade-in duration-200">
<div className="bg-white p-0 rounded-2xl shadow-2xl max-h-[90vh] flex flex-col w-full max-w-4xl overflow-hidden animate-in zoom-in-95 duration-200 border border-white/20">
<div className="flex justify-between items-center px-6 py-4 border-b border-slate-100 bg-slate-50/50">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 text-green-600 rounded-lg">
<Icons.Download className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-slate-800">Export Ready</h2>
<p className="text-xs text-slate-500">Your diagram has been successfully converted</p>
</div>
</div>
<button
onClick={() => setImgData(null)}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full transition-colors"
>
<Icons.Close className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-auto bg-slate-50/50 p-8 flex items-center justify-center min-h-[400px]">
<div className="bg-white p-2 rounded shadow-sm border border-slate-200">
<img src={imgData} alt="Exported diagram" className="max-w-full h-auto object-contain" />
</div>
</div>
<div className="px-6 py-4 border-t border-slate-100 bg-white flex justify-end gap-3">
<button
onClick={() => setImgData(null)}
className="px-5 py-2.5 text-slate-600 font-medium hover:bg-slate-100 rounded-lg transition-colors text-sm"
>
Close Preview
</button>
<a
href={imgData}
download="diagram.svg"
className="px-5 py-2.5 bg-indigo-600 text-white font-medium rounded-lg hover:bg-indigo-700 shadow-lg shadow-indigo-200 hover:shadow-indigo-300 transition-all text-sm flex items-center gap-2"
>
<Icons.Download className="w-4 h-4" />
Download File
</a>
</div>
</div>
</div>
)}
{/* Rename Modal */}
{isRenameModalOpen && (
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-50 p-6 animate-in fade-in duration-200">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-in zoom-in-95 duration-200 border border-white/20">
<div className="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center">
<h2 className="text-lg font-bold text-slate-800">Rename Session</h2>
<button
onClick={() => setIsRenameModalOpen(false)}
className="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full transition-colors"
>
<Icons.Close className="w-5 h-5" />
</button>
</div>
<div className="p-6">
<label className="block text-sm font-medium text-slate-700 mb-2">
Session Name
</label>
<input
type="text"
value={newSessionTitle}
onChange={(e) => setNewSessionTitle(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRenameSave()}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
placeholder="Enter new name"
autoFocus
/>
</div>
<div className="px-6 py-4 border-t border-slate-100 bg-slate-50/50 flex justify-end gap-3">
<button
onClick={() => setIsRenameModalOpen(false)}
className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-100 rounded-lg transition-colors text-sm"
>
Cancel
</button>
<button
onClick={handleRenameSave}
className="px-4 py-2 bg-indigo-600 text-white font-medium rounded-lg hover:bg-indigo-700 shadow-lg shadow-indigo-200 hover:shadow-indigo-300 transition-all text-sm"
>
Save Changes
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,6 @@
export const API_CONFIG = {
// Use environment variable from window.__ENV (runtime) or process.env (build/server)
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
View 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
View File

@@ -0,0 +1,9 @@
declare global {
interface Window {
__ENV?: {
NEXT_PUBLIC_API_BASE_URL: string;
};
}
}
export {};

View File

@@ -0,0 +1,46 @@
export const COOKIE_NAME = "ai_agent_login";
export const COOKIE_DAYS = 7;
export interface UserInfo {
user: string;
ts: number;
}
export const setCookie = (name: string, value: string, days: number) => {
const maxAge = Math.max(0, Math.floor(days * 86400));
document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${maxAge}; Path=/; SameSite=Lax`;
};
export const getCookie = (name: string): string | null => {
const cookies = document.cookie ? document.cookie.split("; ") : [];
for (const item of cookies) {
const eqIndex = item.indexOf("=");
const k = eqIndex >= 0 ? item.slice(0, eqIndex) : item;
const v = eqIndex >= 0 ? item.slice(eqIndex + 1) : "";
if (k === name) return decodeURIComponent(v);
}
return null;
};
export const deleteCookie = (name: string) => {
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`;
};
export const getUserInfo = (): UserInfo | null => {
const raw = getCookie(COOKIE_NAME);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
};
export const setUserInfo = (user: string) => {
const payload: UserInfo = { user, ts: Date.now() };
setCookie(COOKIE_NAME, JSON.stringify(payload), COOKIE_DAYS);
};
export const clearUserInfo = () => {
deleteCookie(COOKIE_NAME);
};

34
frontend/tsconfig.json Normal file
View 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"]
}

94
go.mod Normal file
View File

@@ -0,0 +1,94 @@
module ai-agent-scaffold-go
go 1.25.6
require (
github.com/cloudwego/eino v0.9.2
github.com/gin-gonic/gin v1.12.0
github.com/redis/go-redis/v9 v9.20.0
go.uber.org/zap v1.28.0
google.golang.org/adk v1.4.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.1
)
require (
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
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/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eino-contrib/jsonschema v1.0.3 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // 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/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/safehtml v0.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/goph/emperror v0.17.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // 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/mailru/easyjson v0.7.7 // 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/nikolalohinski/gonja v1.5.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/log v0.16.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/api v0.279.0 // indirect
google.golang.org/genai v1.57.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/grpc v1.81.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
rsc.io/omap v1.2.0 // indirect
rsc.io/ordered v1.1.1 // indirect
)

2
internal/api/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package api contains request and response contracts exposed by triggers.
package api

27
internal/api/dto/agent.go Normal file
View File

@@ -0,0 +1,27 @@
package dto
type AiAgentConfigResponse struct {
AgentID string `json:"agentId"`
AgentName string `json:"agentName"`
AgentDesc string `json:"agentDesc"`
}
type CreateSessionRequest struct {
AgentID string `json:"agentId"`
UserID string `json:"userId"`
}
type CreateSessionResponse struct {
SessionID string `json:"sessionId"`
}
type ChatRequest struct {
AgentID string `json:"agentId"`
UserID string `json:"userId"`
SessionID string `json:"sessionId"`
Message string `json:"message"`
}
type ChatResponse struct {
Content string `json:"content"`
}

View File

@@ -0,0 +1,24 @@
package response
import "ai-agent-scaffold-go/pkg/types"
type Envelope[T any] struct {
Code string `json:"code"`
Info string `json:"info"`
Data T `json:"data,omitempty"`
}
func Success[T any](data T) Envelope[T] {
return Envelope[T]{
Code: types.CodeSuccess,
Info: types.InfoSuccess,
Data: data,
}
}
func Failure(code, info string) Envelope[any] {
return Envelope[any]{
Code: code,
Info: info,
}
}

View File

@@ -0,0 +1,150 @@
// Package bootstrap wires application config, armory assembly, chat service,
// and Gin HTTP routes into a single runnable engine.
package bootstrap
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"ai-agent-scaffold-go/internal/app/config"
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/agent/ports"
"ai-agent-scaffold-go/internal/domain/agent/service/armory"
"ai-agent-scaffold-go/internal/domain/agent/service/armory/factory"
"ai-agent-scaffold-go/internal/domain/agent/service/chat"
"ai-agent-scaffold-go/internal/infrastructure/adk"
"ai-agent-scaffold-go/internal/infrastructure/ai"
httptrigger "ai-agent-scaffold-go/internal/trigger/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type Engine struct {
Application config.Application
Router *gin.Engine
ChatService *chat.Service
Registry ports.AgentRegistry
Sessions ports.SessionStore
Logger *zap.Logger
}
func Build(ctx context.Context, appCfg config.Application, logger *zap.Logger) (*Engine, error) {
tables, err := loadAgentTables(appCfg.Agent.ConfigPaths)
if err != nil {
return nil, err
}
if len(tables) == 0 {
return nil, fmt.Errorf("no agent tables loaded; configure agent.config-paths")
}
llmTimeout, err := appCfg.LLM.RequestTimeoutDuration()
if err != nil {
return nil, err
}
registry := ports.NewInMemoryAgentRegistry()
sessions := ports.NewInMemorySessionStore()
modelProvider := ai.NewEinoProvider().WithRequestTimeout(llmTimeout)
toolRouter := ai.NewMCPToolRouter()
mcpFactory := ai.NewToolFactory(toolRouter)
skillFactory := ai.NewSkillFactory()
agentFactory := adk.NewFactory()
agentFactory.UseToolRouter(toolRouter)
runnerFactory := agentFactory
armoryFactory := factory.NewDefaultFactory(modelProvider, mcpFactory, skillFactory, agentFactory, runnerFactory, registry)
armoryService := armory.NewService(armoryFactory.ArmoryStrategyHandler())
if err := armoryService.AcceptArmoryAgents(ctx, tables); err != nil {
return nil, fmt.Errorf("armory assembly: %w", err)
}
chatService := chat.NewService(registry, sessions)
router := newRouter(appCfg.App.Env)
httptrigger.RegisterAgentRoutes(router, chatService)
if logger != nil {
logger.Info("agents registered", zap.Int("count", len(registry.List())), zap.Duration("llm_request_timeout", llmTimeout))
}
return &Engine{
Application: appCfg,
Router: router,
ChatService: chatService,
Registry: registry,
Sessions: sessions,
Logger: logger,
}, nil
}
func (e *Engine) Run() error {
addr := e.Application.Server.Addr
if addr == "" {
addr = ":8091"
}
if e.Logger != nil {
e.Logger.Info("http server listening", zap.String("addr", addr))
}
return e.Router.Run(addr)
}
func newRouter(env string) *gin.Engine {
if env == "prod" || env == "production" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
router.Use(gin.Recovery())
if env != "prod" && env != "production" {
router.Use(gin.Logger())
}
router.Use(corsMiddleware())
router.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
return router
}
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 == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
func loadAgentTables(paths []string) (map[string]model.AiAgentConfigTable, 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
}
}
return merged, nil
}

View File

@@ -0,0 +1,88 @@
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
type Application struct {
App AppSection `yaml:"app"`
Server ServerSection `yaml:"server"`
Database DatabaseSection `yaml:"database"`
Redis RedisSection `yaml:"redis"`
Agent AgentSection `yaml:"agent"`
LLM LLMSection `yaml:"llm"`
}
type AppSection struct {
Name string `yaml:"name"`
Env string `yaml:"env"`
}
type ServerSection struct {
Addr string `yaml:"addr"`
}
type DatabaseSection struct {
Required bool `yaml:"required"`
DSN string `yaml:"dsn"`
}
type RedisSection struct {
Required bool `yaml:"required"`
Addr string `yaml:"addr"`
Password string `yaml:"password"`
DB int `yaml:"db"`
}
type AgentSection struct {
ConfigPaths []string `yaml:"config-paths"`
}
type LLMSection struct {
RequestTimeout string `yaml:"request-timeout"`
}
const defaultLLMRequestTimeout = 5 * time.Minute
func (s LLMSection) RequestTimeoutDuration() (time.Duration, error) {
raw := ""
if s.RequestTimeout != "" {
raw = s.RequestTimeout
}
if raw == "" {
return defaultLLMRequestTimeout, nil
}
d, err := time.ParseDuration(raw)
if err != nil {
return 0, fmt.Errorf("invalid llm.request-timeout %q: %w", raw, err)
}
if d <= 0 {
return 0, fmt.Errorf("llm.request-timeout must be positive, got %q", raw)
}
return d, nil
}
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"
}
if _, err := app.LLM.RequestTimeoutDuration(); err != nil {
return Application{}, err
}
return app, nil
}

View File

@@ -0,0 +1,186 @@
package config
import (
"fmt"
"os"
"regexp"
"strings"
"ai-agent-scaffold-go/internal/domain/agent/model"
"gopkg.in/yaml.v3"
)
type agentRoot struct {
AI struct {
Agent struct {
Config struct {
Tables map[string]model.AiAgentConfigTable `yaml:"tables"`
} `yaml:"config"`
} `yaml:"agent"`
} `yaml:"ai"`
}
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
}
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)
}
var envPlaceholderRE = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}`)
// expandEnvPlaceholders replaces ${VAR} and ${VAR:-default} in YAML text using
// the current process environment. It deliberately ignores bare $NAME forms so
// dollar signs that happen to appear inside instructions or URLs are not
// mangled.
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 ""
})
}
func normalizeDefaults(table *model.AiAgentConfigTable) {
if table.Module.AiAPI.CompletionsPath == "" {
table.Module.AiAPI.CompletionsPath = "v1/chat/completions"
}
if table.Module.AiAPI.EmbeddingsPath == "" {
table.Module.AiAPI.EmbeddingsPath = "v1/embeddings"
}
for i := range table.Module.ChatModel.ToolSkillsList {
if table.Module.ChatModel.ToolSkillsList[i].Type == "" {
table.Module.ChatModel.ToolSkillsList[i].Type = "directory"
}
}
for i := range table.Module.AgentWorkflows {
if table.Module.AgentWorkflows[i].MaxIterations == 0 {
table.Module.AgentWorkflows[i].MaxIterations = 3
}
}
}
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)
}
}
for i, tool := range table.Module.ChatModel.ToolMCPList {
if err := validateMCPEntry(prefix, i, tool); err != nil {
return err
}
}
return nil
}
func validateMCPEntry(prefix string, index int, tool model.ToolMCPConfig) error {
fieldPrefix := fmt.Sprintf("%s: module.chat-model.tool-mcp-list[%d]", prefix, index)
count := 0
if tool.Local != nil {
count++
}
if tool.SSE != nil {
count++
}
if tool.Stdio != nil {
count++
}
switch count {
case 0:
return fmt.Errorf("%s must define exactly one of local, sse, or stdio", fieldPrefix)
case 1:
default:
return fmt.Errorf("%s cannot define multiple transport types", fieldPrefix)
}
if tool.Local != nil {
if strings.TrimSpace(tool.Local.Name) == "" {
return fmt.Errorf("%s.local.name is required", fieldPrefix)
}
}
if tool.SSE != nil {
if strings.TrimSpace(tool.SSE.Name) == "" {
return fmt.Errorf("%s.sse.name is required", fieldPrefix)
}
if strings.TrimSpace(tool.SSE.BaseURI) == "" {
return fmt.Errorf("%s.sse.base-uri is required", fieldPrefix)
}
if tool.SSE.RequestTimeout < 0 {
return fmt.Errorf("%s.sse.request-timeout must be positive", fieldPrefix)
}
}
if tool.Stdio != nil {
if strings.TrimSpace(tool.Stdio.Name) == "" {
return fmt.Errorf("%s.stdio.name is required", fieldPrefix)
}
if strings.TrimSpace(tool.Stdio.ServerParameters.Command) == "" {
return fmt.Errorf("%s.stdio.server-parameters.command is required", fieldPrefix)
}
if tool.Stdio.RequestTimeout < 0 {
return fmt.Errorf("%s.stdio.request-timeout must be positive", fieldPrefix)
}
}
return nil
}

2
internal/app/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package app wires configuration, application services, and infrastructure.
package app

View File

@@ -0,0 +1,47 @@
package model
type ArmoryCommand struct {
Table AiAgentConfigTable
}
type ChatCommand struct {
AgentID string
UserID string
SessionID string
Message string
Content ChatContent
}
type ChatContent struct {
Texts []TextPart
Files []FilePart
InlineDatas []InlineDataPart
}
type TextPart struct {
Message string
}
type FilePart struct {
FileURI string
MimeType string
}
type InlineDataPart struct {
Bytes []byte
MimeType string
}
type RegisteredAgent struct {
AppName string
AgentID string
AgentName string
AgentDesc string
Runner Runner
}
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)
}

View File

@@ -0,0 +1,96 @@
package model
type WorkflowType string
const (
WorkflowTypeLoop WorkflowType = "loop"
WorkflowTypeParallel WorkflowType = "parallel"
WorkflowTypeSequential WorkflowType = "sequential"
)
type AiAgentConfigTable struct {
AppName string `yaml:"app-name" json:"appName"`
Agent AgentSummary `yaml:"agent" json:"agent"`
Module AgentModule `yaml:"module" json:"module"`
}
type AgentSummary struct {
AgentID string `yaml:"agent-id" json:"agentId"`
AgentName string `yaml:"agent-name" json:"agentName"`
AgentDesc string `yaml:"agent-desc" json:"agentDesc"`
}
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"`
}
type AiAPIConfig struct {
BaseURL string `yaml:"base-url" json:"baseUrl"`
APIKey string `yaml:"api-key" json:"apiKey"`
CompletionsPath string `yaml:"completions-path" json:"completionsPath"`
EmbeddingsPath string `yaml:"embeddings-path" json:"embeddingsPath"`
}
type ChatModelConfig struct {
Model string `yaml:"model" json:"model"`
ToolMCPList []ToolMCPConfig `yaml:"tool-mcp-list" json:"toolMcpList"`
ToolSkillsList []ToolSkillsConfig `yaml:"tool-skills-list" json:"toolSkillsList"`
}
type ToolMCPConfig struct {
SSE *SSEServerParameters `yaml:"sse,omitempty" json:"sse,omitempty"`
Stdio *StdioServerParameters `yaml:"stdio,omitempty" json:"stdio,omitempty"`
Local *LocalToolParameters `yaml:"local,omitempty" json:"local,omitempty"`
}
type SSEServerParameters struct {
Name string `yaml:"name" json:"name"`
BaseURI string `yaml:"base-uri" json:"baseUri"`
SSEEndpoint string `yaml:"sse-endpoint" json:"sseEndpoint"`
RequestTimeout int `yaml:"request-timeout" json:"requestTimeout"`
}
type StdioServerParameters struct {
Name string `yaml:"name" json:"name"`
RequestTimeout int `yaml:"request-timeout" json:"requestTimeout"`
ServerParameters ServerParameters `yaml:"server-parameters" json:"serverParameters"`
}
type ServerParameters struct {
Command string `yaml:"command" json:"command"`
Args []string `yaml:"args" json:"args"`
Env map[string]string `yaml:"env" json:"env"`
}
type LocalToolParameters struct {
Name string `yaml:"name" json:"name"`
}
type ToolSkillsConfig struct {
Type string `yaml:"type" json:"type"`
Path string `yaml:"path" json:"path"`
}
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"`
}
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"`
}
type RunnerConfig struct {
AgentName string `yaml:"agent-name" json:"agentName"`
PluginNameList []string `yaml:"plugin-name-list" json:"pluginNameList"`
}

View File

@@ -0,0 +1,167 @@
package ports
import (
"context"
"sync"
"ai-agent-scaffold-go/internal/domain/agent/model"
)
type ModelProvider interface {
NewAPI(ctx context.Context, config model.AiAPIConfig) (ModelAPI, error)
NewChatModel(ctx context.Context, api ModelAPI, config model.ChatModelConfig, tools []Tool) (ChatModel, error)
}
type ModelAPI interface{}
type ChatRole string
const (
ChatRoleSystem ChatRole = "system"
ChatRoleUser ChatRole = "user"
ChatRoleAssistant ChatRole = "assistant"
ChatRoleTool ChatRole = "tool"
)
type ChatMessage struct {
Role ChatRole
Content string
ToolCallID string
Name string
ToolCalls []ChatToolCall
}
type ChatToolCall struct {
ID string
Name string
Arguments string
}
type ChatReply struct {
Content string
ToolCalls []ChatToolCall
}
type ChatStreamEvent struct {
Delta string
ToolCalls []ChatToolCall
Done bool
}
type ChatModel interface {
Generate(ctx context.Context, messages []ChatMessage) (ChatReply, error)
Stream(ctx context.Context, messages []ChatMessage) (<-chan ChatStreamEvent, <-chan error)
Tools() []Tool
}
type Tool interface {
Name() string
}
type ToolDescriptor interface {
Description() string
}
type MCPToolFactory interface {
BuildTools(ctx context.Context, config model.ToolMCPConfig) ([]Tool, error)
}
type SkillFactory interface {
BuildTools(ctx context.Context, config model.ToolSkillsConfig) ([]Tool, error)
}
type ToolRouter interface {
CallTool(ctx context.Context, name, arguments string) (string, error)
}
type AgentFactory interface {
NewLLMAgent(ctx context.Context, config model.AgentConfig, chatModel ChatModel) (Agent, error)
NewLoopAgent(ctx context.Context, config model.AgentWorkflowConfig, subAgents []Agent) (Agent, error)
NewParallelAgent(ctx context.Context, config model.AgentWorkflowConfig, subAgents []Agent) (Agent, error)
NewSequentialAgent(ctx context.Context, config model.AgentWorkflowConfig, subAgents []Agent) (Agent, error)
}
type Agent interface {
Name() string
}
type RunnerPlugin interface {
Name() string
OnUserMessage(ctx context.Context, appName, userID, sessionID string, agent Agent, content model.ChatContent) error
BeforeAgent(ctx context.Context, appName, userID, sessionID string, agent Agent) error
}
type RunnerFactory interface {
NewRunner(ctx context.Context, appName string, agent Agent, pluginNames []string) (model.Runner, error)
}
type AgentRegistry interface {
Register(agent model.RegisteredAgent) error
Get(agentID string) (model.RegisteredAgent, bool)
List() []model.RegisteredAgent
}
type SessionStore interface {
Get(userID, agentID string) (string, bool)
Set(userID, agentID, sessionID string) error
}
type InMemoryAgentRegistry struct {
mu sync.RWMutex
agents map[string]model.RegisteredAgent
}
func NewInMemoryAgentRegistry() *InMemoryAgentRegistry {
return &InMemoryAgentRegistry{agents: make(map[string]model.RegisteredAgent)}
}
func (r *InMemoryAgentRegistry) Register(agent model.RegisteredAgent) error {
r.mu.Lock()
defer r.mu.Unlock()
r.agents[agent.AgentID] = agent
return nil
}
func (r *InMemoryAgentRegistry) Get(agentID string) (model.RegisteredAgent, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
agent, ok := r.agents[agentID]
return agent, ok
}
func (r *InMemoryAgentRegistry) List() []model.RegisteredAgent {
r.mu.RLock()
defer r.mu.RUnlock()
agents := make([]model.RegisteredAgent, 0, len(r.agents))
for _, agent := range r.agents {
agents = append(agents, agent)
}
return agents
}
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[sessionKey(userID, agentID)]
return sessionID, ok
}
func (s *InMemorySessionStore) Set(userID, agentID, sessionID string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[sessionKey(userID, agentID)] = sessionID
return nil
}
func sessionKey(userID, agentID string) string {
return userID + ":" + agentID
}

View File

@@ -0,0 +1,29 @@
package armory
import (
"context"
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/agent/ports"
"ai-agent-scaffold-go/internal/domain/shared/tree"
)
type AgentNode struct {
agentFactory ports.AgentFactory
next Handler
}
func NewAgentNode(agentFactory ports.AgentFactory, next Handler) AgentNode {
return AgentNode{agentFactory: agentFactory, next: next}
}
func (n AgentNode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *DynamicContext) (model.RegisteredAgent, error) {
for _, config := range command.Table.Module.Agents {
agent, err := n.agentFactory.NewLLMAgent(ctx, config, dynamic.ChatModel)
if err != nil {
return model.RegisteredAgent{}, err
}
dynamic.AddAgent(agent)
}
return tree.Route(ctx, n.next, command, dynamic)
}

View File

@@ -0,0 +1,27 @@
package armory
import (
"context"
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/agent/ports"
"ai-agent-scaffold-go/internal/domain/shared/tree"
)
type AiAPINode struct {
modelProvider ports.ModelProvider
next Handler
}
func NewAiAPINode(modelProvider ports.ModelProvider, next Handler) AiAPINode {
return AiAPINode{modelProvider: modelProvider, next: next}
}
func (n AiAPINode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *DynamicContext) (model.RegisteredAgent, error) {
api, err := n.modelProvider.NewAPI(ctx, command.Table.Module.AiAPI)
if err != nil {
return model.RegisteredAgent{}, err
}
dynamic.ModelAPI = api
return tree.Route(ctx, n.next, command, dynamic)
}

View File

@@ -0,0 +1,54 @@
package armory
import (
"context"
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/agent/ports"
"ai-agent-scaffold-go/internal/domain/shared/tree"
)
type ChatModelNode struct {
modelProvider ports.ModelProvider
mcpFactory ports.MCPToolFactory
skillFactory ports.SkillFactory
next Handler
}
func NewChatModelNode(
modelProvider ports.ModelProvider,
mcpFactory ports.MCPToolFactory,
skillFactory ports.SkillFactory,
next Handler,
) ChatModelNode {
return ChatModelNode{
modelProvider: modelProvider,
mcpFactory: mcpFactory,
skillFactory: skillFactory,
next: next,
}
}
func (n ChatModelNode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *DynamicContext) (model.RegisteredAgent, error) {
var tools []ports.Tool
for _, config := range command.Table.Module.ChatModel.ToolMCPList {
built, err := n.mcpFactory.BuildTools(ctx, config)
if err != nil {
return model.RegisteredAgent{}, err
}
tools = append(tools, built...)
}
for _, config := range command.Table.Module.ChatModel.ToolSkillsList {
built, err := n.skillFactory.BuildTools(ctx, config)
if err != nil {
return model.RegisteredAgent{}, err
}
tools = append(tools, built...)
}
chatModel, err := n.modelProvider.NewChatModel(ctx, dynamic.ModelAPI, command.Table.Module.ChatModel, tools)
if err != nil {
return model.RegisteredAgent{}, err
}
dynamic.ChatModel = chatModel
return tree.Route(ctx, n.next, command, dynamic)
}

View File

@@ -0,0 +1,87 @@
package armory
import (
"sync"
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/agent/ports"
)
type DynamicContext struct {
mu sync.RWMutex
ModelAPI ports.ModelAPI
ChatModel ports.ChatModel
agentGroup map[string]ports.Agent
currentStepIndex int
currentWorkflow *model.AgentWorkflowConfig
values map[string]any
}
func NewDynamicContext() *DynamicContext {
return &DynamicContext{
agentGroup: make(map[string]ports.Agent),
values: make(map[string]any),
}
}
func (c *DynamicContext) AddAgent(agent ports.Agent) {
c.mu.Lock()
defer c.mu.Unlock()
c.agentGroup[agent.Name()] = agent
}
func (c *DynamicContext) Agent(name string) (ports.Agent, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
agent, ok := c.agentGroup[name]
return agent, ok
}
func (c *DynamicContext) QueryAgentList(names []string) []ports.Agent {
c.mu.RLock()
defer c.mu.RUnlock()
agents := make([]ports.Agent, 0, len(names))
for _, name := range names {
if agent, ok := c.agentGroup[name]; ok {
agents = append(agents, agent)
}
}
return agents
}
func (c *DynamicContext) AddCurrentStepIndex() {
c.mu.Lock()
defer c.mu.Unlock()
c.currentStepIndex++
}
func (c *DynamicContext) CurrentStepIndex() int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.currentStepIndex
}
func (c *DynamicContext) SetCurrentWorkflow(workflow *model.AgentWorkflowConfig) {
c.mu.Lock()
defer c.mu.Unlock()
c.currentWorkflow = workflow
}
func (c *DynamicContext) CurrentWorkflow() *model.AgentWorkflowConfig {
c.mu.RLock()
defer c.mu.RUnlock()
return c.currentWorkflow
}
func (c *DynamicContext) SetValue(key string, value any) {
c.mu.Lock()
defer c.mu.Unlock()
c.values[key] = value
}
func (c *DynamicContext) Value(key string) (any, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
value, ok := c.values[key]
return value, ok
}

View File

@@ -0,0 +1,32 @@
package factory
import (
"ai-agent-scaffold-go/internal/domain/agent/ports"
"ai-agent-scaffold-go/internal/domain/agent/service/armory"
"ai-agent-scaffold-go/internal/domain/agent/service/armory/workflow"
)
type DefaultFactory struct {
root armory.Handler
}
func NewDefaultFactory(
modelProvider ports.ModelProvider,
mcpFactory ports.MCPToolFactory,
skillFactory ports.SkillFactory,
agentFactory ports.AgentFactory,
runnerFactory ports.RunnerFactory,
registry ports.AgentRegistry,
) *DefaultFactory {
runner := armory.NewRunnerNode(runnerFactory, registry)
workflowNode := workflow.NewAgentWorkflowNode(agentFactory, runner)
agent := armory.NewAgentNode(agentFactory, workflowNode)
chatModel := armory.NewChatModelNode(modelProvider, mcpFactory, skillFactory, agent)
api := armory.NewAiAPINode(modelProvider, chatModel)
root := armory.NewRootNode(api)
return &DefaultFactory{root: root}
}
func (f *DefaultFactory) ArmoryStrategyHandler() armory.Handler {
return f.root
}

View File

@@ -0,0 +1,20 @@
package armory
import (
"context"
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/shared/tree"
)
type RootNode struct {
next Handler
}
func NewRootNode(next Handler) RootNode {
return RootNode{next: next}
}
func (n RootNode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *DynamicContext) (model.RegisteredAgent, error) {
return tree.Route(ctx, n.next, command, dynamic)
}

View File

@@ -0,0 +1,44 @@
package armory
import (
"context"
"fmt"
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/agent/ports"
)
type RunnerNode struct {
runnerFactory ports.RunnerFactory
registry ports.AgentRegistry
}
func NewRunnerNode(runnerFactory ports.RunnerFactory, registry ports.AgentRegistry) RunnerNode {
return RunnerNode{runnerFactory: runnerFactory, registry: registry}
}
func (n RunnerNode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *DynamicContext) (model.RegisteredAgent, error) {
runnerConfig := command.Table.Module.Runner
if runnerConfig.AgentName == "" {
return model.RegisteredAgent{}, fmt.Errorf("runner.agent-name is required")
}
agent, ok := dynamic.Agent(runnerConfig.AgentName)
if !ok {
return model.RegisteredAgent{}, fmt.Errorf("runner agent %q not found", runnerConfig.AgentName)
}
runner, err := n.runnerFactory.NewRunner(ctx, command.Table.AppName, agent, runnerConfig.PluginNameList)
if err != nil {
return model.RegisteredAgent{}, err
}
registered := model.RegisteredAgent{
AppName: command.Table.AppName,
AgentID: command.Table.Agent.AgentID,
AgentName: command.Table.Agent.AgentName,
AgentDesc: command.Table.Agent.AgentDesc,
Runner: runner,
}
if err := n.registry.Register(registered); err != nil {
return model.RegisteredAgent{}, err
}
return registered, nil
}

View File

@@ -0,0 +1,25 @@
package armory
import (
"context"
"ai-agent-scaffold-go/internal/domain/agent/model"
)
type Service struct {
handler Handler
}
func NewService(handler Handler) *Service {
return &Service{handler: handler}
}
func (s *Service) AcceptArmoryAgents(ctx context.Context, tables map[string]model.AiAgentConfigTable) error {
for _, table := range tables {
_, err := s.handler.Apply(ctx, model.ArmoryCommand{Table: table}, NewDynamicContext())
if err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,8 @@
package armory
import (
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/shared/tree"
)
type Handler = tree.Handler[model.ArmoryCommand, *DynamicContext, model.RegisteredAgent]

View File

@@ -0,0 +1,67 @@
package workflow
import (
"context"
"fmt"
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/agent/ports"
"ai-agent-scaffold-go/internal/domain/agent/service/armory"
"ai-agent-scaffold-go/internal/domain/shared/tree"
)
type workflowBuilder interface {
Build(context.Context, model.AgentWorkflowConfig, []ports.Agent) (ports.Agent, error)
}
type AgentWorkflowNode struct {
next armory.Handler
builders map[model.WorkflowType]workflowBuilder
buildOrder []model.WorkflowType
}
func NewAgentWorkflowNode(agentFactory ports.AgentFactory, next armory.Handler) *AgentWorkflowNode {
buildOrder := []model.WorkflowType{
model.WorkflowTypeLoop,
model.WorkflowTypeParallel,
model.WorkflowTypeSequential,
}
return &AgentWorkflowNode{
next: next,
builders: map[model.WorkflowType]workflowBuilder{
model.WorkflowTypeLoop: NewLoopNode(agentFactory),
model.WorkflowTypeParallel: NewParallelNode(agentFactory),
model.WorkflowTypeSequential: NewSequentialNode(agentFactory),
},
buildOrder: buildOrder,
}
}
func (n *AgentWorkflowNode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *armory.DynamicContext) (model.RegisteredAgent, error) {
workflows := command.Table.Module.AgentWorkflows
if dynamic.CurrentStepIndex() >= len(workflows) {
dynamic.SetCurrentWorkflow(nil)
return tree.Route(ctx, n.next, command, dynamic)
}
workflow := workflows[dynamic.CurrentStepIndex()]
dynamic.SetCurrentWorkflow(&workflow)
dynamic.AddCurrentStepIndex()
agent, err := n.build(ctx, workflow, dynamic.QueryAgentList(workflow.SubAgents))
if err != nil {
return model.RegisteredAgent{}, err
}
dynamic.AddAgent(agent)
return n.Apply(ctx, command, dynamic)
}
func (n *AgentWorkflowNode) build(ctx context.Context, workflow model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
for _, workflowType := range n.buildOrder {
if workflow.Type != workflowType {
continue
}
return n.builders[workflowType].Build(ctx, workflow, subAgents)
}
return nil, fmt.Errorf("agentWorkflow type is error: %s", workflow.Type)
}

View File

@@ -0,0 +1,20 @@
package workflow
import (
"context"
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/agent/ports"
)
type LoopNode struct {
agentFactory ports.AgentFactory
}
func NewLoopNode(agentFactory ports.AgentFactory) LoopNode {
return LoopNode{agentFactory: agentFactory}
}
func (n LoopNode) Build(ctx context.Context, workflow model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
return n.agentFactory.NewLoopAgent(ctx, workflow, subAgents)
}

View File

@@ -0,0 +1,20 @@
package workflow
import (
"context"
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/agent/ports"
)
type ParallelNode struct {
agentFactory ports.AgentFactory
}
func NewParallelNode(agentFactory ports.AgentFactory) ParallelNode {
return ParallelNode{agentFactory: agentFactory}
}
func (n ParallelNode) Build(ctx context.Context, workflow model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
return n.agentFactory.NewParallelAgent(ctx, workflow, subAgents)
}

View File

@@ -0,0 +1,20 @@
package workflow
import (
"context"
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/agent/ports"
)
type SequentialNode struct {
agentFactory ports.AgentFactory
}
func NewSequentialNode(agentFactory ports.AgentFactory) SequentialNode {
return SequentialNode{agentFactory: agentFactory}
}
func (n SequentialNode) Build(ctx context.Context, workflow model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
return n.agentFactory.NewSequentialAgent(ctx, workflow, subAgents)
}

View File

@@ -0,0 +1,117 @@
package chat
import (
"fmt"
"sort"
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/agent/ports"
"ai-agent-scaffold-go/pkg/types"
)
type Service struct {
registry ports.AgentRegistry
sessions ports.SessionStore
}
func NewService(registry ports.AgentRegistry, sessions ports.SessionStore) *Service {
return &Service{registry: registry, sessions: sessions}
}
func (s *Service) 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
}
func (s *Service) 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
}
func (s *Service) HandleMessage(agentID, userID, sessionID, message string) ([]string, error) {
content := model.ChatContent{Texts: []model.TextPart{{Message: message}}}
return s.HandleCommand(model.ChatCommand{
AgentID: agentID,
UserID: userID,
SessionID: sessionID,
Message: message,
Content: content,
})
}
func (s *Service) HandleCommand(command model.ChatCommand) ([]string, error) {
registered, sessionID, err := s.resolveRunnerSession(command)
if err != nil {
return nil, err
}
return registered.Runner.Run(command.UserID, sessionID, command.Content)
}
func (s *Service) HandleMessageStream(agentID, userID, sessionID, message string) (<-chan string, <-chan error) {
return s.HandleCommandStream(model.ChatCommand{
AgentID: agentID,
UserID: userID,
SessionID: sessionID,
Message: message,
Content: model.ChatContent{Texts: []model.TextPart{{Message: message}}},
})
}
func (s *Service) HandleCommandStream(command model.ChatCommand) (<-chan string, <-chan error) {
registered, sessionID, err := s.resolveRunnerSession(command)
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(command.UserID, sessionID, command.Content)
}
func (s *Service) resolveRunnerSession(command model.ChatCommand) (model.RegisteredAgent, string, error) {
registered, ok := s.registry.Get(command.AgentID)
if !ok || registered.Runner == nil {
return model.RegisteredAgent{}, "", types.NewAppError(types.CodeAgentNotFound, types.InfoAgentNotFound)
}
sessionID := command.SessionID
if sessionID == "" {
var err error
sessionID, err = s.CreateSession(command.AgentID, command.UserID)
if err != nil {
return model.RegisteredAgent{}, "", err
}
}
if len(command.Content.Texts) == 0 && command.Message != "" {
command.Content.Texts = []model.TextPart{{Message: command.Message}}
}
if len(command.Content.Texts) == 0 && len(command.Content.Files) == 0 && len(command.Content.InlineDatas) == 0 {
return model.RegisteredAgent{}, "", fmt.Errorf("chat content is required")
}
return registered, sessionID, nil
}

2
internal/domain/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package domain contains Agent models, services, ports, and Armory logic.
package domain

View File

@@ -0,0 +1,19 @@
package tree
import "context"
type Handler[C any, D any, R any] interface {
Apply(ctx context.Context, command C, dynamic D) (R, error)
}
func Route[C any, D any, R any](ctx context.Context, next Handler[C, D, R], command C, dynamic D) (R, error) {
if next == nil {
return zero[R](), nil
}
return next.Apply(ctx, command, dynamic)
}
func zero[T any]() T {
var value T
return value
}

View File

@@ -0,0 +1,467 @@
package adk
import (
"context"
"fmt"
"strings"
"sync/atomic"
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/agent/ports"
"google.golang.org/adk/plugin"
"google.golang.org/adk/plugin/loggingplugin"
)
const maxToolCallIterations = 4
type Factory struct {
sessionCounter atomic.Uint64
plugins map[string]func() (ports.RunnerPlugin, error)
router ports.ToolRouter
}
type Agent struct {
name string
kind string
description string
instruction string
outputKey string
chatModel ports.ChatModel
router ports.ToolRouter
subAgents []ports.Agent
}
type Runner struct {
appName string
agent ports.Agent
plugins []ports.RunnerPlugin
counter *atomic.Uint64
}
func NewFactory() *Factory {
return &Factory{plugins: defaultPlugins()}
}
func (f *Factory) UseToolRouter(router ports.ToolRouter) {
f.router = router
}
func (f *Factory) NewLLMAgent(_ context.Context, config model.AgentConfig, chatModel ports.ChatModel) (ports.Agent, error) {
if strings.TrimSpace(config.Name) == "" {
return nil, fmt.Errorf("agent name is required")
}
if chatModel == nil {
return nil, fmt.Errorf("agent %q requires a chat model", config.Name)
}
return &Agent{
name: config.Name,
kind: "llm",
description: config.Description,
instruction: config.Instruction,
outputKey: config.OutputKey,
chatModel: chatModel,
router: f.router,
}, nil
}
func (f *Factory) NewLoopAgent(_ context.Context, config model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
return newWorkflowAgent("loop", config, subAgents, f.router)
}
func (f *Factory) NewParallelAgent(_ context.Context, config model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
return newWorkflowAgent("parallel", config, subAgents, f.router)
}
func (f *Factory) NewSequentialAgent(_ context.Context, config model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
return newWorkflowAgent("sequential", config, subAgents, f.router)
}
func (f *Factory) NewRunner(_ context.Context, appName string, agent ports.Agent, pluginNames []string) (model.Runner, error) {
if strings.TrimSpace(appName) == "" {
return nil, fmt.Errorf("app name is required")
}
if agent == nil {
return nil, fmt.Errorf("agent is required")
}
plugins, err := f.resolvePlugins(pluginNames)
if err != nil {
return nil, err
}
return &Runner{appName: appName, agent: agent, plugins: plugins, counter: &f.sessionCounter}, nil
}
func newWorkflowAgent(kind string, config model.AgentWorkflowConfig, subAgents []ports.Agent, router ports.ToolRouter) (ports.Agent, error) {
if strings.TrimSpace(config.Name) == "" {
return nil, fmt.Errorf("%s agent name is required", kind)
}
return &Agent{
name: config.Name,
kind: kind,
description: config.Description,
subAgents: subAgents,
router: router,
}, nil
}
func (a *Agent) Name() string {
return a.name
}
func (a *Agent) Description() string {
return a.description
}
func (a *Agent) run(ctx context.Context, content model.ChatContent) (string, error) {
return a.runWithVars(ctx, content, map[string]string{})
}
func (a *Agent) runWithVars(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error) {
switch a.kind {
case "llm":
return a.runLLM(ctx, content, vars)
case "sequential":
return a.runSequential(ctx, content, vars)
case "loop", "parallel":
return a.runFanOut(ctx, content, vars)
default:
return "", fmt.Errorf("agent %q has unknown kind %q", a.name, a.kind)
}
}
func (a *Agent) stream(ctx context.Context, content model.ChatContent, out chan<- string) error {
switch a.kind {
case "llm":
return a.streamLLM(ctx, content, out, map[string]string{})
default:
text, err := a.run(ctx, content)
if err != nil {
return err
}
if text != "" {
select {
case out <- text:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}
}
func (a *Agent) runLLM(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
}
messages = append(messages, ports.ChatMessage{Role: ports.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)
}
func (a *Agent) streamLLM(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 []ports.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, ports.ChatMessage{Role: ports.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)
}
func (a *Agent) executeToolCalls(ctx context.Context, calls []ports.ChatToolCall) ([]ports.ChatMessage, error) {
if a.router == nil {
return nil, fmt.Errorf("agent %q has no tool router but model requested tool calls", a.name)
}
out := make([]ports.ChatMessage, 0, len(calls))
for _, call := range calls {
result, err := a.router.CallTool(ctx, call.Name, call.Arguments)
if err != nil {
return nil, fmt.Errorf("tool %q: %w", call.Name, err)
}
out = append(out, ports.ChatMessage{
Role: ports.ChatRoleTool,
Content: result,
ToolCallID: call.ID,
Name: call.Name,
})
}
return out, nil
}
func (a *Agent) runSequential(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error) {
scope := cloneVars(vars)
var last string
for _, sub := range a.subAgents {
impl, ok := sub.(*Agent)
if !ok {
return "", fmt.Errorf("sub-agent %q is not a runnable agent", sub.Name())
}
text, err := impl.runWithVars(ctx, content, scope)
if err != nil {
return "", err
}
last = text
if key := strings.TrimSpace(impl.outputKey); key != "" {
scope[key] = text
}
}
return last, nil
}
func (a *Agent) runFanOut(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error) {
parts := make([]string, 0, len(a.subAgents))
for _, sub := range a.subAgents {
impl, ok := sub.(*Agent)
if !ok {
return "", fmt.Errorf("sub-agent %q is not a runnable agent", sub.Name())
}
text, err := impl.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 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
}
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
}
func initialMessages(instruction, userText string) []ports.ChatMessage {
messages := make([]ports.ChatMessage, 0, 2)
if strings.TrimSpace(instruction) != "" {
messages = append(messages, ports.ChatMessage{Role: ports.ChatRoleSystem, Content: instruction})
}
messages = append(messages, ports.ChatMessage{Role: ports.ChatRoleUser, Content: userText})
return messages
}
func (r *Runner) CreateSession(userID string) (string, error) {
if strings.TrimSpace(userID) == "" {
return "", fmt.Errorf("user id is required")
}
next := r.counter.Add(1)
return fmt.Sprintf("%s:%s:%d", r.appName, userID, next), nil
}
func (r *Runner) 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")
}
if err := r.notifyPlugins(userID, sessionID, content); err != nil {
return nil, err
}
impl, ok := r.agent.(*Agent)
if !ok {
return nil, fmt.Errorf("runner agent %q is not runnable", r.agent.Name())
}
output, err := impl.run(context.Background(), content)
if err != nil {
return nil, err
}
if output == "" {
return []string{}, nil
}
return []string{output}, nil
}
func (r *Runner) 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.notifyPlugins(userID, sessionID, content); err != nil {
errs <- err
return
}
impl, ok := r.agent.(*Agent)
if !ok {
errs <- fmt.Errorf("runner agent %q is not runnable", r.agent.Name())
return
}
if err := impl.stream(context.Background(), content, outputs); err != nil {
errs <- err
}
}()
return outputs, errs
}
func (r *Runner) notifyPlugins(userID, sessionID string, content model.ChatContent) error {
for _, item := range r.plugins {
if err := item.OnUserMessage(context.Background(), r.appName, userID, sessionID, r.agent, content); err != nil {
return fmt.Errorf("plugin %q on user message: %w", item.Name(), err)
}
if err := item.BeforeAgent(context.Background(), r.appName, userID, sessionID, r.agent); err != nil {
return fmt.Errorf("plugin %q before agent: %w", item.Name(), err)
}
}
return nil
}
func firstText(content model.ChatContent) string {
if len(content.Texts) == 0 {
return ""
}
return content.Texts[0].Message
}
func defaultPlugins() map[string]func() (ports.RunnerPlugin, error) {
return map[string]func() (ports.RunnerPlugin, error){
"myTestPlugin": newMyTestPlugin,
"myLogPlugin": func() (ports.RunnerPlugin, error) {
p, err := loggingplugin.New("myLogPlugin")
if err != nil {
return nil, err
}
return adkRunnerPlugin{name: "myLogPlugin", plugin: p}, nil
},
}
}
func (f *Factory) resolvePlugins(names []string) ([]ports.RunnerPlugin, error) {
if len(names) == 0 {
return nil, nil
}
plugins := make([]ports.RunnerPlugin, 0, len(names))
for _, name := range names {
pluginName := strings.TrimSpace(name)
if pluginName == "" {
continue
}
builder, ok := f.plugins[pluginName]
if !ok {
return nil, fmt.Errorf("runner plugin %q is not registered", pluginName)
}
plugin, err := builder()
if err != nil {
return nil, fmt.Errorf("create runner plugin %q: %w", pluginName, err)
}
plugins = append(plugins, plugin)
}
return plugins, nil
}
func newMyTestPlugin() (ports.RunnerPlugin, error) {
return myTestPlugin{}, nil
}
type myTestPlugin struct{}
func (myTestPlugin) Name() string {
return "myTestPlugin"
}
func (myTestPlugin) OnUserMessage(_ context.Context, _, _, _ string, _ ports.Agent, content model.ChatContent) error {
fmt.Printf("[myTestPlugin] 用户输入信息:%s\n", firstText(content))
return nil
}
func (myTestPlugin) BeforeAgent(_ context.Context, _, _, _ string, agent ports.Agent) error {
fmt.Printf("[myTestPlugin] 智能体名称:%s\n", agent.Name())
return nil
}
type adkRunnerPlugin struct {
name string
plugin *plugin.Plugin
}
func (p adkRunnerPlugin) Name() string {
return p.name
}
func (p adkRunnerPlugin) OnUserMessage(_ context.Context, _, _, _ string, _ ports.Agent, content model.ChatContent) error {
if p.plugin.OnUserMessageCallback() != nil {
fmt.Printf("[%s] USER MESSAGE RECEIVED %s\n", p.name, firstText(content))
}
return nil
}
func (p adkRunnerPlugin) BeforeAgent(_ context.Context, _, _, _ string, agent ports.Agent) error {
if p.plugin.BeforeAgentCallback() != nil {
fmt.Printf("[%s] AGENT STARTING %s\n", p.name, agent.Name())
}
return nil
}

View File

@@ -0,0 +1,117 @@
package ai
import (
"context"
"fmt"
"strings"
"time"
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/agent/ports"
)
type EinoProvider struct {
requestTimeout time.Duration
}
type EinoAPIConfig struct {
BaseURL string
APIKey string
CompletionsPath string
EmbeddingsPath string
}
type EinoChatModel struct {
client *OpenAIClient
tools []ports.Tool
}
type EinoTool struct {
ToolName string
}
func NewEinoProvider() *EinoProvider {
return &EinoProvider{requestTimeout: 5 * time.Minute}
}
func (p *EinoProvider) WithRequestTimeout(timeout time.Duration) *EinoProvider {
if timeout > 0 {
p.requestTimeout = timeout
}
return p
}
func (p *EinoProvider) NewAPI(_ context.Context, config model.AiAPIConfig) (ports.ModelAPI, error) {
if strings.TrimSpace(config.BaseURL) == "" {
return nil, fmt.Errorf("base url is required")
}
if strings.TrimSpace(config.APIKey) == "" {
return nil, fmt.Errorf("api key is required")
}
return EinoAPIConfig{
BaseURL: config.BaseURL,
APIKey: config.APIKey,
CompletionsPath: config.CompletionsPath,
EmbeddingsPath: config.EmbeddingsPath,
}, nil
}
func (p *EinoProvider) NewChatModel(_ context.Context, api ports.ModelAPI, config model.ChatModelConfig, tools []ports.Tool) (ports.ChatModel, error) {
if api == nil {
return nil, fmt.Errorf("model api is required")
}
if strings.TrimSpace(config.Model) == "" {
return nil, fmt.Errorf("model is required")
}
apiCfg, ok := api.(EinoAPIConfig)
if !ok {
return nil, fmt.Errorf("unsupported model api type %T", api)
}
completionsURL := joinURL(apiCfg.BaseURL, apiCfg.CompletionsPath)
timeout := p.requestTimeout
if timeout <= 0 {
timeout = 5 * time.Minute
}
client := NewOpenAIClient(completionsURL, apiCfg.APIKey, config.Model, timeout)
return &EinoChatModel{client: client, tools: tools}, nil
}
func (m *EinoChatModel) Tools() []ports.Tool {
return m.tools
}
func (m *EinoChatModel) Generate(ctx context.Context, messages []ports.ChatMessage) (ports.ChatReply, error) {
return m.client.Generate(ctx, messages, m.toolDefs())
}
func (m *EinoChatModel) Stream(ctx context.Context, messages []ports.ChatMessage) (<-chan ports.ChatStreamEvent, <-chan error) {
return m.client.Stream(ctx, messages, m.toolDefs())
}
func (m *EinoChatModel) toolDefs() []OpenAIToolDef {
if len(m.tools) == 0 {
return nil
}
defs := make([]OpenAIToolDef, 0, len(m.tools))
for _, tool := range m.tools {
desc := ""
if d, ok := tool.(ports.ToolDescriptor); ok {
desc = d.Description()
}
defs = append(defs, OpenAIToolDef{Name: tool.Name(), Description: desc})
}
return defs
}
func (t EinoTool) Name() string {
return t.ToolName
}
func joinURL(base, path string) string {
base = strings.TrimRight(base, "/")
path = strings.TrimLeft(path, "/")
if path == "" {
return base
}
return base + "/" + path
}

View File

@@ -0,0 +1,509 @@
package ai
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"ai-agent-scaffold-go/internal/domain/agent/ports"
)
const (
mcpProtocolVersion = "2024-11-05"
mcpClientName = "ai-agent-scaffold-go"
mcpClientVersion = "0.1.0"
)
type MCPSSEClient struct {
baseURI string
endpoint string
httpClient *http.Client
timeout time.Duration
mu sync.Mutex
started bool
postURL string
cancelFn context.CancelFunc
pending map[uint64]chan json.RawMessage
nextID atomic.Uint64
streamErr chan error
endpointSig chan struct{}
}
func NewMCPSSEClient(baseURI, endpoint string, requestTimeoutMillis int) *MCPSSEClient {
timeout := time.Duration(requestTimeoutMillis) * time.Millisecond
if timeout <= 0 {
timeout = 120 * time.Second
}
return &MCPSSEClient{
baseURI: strings.TrimRight(baseURI, "/"),
endpoint: endpoint,
httpClient: &http.Client{Timeout: 0},
timeout: timeout,
}
}
func (c *MCPSSEClient) ensureStarted(ctx context.Context) error {
c.mu.Lock()
if c.started {
c.mu.Unlock()
return nil
}
c.pending = make(map[uint64]chan json.RawMessage)
c.endpointSig = make(chan struct{})
c.streamErr = make(chan error, 1)
streamCtx, cancel := context.WithCancel(context.Background())
c.cancelFn = cancel
c.started = true
c.mu.Unlock()
if err := c.openSSE(streamCtx); err != nil {
c.shutdown()
return err
}
waitCtx, waitCancel := context.WithTimeout(ctx, c.timeout)
defer waitCancel()
select {
case <-c.endpointSig:
case err := <-c.streamErr:
c.shutdown()
return err
case <-waitCtx.Done():
c.shutdown()
return fmt.Errorf("mcp sse endpoint event timeout: %w", waitCtx.Err())
}
if err := c.initialize(ctx); err != nil {
c.shutdown()
return err
}
return nil
}
func (c *MCPSSEClient) openSSE(ctx context.Context) error {
target := c.baseURI + c.endpoint
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return fmt.Errorf("mcp sse build request: %w", err)
}
req.Header.Set("Accept", "text/event-stream")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("mcp sse open: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
raw, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return fmt.Errorf("mcp sse status %d: %s", resp.StatusCode, truncate(string(raw), 200))
}
go c.readLoop(resp)
return nil
}
func (c *MCPSSEClient) readLoop(resp *http.Response) {
defer resp.Body.Close()
reader := bufio.NewReader(resp.Body)
var event string
var dataBuf strings.Builder
dispatch := func() {
defer func() {
event = ""
dataBuf.Reset()
}()
data := dataBuf.String()
if data == "" {
return
}
switch event {
case "endpoint", "":
c.handleEndpoint(data, event)
case "message":
c.handleMessage(data)
}
}
for {
line, err := reader.ReadString('\n')
if err != nil {
if err != io.EOF {
c.signalStreamErr(fmt.Errorf("mcp sse read: %w", err))
} else {
c.signalStreamErr(fmt.Errorf("mcp sse stream closed"))
}
return
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
dispatch()
continue
}
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
if dataBuf.Len() > 0 {
dataBuf.WriteByte('\n')
}
dataBuf.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
}
func (c *MCPSSEClient) handleEndpoint(data, eventName string) {
if c.postURLAlreadySet() {
if eventName == "" {
c.handleMessage(data)
}
return
}
target := c.resolvePostURL(data)
c.mu.Lock()
if c.postURL == "" {
c.postURL = target
close(c.endpointSig)
}
c.mu.Unlock()
}
func (c *MCPSSEClient) postURLAlreadySet() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.postURL != ""
}
func (c *MCPSSEClient) resolvePostURL(raw string) string {
parsed, err := url.Parse(raw)
if err != nil || !parsed.IsAbs() {
base, baseErr := url.Parse(c.baseURI)
if baseErr == nil {
ref, refErr := url.Parse(raw)
if refErr == nil {
return base.ResolveReference(ref).String()
}
}
}
return raw
}
func (c *MCPSSEClient) handleMessage(data string) {
var resp struct {
ID json.Number `json:"id"`
Result json.RawMessage `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(data), &resp); err != nil {
return
}
if resp.ID == "" {
return
}
id, err := resp.ID.Int64()
if err != nil || id <= 0 {
return
}
c.mu.Lock()
ch, ok := c.pending[uint64(id)]
if ok {
delete(c.pending, uint64(id))
}
c.mu.Unlock()
if !ok {
return
}
if resp.Error != nil {
ch <- mustJSON(map[string]any{"__error__": resp.Error.Message, "code": resp.Error.Code})
close(ch)
return
}
ch <- resp.Result
close(ch)
}
func mustJSON(v any) json.RawMessage {
raw, _ := json.Marshal(v)
return raw
}
func (c *MCPSSEClient) signalStreamErr(err error) {
c.mu.Lock()
defer c.mu.Unlock()
select {
case c.streamErr <- err:
default:
}
for id, ch := range c.pending {
ch <- mustJSON(map[string]any{"__error__": err.Error()})
close(ch)
delete(c.pending, id)
}
}
func (c *MCPSSEClient) shutdown() {
c.mu.Lock()
defer c.mu.Unlock()
if c.cancelFn != nil {
c.cancelFn()
}
c.started = false
c.postURL = ""
c.pending = nil
}
func (c *MCPSSEClient) initialize(ctx context.Context) error {
_, err := c.callRPC(ctx, "initialize", map[string]any{
"protocolVersion": mcpProtocolVersion,
"capabilities": map[string]any{},
"clientInfo": map[string]any{
"name": mcpClientName,
"version": mcpClientVersion,
},
})
if err != nil {
return fmt.Errorf("mcp initialize: %w", err)
}
if err := c.notify(ctx, "notifications/initialized", map[string]any{}); err != nil {
return fmt.Errorf("mcp notifications/initialized: %w", err)
}
return nil
}
func (c *MCPSSEClient) CallTool(ctx context.Context, name, arguments string) (string, error) {
if err := c.ensureStarted(ctx); err != nil {
return "", err
}
args := map[string]any{}
trimmed := strings.TrimSpace(arguments)
if trimmed != "" {
if err := json.Unmarshal([]byte(trimmed), &args); err != nil {
return "", fmt.Errorf("mcp tool %q arguments not valid json: %w", name, err)
}
}
result, err := c.callRPC(ctx, "tools/call", map[string]any{
"name": name,
"arguments": args,
})
if err != nil {
return "", fmt.Errorf("mcp tools/call %q: %w", name, err)
}
return extractToolText(result), nil
}
func (c *MCPSSEClient) ListTools(ctx context.Context) ([]string, error) {
if err := c.ensureStarted(ctx); err != nil {
return nil, err
}
result, err := c.callRPC(ctx, "tools/list", map[string]any{})
if err != nil {
return nil, err
}
var parsed struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
}
if err := json.Unmarshal(result, &parsed); err != nil {
return nil, err
}
names := make([]string, 0, len(parsed.Tools))
for _, t := range parsed.Tools {
names = append(names, t.Name)
}
return names, nil
}
type toolCallResult struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
}
func extractToolText(raw json.RawMessage) string {
var parsed toolCallResult
if err := json.Unmarshal(raw, &parsed); err != nil {
return string(raw)
}
parts := make([]string, 0, len(parsed.Content))
for _, item := range parsed.Content {
if item.Type == "text" && item.Text != "" {
parts = append(parts, item.Text)
}
}
if len(parts) == 0 {
return string(raw)
}
return strings.Join(parts, "\n")
}
func (c *MCPSSEClient) callRPC(ctx context.Context, method string, params any) (json.RawMessage, error) {
id := c.nextID.Add(1)
ch := make(chan json.RawMessage, 1)
c.mu.Lock()
postURL := c.postURL
c.pending[id] = ch
c.mu.Unlock()
if postURL == "" {
return nil, fmt.Errorf("mcp post url is not set")
}
body, err := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
})
if err != nil {
return nil, fmt.Errorf("mcp marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, postURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("mcp build post: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("mcp post: %w", err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("mcp post status %d", resp.StatusCode)
}
waitCtx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
select {
case msg, ok := <-ch:
if !ok {
return nil, fmt.Errorf("mcp call %s closed unexpectedly", method)
}
var probe struct {
Err string `json:"__error__"`
}
if err := json.Unmarshal(msg, &probe); err == nil && probe.Err != "" {
return nil, fmt.Errorf("%s", probe.Err)
}
return msg, nil
case <-waitCtx.Done():
return nil, fmt.Errorf("mcp call %s timeout: %w", method, waitCtx.Err())
}
}
func (c *MCPSSEClient) notify(ctx context.Context, method string, params any) error {
c.mu.Lock()
postURL := c.postURL
c.mu.Unlock()
if postURL == "" {
return fmt.Errorf("mcp post url is not set")
}
body, err := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"method": method,
"params": params,
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, postURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return nil
}
type MCPToolRouter struct {
clients map[string]*MCPSSEClient
tools []ports.Tool
registered map[string]struct{}
listTimeout time.Duration
listFailures map[string]error
}
func NewMCPToolRouter() *MCPToolRouter {
return &MCPToolRouter{
clients: make(map[string]*MCPSSEClient),
registered: make(map[string]struct{}),
listTimeout: 10 * time.Second,
listFailures: make(map[string]error),
}
}
func (r *MCPToolRouter) Register(tool ports.Tool) {
r.RegisterAndExpand(tool)
}
func (r *MCPToolRouter) RegisterAndExpand(tool ports.Tool) []ports.Tool {
mcp, ok := tool.(MCPTool)
if !ok {
r.tools = append(r.tools, tool)
return []ports.Tool{tool}
}
if mcp.TransportType != "sse" {
r.tools = append(r.tools, mcp)
return []ports.Tool{mcp}
}
if _, exists := r.clients[mcp.ToolName]; exists {
return nil
}
client := NewMCPSSEClient(mcp.BaseURI, mcp.SSEEndpoint, mcp.RequestTimeout)
r.clients[mcp.ToolName] = client
ctx, cancel := context.WithTimeout(context.Background(), r.listTimeout)
defer cancel()
names, err := client.ListTools(ctx)
if err != nil {
r.listFailures[mcp.ToolName] = err
r.tools = append(r.tools, mcp)
return []ports.Tool{mcp}
}
expanded := make([]ports.Tool, 0, len(names))
for _, n := range names {
r.clients[n] = client
r.registered[n] = struct{}{}
t := EinoTool{ToolName: n}
r.tools = append(r.tools, t)
expanded = append(expanded, t)
}
return expanded
}
func (r *MCPToolRouter) Tools() []ports.Tool {
return r.tools
}
func (r *MCPToolRouter) CallTool(ctx context.Context, name, arguments string) (string, error) {
if client, ok := r.clients[name]; ok {
return client.CallTool(ctx, name, arguments)
}
for _, tool := range r.tools {
if tool.Name() != name {
continue
}
switch tool.(type) {
case MCPTool:
return "", fmt.Errorf("mcp tool %q transport not supported in runtime", name)
default:
return "", fmt.Errorf("tool %q is not callable in current runtime", name)
}
}
return "", fmt.Errorf("tool %q is not registered", name)
}

View File

@@ -0,0 +1,319 @@
package ai
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"ai-agent-scaffold-go/internal/domain/agent/ports"
)
type OpenAIClient struct {
httpClient *http.Client
completionsURL string
apiKey string
model string
}
type OpenAIToolDef struct {
Name string
Description string
}
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,
}
}
func (c *OpenAIClient) Generate(ctx context.Context, messages []ports.ChatMessage, tools []OpenAIToolDef) (ports.ChatReply, error) {
body, err := buildRequestBody(c.model, messages, tools, false)
if err != nil {
return ports.ChatReply{}, err
}
resp, err := c.do(ctx, body)
if err != nil {
return ports.ChatReply{}, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return ports.ChatReply{}, fmt.Errorf("openai read body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ports.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 ports.ChatReply{}, fmt.Errorf("openai decode: %w", err)
}
if len(parsed.Choices) == 0 {
return ports.ChatReply{}, fmt.Errorf("openai response has no choices")
}
choice := parsed.Choices[0].Message
reply := ports.ChatReply{Content: choice.Content}
for _, tc := range choice.ToolCalls {
reply.ToolCalls = append(reply.ToolCalls, ports.ChatToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
})
}
return reply, nil
}
func (c *OpenAIClient) Stream(ctx context.Context, messages []ports.ChatMessage, tools []OpenAIToolDef) (<-chan ports.ChatStreamEvent, <-chan error) {
events := make(chan ports.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]*ports.ChatToolCall{}
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
emitToolCalls(events, toolCallBuf)
events <- ports.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 <- ports.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 <- ports.ChatStreamEvent{Delta: delta.Content}:
case <-ctx.Done():
errs <- ctx.Err()
return
}
}
for _, tc := range delta.ToolCalls {
current, ok := toolCallBuf[tc.Index]
if !ok {
current = &ports.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
}
func emitToolCalls(events chan<- ports.ChatStreamEvent, buf map[int]*ports.ChatToolCall) {
if len(buf) == 0 {
return
}
calls := make([]ports.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 <- ports.ChatStreamEvent{ToolCalls: calls}
}
}
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)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("openai http: %w", err)
}
return resp, nil
}
func buildRequestBody(modelName string, messages []ports.ChatMessage, tools []OpenAIToolDef, stream bool) ([]byte, error) {
payload := map[string]any{
"model": modelName,
"messages": encodeMessages(messages),
"stream": stream,
}
if len(tools) > 0 {
payload["tools"] = encodeTools(tools)
}
raw, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("openai marshal request: %w", err)
}
return raw, nil
}
func encodeMessages(messages []ports.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 != ports.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 _, c := range m.ToolCalls {
calls = append(calls, map[string]any{
"id": c.ID,
"type": "function",
"function": map[string]any{
"name": c.Name,
"arguments": c.Arguments,
},
})
}
entry["tool_calls"] = calls
}
encoded = append(encoded, entry)
}
return encoded
}
func encodeTools(tools []OpenAIToolDef) []map[string]any {
out := make([]map[string]any, 0, len(tools))
for _, t := range tools {
out = append(out, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": fallbackDescription(t),
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
"type": "string",
"description": "自由文本输入或检索查询,工具会按其语义解释",
},
},
},
},
})
}
return out
}
func fallbackDescription(t OpenAIToolDef) string {
if strings.TrimSpace(t.Description) != "" {
return t.Description
}
return "外部工具 " + t.Name + ",参数 query 为自由文本"
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
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"`
FinishReason string `json:"finish_reason"`
} `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,508 @@
package ai
import (
"context"
"fmt"
"io/fs"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"ai-agent-scaffold-go/internal/domain/agent/model"
"ai-agent-scaffold-go/internal/domain/agent/ports"
"gopkg.in/yaml.v3"
)
type ToolFactory struct {
router *MCPToolRouter
}
type SkillFactory struct{}
type MCPTool struct {
ToolName string
TransportType string
BaseURI string
SSEEndpoint string
Command string
Args []string
Env map[string]string
RequestTimeout int
}
type SkillTool struct {
ToolName string
SkillName string
Description string
Path string
ManifestPath string
}
func NewToolFactory(router *MCPToolRouter) *ToolFactory {
return &ToolFactory{router: router}
}
func NewSkillFactory() *SkillFactory {
return &SkillFactory{}
}
func (f *ToolFactory) BuildTools(_ context.Context, config model.ToolMCPConfig) ([]ports.Tool, error) {
kind, err := validateMCPConfig(config)
if err != nil {
return nil, err
}
var (
tools []ports.Tool
)
switch {
case kind == "local":
tools, err = buildLocalMCPTools(config.Local)
case kind == "sse":
tools, err = buildSSEMCPTools(config.SSE)
case kind == "stdio":
tools, err = buildStdioMCPTools(config.Stdio)
case config.SSE != nil:
tools, err = buildSSEMCPTools(config.SSE)
case config.Stdio != nil:
tools, err = buildStdioMCPTools(config.Stdio)
default:
return nil, fmt.Errorf("mcp tool config is empty")
}
if err != nil {
return nil, err
}
if f.router != nil {
expanded := make([]ports.Tool, 0, len(tools))
for _, t := range tools {
ts := f.router.RegisterAndExpand(t)
if len(ts) == 0 {
continue
}
expanded = append(expanded, ts...)
}
if len(expanded) > 0 {
return expanded, nil
}
}
return tools, nil
}
func (t MCPTool) Name() string {
return t.ToolName
}
func (f *SkillFactory) BuildTools(_ context.Context, config model.ToolSkillsConfig) ([]ports.Tool, error) {
skillType := strings.TrimSpace(config.Type)
if skillType == "" {
skillType = "directory"
}
if skillType != "directory" && skillType != "resource" {
return nil, fmt.Errorf("unsupported skill type %q", config.Type)
}
rawPath := strings.TrimSpace(config.Path)
if rawPath == "" {
return nil, fmt.Errorf("skill path is required")
}
root, err := resolveSkillRoot(skillType, rawPath)
if err != nil {
return nil, err
}
manifests, err := findSkillManifests(root)
if err != nil {
return nil, err
}
if len(manifests) == 0 {
return nil, fmt.Errorf("skill path %q has no SKILL.md files", root)
}
tools := make([]ports.Tool, 0, len(manifests))
for _, manifest := range manifests {
tool, err := loadSkillTool(root, manifest)
if err != nil {
return nil, err
}
tools = append(tools, tool)
}
return tools, nil
}
func (t SkillTool) Name() string {
return t.ToolName
}
func resolveSkillRoot(skillType, rawPath string) (string, error) {
if filepath.IsAbs(rawPath) {
return existingPath(rawPath, rawPath)
}
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("resolve skill path %q: %w", rawPath, err)
}
candidates := skillPathCandidates(cwd, skillType, rawPath)
for _, candidate := range candidates {
if path, err := existingPath(candidate, rawPath); err == nil {
return path, nil
}
}
return "", fmt.Errorf("skill path %q cannot be resolved", rawPath)
}
func skillPathCandidates(cwd, skillType, rawPath string) []string {
var candidates []string
add := func(path string) {
clean := filepath.Clean(path)
for _, candidate := range candidates {
if candidate == clean {
return
}
}
candidates = append(candidates, clean)
}
for dir := cwd; ; dir = filepath.Dir(dir) {
add(filepath.Join(dir, rawPath))
add(filepath.Join(dir, "configs", rawPath))
add(filepath.Join(dir, "ai-agent-scaffold-go", rawPath))
add(filepath.Join(dir, "ai-agent-scaffold-go", "configs", rawPath))
if skillType == "resource" && strings.HasPrefix(rawPath, "agent"+string(filepath.Separator)) {
add(filepath.Join(dir, "configs", rawPath))
add(filepath.Join(dir, "ai-agent-scaffold-go", "configs", rawPath))
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
}
return candidates
}
func existingPath(candidate, original string) (string, error) {
info, err := os.Stat(candidate)
if err != nil {
return "", fmt.Errorf("skill path %q cannot be resolved", original)
}
if !info.IsDir() && filepath.Base(candidate) != "SKILL.md" {
return "", fmt.Errorf("skill path %q is not a directory or SKILL.md file", candidate)
}
return candidate, nil
}
func findSkillManifests(root string) ([]string, error) {
info, err := os.Stat(root)
if err != nil {
return nil, err
}
if !info.IsDir() {
return []string{root}, nil
}
var manifests []string
err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
if entry.Name() == "SKILL.md" {
manifests = append(manifests, path)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("scan skill path %q: %w", root, err)
}
sort.Strings(manifests)
return manifests, nil
}
func loadSkillTool(root, manifest string) (SkillTool, error) {
data, err := os.ReadFile(manifest)
if err != nil {
return SkillTool{}, fmt.Errorf("read skill manifest %q: %w", manifest, err)
}
meta, err := parseSkillFrontMatter(string(data))
if err != nil {
return SkillTool{}, fmt.Errorf("parse skill manifest %q: %w", manifest, err)
}
skillDir := filepath.Dir(manifest)
name := strings.TrimSpace(meta["name"])
if name == "" {
name = filepath.Base(skillDir)
}
description := strings.TrimSpace(meta["description"])
return SkillTool{
ToolName: "skill_" + sanitizeToolName(name),
SkillName: name,
Description: description,
Path: skillDir,
ManifestPath: manifest,
}, nil
}
func sanitizeToolName(name string) string {
var b strings.Builder
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := b.String()
if out == "" {
out = "tool"
}
if len(out) > 64 {
out = out[:64]
}
return out
}
func parseSkillFrontMatter(content string) (map[string]string, error) {
result := make(map[string]string)
if !strings.HasPrefix(content, "---") {
return result, nil
}
rest := strings.TrimPrefix(content, "---")
end := strings.Index(rest, "\n---")
if end < 0 {
return result, fmt.Errorf("front matter terminator is required")
}
frontMatter := rest[:end]
if err := yaml.Unmarshal([]byte(frontMatter), &result); err != nil {
return nil, err
}
return result, nil
}
func validateMCPConfig(config model.ToolMCPConfig) (string, error) {
count := 0
kind := ""
if config.Local != nil {
count++
kind = "local"
}
if config.SSE != nil {
count++
kind = "sse"
}
if config.Stdio != nil {
count++
kind = "stdio"
}
switch count {
case 0:
return "", fmt.Errorf("mcp config must define exactly one of local, sse, or stdio")
case 1:
return kind, nil
default:
return "", fmt.Errorf("mcp config cannot define multiple transports in one entry")
}
}
func buildLocalMCPTools(config *model.LocalToolParameters) ([]ports.Tool, error) {
name := strings.TrimSpace(config.Name)
if name == "" {
return nil, fmt.Errorf("local mcp tool name is required")
}
switch name {
case "echoLocalTool":
return []ports.Tool{MCPTool{ToolName: name, TransportType: "local"}}, nil
default:
return nil, fmt.Errorf("local mcp tool %q is not registered in go", name)
}
}
func buildSSEMCPTools(config *model.SSEServerParameters) ([]ports.Tool, error) {
name := strings.TrimSpace(config.Name)
if name == "" {
return nil, fmt.Errorf("sse mcp tool name is required")
}
baseURI := strings.TrimSpace(config.BaseURI)
if baseURI == "" {
return nil, fmt.Errorf("sse mcp tool %q base-uri is required", name)
}
normalizedBaseURI, endpoint, err := normalizeSSETarget(baseURI, strings.TrimSpace(config.SSEEndpoint))
if err != nil {
return nil, fmt.Errorf("sse mcp tool %q: %w", name, err)
}
timeout := normalizeTimeout(config.RequestTimeout)
return []ports.Tool{MCPTool{
ToolName: name,
TransportType: "sse",
BaseURI: normalizedBaseURI,
SSEEndpoint: endpoint,
RequestTimeout: timeout,
}}, nil
}
func buildStdioMCPTools(config *model.StdioServerParameters) ([]ports.Tool, error) {
name := strings.TrimSpace(config.Name)
if name == "" {
return nil, fmt.Errorf("stdio mcp tool name is required")
}
command := strings.TrimSpace(config.ServerParameters.Command)
if command == "" {
return nil, fmt.Errorf("stdio mcp tool %q command is required", name)
}
timeout := normalizeTimeout(config.RequestTimeout)
return []ports.Tool{MCPTool{
ToolName: name,
TransportType: "stdio",
Command: command,
Args: append([]string(nil), config.ServerParameters.Args...),
Env: cloneEnv(config.ServerParameters.Env),
RequestTimeout: timeout,
}}, nil
}
func normalizeSSETarget(baseURI, endpoint string) (string, string, error) {
parsed, err := url.Parse(baseURI)
if err != nil {
return "", "", fmt.Errorf("invalid base-uri: %w", err)
}
if parsed.Scheme == "" || parsed.Host == "" {
return "", "", fmt.Errorf("base-uri must include scheme and host")
}
host := strings.TrimRight(parsed.Scheme+"://"+parsed.Host, "/")
basePath := parsed.RawPath
if basePath == "" {
basePath = parsed.EscapedPath()
}
baseQuery := parsed.RawQuery
if endpoint == "" {
if basePath == "" || basePath == "/" {
return host, "/sse", nil
}
merged := basePath
if baseQuery != "" {
merged += "?" + baseQuery
}
return host, normalizeEndpoint(merged), nil
}
endpointPath, endpointQuery := splitPathQuery(endpoint)
mergedPath := joinPaths(basePath, endpointPath)
mergedQuery := mergeQueries(baseQuery, endpointQuery)
if mergedQuery != "" {
mergedPath += "?" + mergedQuery
}
return host, normalizeEndpoint(mergedPath), nil
}
func splitPathQuery(raw string) (string, string) {
if idx := strings.Index(raw, "?"); idx >= 0 {
return raw[:idx], raw[idx+1:]
}
return raw, ""
}
func joinPaths(base, extra string) string {
if extra == "" {
if base == "" {
return "/"
}
return base
}
if strings.HasPrefix(extra, "/") {
return extra
}
if base == "" {
return "/" + extra
}
if strings.HasSuffix(base, "/") {
return base + extra
}
return base + "/" + extra
}
func mergeQueries(base, extra string) string {
switch {
case base == "" && extra == "":
return ""
case base == "":
return extra
case extra == "":
return base
default:
return base + "&" + extra
}
}
func normalizeEndpoint(endpoint string) string {
trimmed := strings.TrimSpace(endpoint)
if trimmed == "" {
return "/sse"
}
if strings.HasPrefix(trimmed, "/") {
return trimmed
}
return "/" + trimmed
}
func normalizeTimeout(timeout int) int {
if timeout > 0 {
return timeout
}
return 300000
}
func cloneEnv(values map[string]string) map[string]string {
if len(values) == 0 {
return nil
}
cloned := make(map[string]string, len(values))
for key, value := range values {
cloned[key] = value
}
return cloned
}
func maskSecretPath(raw string) string {
if strings.TrimSpace(raw) == "" {
return raw
}
parsed, err := url.Parse(raw)
if err != nil {
return raw
}
query := parsed.Query()
changed := false
for key := range query {
upper := strings.ToUpper(key)
if strings.Contains(upper, "KEY") || strings.Contains(upper, "TOKEN") || strings.Contains(upper, "SECRET") {
query.Set(key, "${"+strings.ToUpper(strings.ReplaceAll(key, "-", "_"))+"}")
changed = true
}
}
if !changed {
return raw
}
parsed.RawQuery = query.Encode()
return parsed.String()
}
func mcpTimeoutString(timeout int) string {
return strconv.Itoa(normalizeTimeout(timeout))
}

35
internal/infrastructure/cache/redis.go vendored Normal file
View File

@@ -0,0 +1,35 @@
package cache
import (
"context"
"fmt"
"strings"
"github.com/redis/go-redis/v9"
)
type RedisConfig struct {
Required bool
Addr string
Password string
DB int
}
func OpenRedis(config RedisConfig) (*redis.Client, error) {
if strings.TrimSpace(config.Addr) == "" {
if config.Required {
return nil, fmt.Errorf("redis addr is required")
}
return nil, nil
}
client := redis.NewClient(&redis.Options{
Addr: config.Addr,
Password: config.Password,
DB: config.DB,
})
if err := client.Ping(context.Background()).Err(); err != nil {
_ = client.Close()
return nil, fmt.Errorf("ping redis: %w", err)
}
return client, nil
}

View File

@@ -0,0 +1,20 @@
// Package dependencies anchors verified runtime dependencies while adapters are added.
package dependencies
import (
_ "github.com/cloudwego/eino/adk"
_ "github.com/cloudwego/eino/components/model"
_ "github.com/cloudwego/eino/components/tool"
_ "github.com/gin-gonic/gin"
_ "github.com/redis/go-redis/v9"
_ "go.uber.org/zap"
_ "google.golang.org/adk/agent"
_ "google.golang.org/adk/agent/llmagent"
_ "google.golang.org/adk/agent/workflowagents/loopagent"
_ "google.golang.org/adk/agent/workflowagents/parallelagent"
_ "google.golang.org/adk/agent/workflowagents/sequentialagent"
_ "google.golang.org/adk/runner"
_ "gopkg.in/yaml.v3"
_ "gorm.io/driver/mysql"
_ "gorm.io/gorm"
)

View File

@@ -0,0 +1,2 @@
// Package infrastructure contains adapters for persistence, cache, AI, and runtime providers.
package infrastructure

View File

@@ -0,0 +1,10 @@
package logging
import "go.uber.org/zap"
func New(env string) (*zap.Logger, error) {
if env == "prod" || env == "production" {
return zap.NewProduction()
}
return zap.NewDevelopment()
}

View File

@@ -0,0 +1,35 @@
package persistence
import (
"fmt"
"strings"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
type MySQLConfig struct {
Required bool
DSN string
}
func OpenMySQL(config MySQLConfig) (*gorm.DB, error) {
if strings.TrimSpace(config.DSN) == "" {
if config.Required {
return nil, fmt.Errorf("mysql dsn is required")
}
return nil, nil
}
db, err := gorm.Open(mysql.Open(config.DSN), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("mysql db handle: %w", err)
}
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("ping mysql: %w", err)
}
return db, nil
}

2
internal/trigger/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package trigger contains inbound adapters such as HTTP handlers.
package trigger

View File

@@ -0,0 +1,109 @@
package http
import (
"errors"
"net/http"
"strings"
"ai-agent-scaffold-go/internal/api/dto"
"ai-agent-scaffold-go/internal/api/response"
"ai-agent-scaffold-go/internal/domain/agent/service/chat"
"ai-agent-scaffold-go/pkg/types"
"github.com/gin-gonic/gin"
)
func RegisterAgentRoutes(router gin.IRouter, service *chat.Service) {
group := router.Group("/api/v1")
group.GET("/query_ai_agent_config_list", queryAgentConfigList(service))
group.POST("/create_session", createSession(service))
group.GET("/create_session", createSessionQuery(service))
group.POST("/chat", chatMessage(service))
group.POST("/chat_stream", chatStream(service))
}
func queryAgentConfigList(service *chat.Service) gin.HandlerFunc {
return func(c *gin.Context) {
agents := service.QueryAgentConfigList()
responses := make([]dto.AiAgentConfigResponse, 0, len(agents))
for _, agent := range agents {
responses = append(responses, dto.AiAgentConfigResponse{
AgentID: agent.AgentID,
AgentName: agent.AgentName,
AgentDesc: agent.AgentDesc,
})
}
c.JSON(http.StatusOK, response.Success(responses))
}
}
func createSession(service *chat.Service) gin.HandlerFunc {
return func(c *gin.Context) {
var request dto.CreateSessionRequest
if err := c.ShouldBindJSON(&request); err != nil {
writeError(c, types.NewAppError(types.CodeIllegalParameter, err.Error()))
return
}
sessionID, err := service.CreateSession(request.AgentID, request.UserID)
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, response.Success(dto.CreateSessionResponse{SessionID: sessionID}))
}
}
func createSessionQuery(service *chat.Service) gin.HandlerFunc {
return func(c *gin.Context) {
sessionID, err := service.CreateSession(c.Query("agentId"), c.Query("userId"))
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, response.Success(dto.CreateSessionResponse{SessionID: sessionID}))
}
}
func chatMessage(service *chat.Service) gin.HandlerFunc {
return func(c *gin.Context) {
var request dto.ChatRequest
if err := c.ShouldBindJSON(&request); err != nil {
writeError(c, types.NewAppError(types.CodeIllegalParameter, err.Error()))
return
}
outputs, err := service.HandleMessage(request.AgentID, request.UserID, request.SessionID, request.Message)
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, response.Success(dto.ChatResponse{Content: strings.Join(outputs, "\n")}))
}
}
func chatStream(service *chat.Service) gin.HandlerFunc {
return func(c *gin.Context) {
var request dto.ChatRequest
if err := c.ShouldBindJSON(&request); err != nil {
writeError(c, types.NewAppError(types.CodeIllegalParameter, err.Error()))
return
}
outputs, errs := service.HandleMessageStream(request.AgentID, request.UserID, request.SessionID, request.Message)
c.Header("Content-Type", "text/event-stream")
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 writeError(c *gin.Context, err error) {
var appErr *types.AppError
if errors.As(err, &appErr) {
c.JSON(http.StatusOK, response.Failure(appErr.Code, appErr.Info))
return
}
c.JSON(http.StatusOK, response.Failure(types.CodeUnknownError, err.Error()))
}

12
pkg/types/codes.go Normal file
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"
)

2
pkg/types/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package types contains shared public support types for the scaffold.
package types

17
pkg/types/errors.go Normal file
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
}