Compare commits
3
Commits
2c4d4af070
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e797cfec1 | ||
|
|
4e4c6b32fc | ||
|
|
249227ef0a |
@@ -0,0 +1,272 @@
|
||||
# aichat
|
||||
|
||||
一个基于 Go + Gin 的 AI 聊天 Web 应用,默认使用火山引擎 Ark/OpenAI 兼容接口,支持多模型配置、流式输出、工具调用、图片输入、上下文窗口管理和本地对话历史。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **多模型配置**:在配置文件中维护多个 OpenAI 兼容模型,并可在页面中切换当前模型。
|
||||
- **流式对话**:通过 SSE 实时返回模型响应,支持 Token 统计与速度展示。
|
||||
- **工具调用**:内置搜索、计算器、时间、SQL 查询等工具,并通过工具路由按需调用。
|
||||
- **上下文窗口管理**:根据模型上下文长度自动保留、截断历史消息。
|
||||
- **对话持久化**:会话保存在本地 `conversations/` 目录,支持创建、查看和删除。
|
||||
- **多模态输入**:支持图片消息输入,由兼容多模态的模型处理。
|
||||
- **思考标签解析**:可解析模型输出中的 `<think>` 内容并单独展示。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Go 1.25+
|
||||
- Gin Web Framework
|
||||
- 火山引擎 Ark SDK / OpenAI 兼容接口
|
||||
- YAML 配置
|
||||
- SQLite / MySQL 驱动
|
||||
- 原生 HTML/CSS/JavaScript
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 克隆项目
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd aichat
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
### 3. 准备配置
|
||||
|
||||
首次运行时,程序会自动生成 `config.yaml`。你也可以手动创建:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
mode: tcp
|
||||
address: 0.0.0.0:8080
|
||||
|
||||
openai:
|
||||
- name: doubao
|
||||
active: true
|
||||
api_key: ${ARK_API_KEY}
|
||||
base_url: https://ark.cn-beijing.volces.com/api/v3
|
||||
model: doubao-seed-2-0-pro-260215
|
||||
timeout: 120
|
||||
context_window_tokens: 262144
|
||||
|
||||
tool_router:
|
||||
enabled: true
|
||||
openai_name: doubao
|
||||
timeout: 30
|
||||
max_tokens: 512
|
||||
system_prompt: |-
|
||||
你可以按需直接调用可用工具来回答用户问题。
|
||||
每个工具的 description 描述了它的适用场景和调用条件。
|
||||
工具结果优先于模型内置知识;工具失败时必须如实说明,不要编造结果。
|
||||
只调用确实必要的工具。
|
||||
```
|
||||
|
||||
建议使用环境变量提供密钥,避免把 API Key 写入仓库:
|
||||
|
||||
```bash
|
||||
export ARK_API_KEY="your-ark-api-key"
|
||||
```
|
||||
|
||||
> `config.yaml` 已在 `.gitignore` 中忽略,适合作为本地私有配置文件。
|
||||
|
||||
### 4. 启动服务
|
||||
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
启动后访问:
|
||||
|
||||
```text
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
## 构建与测试
|
||||
|
||||
```bash
|
||||
# 构建二进制
|
||||
go build -o aichat main.go
|
||||
|
||||
# 运行全部测试
|
||||
go test ./...
|
||||
|
||||
# 查看详细测试输出
|
||||
go test -v ./...
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### `server`
|
||||
|
||||
| 字段 | 说明 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| `mode` | 服务监听模式,支持 `tcp` 或 Unix socket | `tcp` |
|
||||
| `address` | 监听地址 | `0.0.0.0:8080` |
|
||||
|
||||
### `openai`
|
||||
|
||||
`openai` 是模型配置列表,也兼容单对象配置。常用字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `name` | 配置名称,用于页面展示和切换 |
|
||||
| `active` | 是否为默认激活模型;多个配置只能有一个激活 |
|
||||
| `api_key` | API Key;也可用 `ARK_API_KEY` 环境变量覆盖 |
|
||||
| `base_url` | OpenAI 兼容接口地址 |
|
||||
| `model` | 模型名称 |
|
||||
| `timeout` | 请求超时时间,单位秒 |
|
||||
| `context_window_tokens` | 上下文窗口 Token 上限 |
|
||||
| `parse_think_tags` | 是否解析 `<think>` 标签,可选 |
|
||||
|
||||
### `tool_router`
|
||||
|
||||
工具路由用于判断用户问题是否需要调用工具。
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `enabled` | 是否启用工具路由 |
|
||||
| `openai_name` | 用于工具路由决策的模型配置名称,留空时使用当前激活模型 |
|
||||
| `timeout` | 工具路由请求超时时间,单位秒 |
|
||||
| `max_tokens` | 工具路由最大输出 Token 数 |
|
||||
| `system_prompt` | 工具路由系统提示词 |
|
||||
| `tools` | 可选的工具覆盖配置 |
|
||||
|
||||
## 内置工具
|
||||
|
||||
工具配置位于 `agents/*/config.yaml`。
|
||||
|
||||
| 工具 | 默认状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `search` | 启用 | 联网搜索,默认 DuckDuckGo,可用于实时信息、新闻、版本、网页核验等场景 |
|
||||
| `calculator` | 启用 | 四则运算和简单数学表达式计算 |
|
||||
| `time` | 启用 | 将“今天、明天、本周”等相对时间转换为绝对日期范围 |
|
||||
| `sql` | 禁用 | 查询本地业务数据库,默认 SQLite 只读连接 |
|
||||
|
||||
### 搜索工具
|
||||
|
||||
默认配置:
|
||||
|
||||
```yaml
|
||||
enabled: true
|
||||
profiles:
|
||||
- name: duckduckgo
|
||||
active: true
|
||||
enabled: true
|
||||
provider: duckduckgo
|
||||
base_url: https://api.duckduckgo.com/
|
||||
count: 5
|
||||
timeout: 10
|
||||
```
|
||||
|
||||
如果使用 Brave Search,可设置:
|
||||
|
||||
```bash
|
||||
export BRAVE_SEARCH_API_KEY="your-brave-search-api-key"
|
||||
```
|
||||
|
||||
### SQL 工具
|
||||
|
||||
SQL 工具默认关闭。开启前请确认数据库连接、权限和表白名单配置:
|
||||
|
||||
```yaml
|
||||
enabled: true
|
||||
default_database: default
|
||||
databases:
|
||||
- name: default
|
||||
active: true
|
||||
driver: sqlite
|
||||
dsn: file:data/app.db?mode=ro
|
||||
timeout: 10
|
||||
max_rows: 50
|
||||
max_cell_bytes: 4096
|
||||
schema:
|
||||
include_tables: []
|
||||
exclude_tables: []
|
||||
```
|
||||
|
||||
## HTTP API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/` | Web 聊天页面 |
|
||||
| `POST` | `/api/chat` | 流式聊天接口 |
|
||||
| `GET` | `/api/openai` | 获取模型配置列表 |
|
||||
| `POST` | `/api/openai/active` | 切换当前激活模型 |
|
||||
| `GET` | `/api/search` | 获取搜索配置列表 |
|
||||
| `POST` | `/api/search/active` | 切换当前激活搜索源 |
|
||||
| `GET` | `/api/conversations` | 获取对话列表 |
|
||||
| `POST` | `/api/conversations` | 创建新对话 |
|
||||
| `GET` | `/api/conversations/:id` | 获取指定对话 |
|
||||
| `DELETE` | `/api/conversations/:id` | 删除指定对话 |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── main.go # 应用入口
|
||||
├── config.yaml # 本地配置文件(已忽略)
|
||||
├── config/ # 配置加载、默认值和规范化
|
||||
├── server/ # HTTP 服务、路由和处理器
|
||||
├── llm/ # LLM 客户端与模型状态管理
|
||||
├── message/ # 消息结构和格式转换
|
||||
├── stream/ # SSE、流式响应、Token 统计
|
||||
├── contextwindow/ # 上下文窗口管理
|
||||
├── conversation/ # 对话历史存储
|
||||
├── agenttool/ # 工具注册与加载框架
|
||||
├── toolmanager/ # 工具管理器
|
||||
├── toolrouter/ # 工具路由和调用循环
|
||||
├── agents/ # 内置工具实现与配置
|
||||
│ ├── search/
|
||||
│ ├── calculator/
|
||||
│ ├── time/
|
||||
│ └── sql/
|
||||
└── templates/ # 前端页面模板
|
||||
```
|
||||
|
||||
## 添加新工具
|
||||
|
||||
1. 在 `agents/<tool-name>/` 下新增工具实现和 `config.yaml`。
|
||||
2. 在工具配置中提供 `enabled` 和 `activation_prompt`,说明工具适用场景。
|
||||
3. 使用 `agenttool` 注册工具,确保工具管理器能加载到该工具。
|
||||
4. 根据需要补充测试用例。
|
||||
5. 运行:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 配置文件不存在怎么办?
|
||||
|
||||
程序启动时会自动创建默认 `config.yaml`。你只需要补充模型名称和 API Key。
|
||||
|
||||
### 如何避免提交密钥?
|
||||
|
||||
`config.yaml` 已被 `.gitignore` 忽略。推荐使用环境变量:
|
||||
|
||||
```bash
|
||||
export ARK_API_KEY="your-ark-api-key"
|
||||
```
|
||||
|
||||
### 为什么工具没有被调用?
|
||||
|
||||
请检查:
|
||||
|
||||
1. `tool_router.enabled` 是否为 `true`。
|
||||
2. 对应 `agents/<tool>/config.yaml` 中 `enabled` 是否为 `true`。
|
||||
3. 用户问题是否符合该工具的 `activation_prompt`。
|
||||
4. 工具路由模型配置 `tool_router.openai_name` 是否存在且可用。
|
||||
|
||||
### 对话记录保存在哪里?
|
||||
|
||||
默认保存在本地 `conversations/` 目录。该目录已被 `.gitignore` 忽略。
|
||||
|
||||
## License
|
||||
|
||||
请根据项目实际情况补充许可证信息。
|
||||
@@ -0,0 +1,349 @@
|
||||
package calculator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
agents "aichat/agenttool"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolName = "calculator"
|
||||
ActivationPrompt = "执行简单、确定性的数学四则运算。当用户询问加减乘除、括号表达式、小数运算或需要准确计算表达式结果时,应直接调用此工具;不用于代数推导、方程求解、统计分析或复杂数学证明。"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
ActivationPrompt string `yaml:"activation_prompt" json:"activation_prompt"`
|
||||
}
|
||||
|
||||
type ToolArgs struct {
|
||||
Expression string `json:"expression"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type LoadedTool struct {
|
||||
cfg *Config
|
||||
}
|
||||
|
||||
func NewLoadedTool(cfg *Config) *LoadedTool {
|
||||
if cfg == nil {
|
||||
defaultCfg := defaultConfig()
|
||||
cfg = &defaultCfg
|
||||
}
|
||||
return &LoadedTool{cfg: cfg}
|
||||
}
|
||||
|
||||
func init() {
|
||||
agents.Register(agents.Descriptor{Name: ToolName, Load: func(path string, options agents.LoadOptions) (agents.LoadedTool, error) {
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewLoadedTool(cfg), nil
|
||||
}})
|
||||
}
|
||||
|
||||
func defaultConfig() Config {
|
||||
return Config{Enabled: true, ActivationPrompt: ActivationPrompt}
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("检查计算器工具配置失败: %w", err)
|
||||
}
|
||||
cfg := defaultConfig()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建计算器工具配置目录失败: %w", err)
|
||||
}
|
||||
data, err := yaml.Marshal(&cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成计算器工具配置失败: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return nil, fmt.Errorf("写入计算器工具配置失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取计算器工具配置失败: %w", err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析计算器工具配置失败: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(cfg.ActivationPrompt) == "" {
|
||||
cfg.ActivationPrompt = ActivationPrompt
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (t *LoadedTool) Name() string { return ToolName }
|
||||
|
||||
func (t *LoadedTool) Enabled() bool { return t != nil && t.cfg != nil && t.cfg.Enabled }
|
||||
|
||||
func (t *LoadedTool) ToolDefinition(description string) *model.Tool {
|
||||
if strings.TrimSpace(description) == "" && t != nil && t.cfg != nil {
|
||||
description = t.cfg.ActivationPrompt
|
||||
}
|
||||
return ToolDefinition(description)
|
||||
}
|
||||
|
||||
func (t *LoadedTool) Execute(ctx context.Context, args string, runtime agents.Runtime) (string, error) {
|
||||
result, err := ExecuteTool(args)
|
||||
if err == nil && runtime.Emit != nil {
|
||||
runtime.Emit(agents.Frame{Type: "trace", Tool: ToolName, Stage: "calculate", Status: "success", Message: "四则运算完成"})
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (t *LoadedTool) RawState() any { return nil }
|
||||
|
||||
func ToolDefinition(description string) *model.Tool {
|
||||
description = strings.TrimSpace(description)
|
||||
if description == "" {
|
||||
description = ActivationPrompt
|
||||
}
|
||||
return &model.Tool{
|
||||
Type: model.ToolTypeFunction,
|
||||
Function: &model.FunctionDefinition{
|
||||
Name: ToolName,
|
||||
Description: description,
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"expression": map[string]any{
|
||||
"type": "string",
|
||||
"description": "要计算的四则运算表达式,例如 12.5*(3+4)/2。仅支持数字、+、-、*、/ 和括号。",
|
||||
},
|
||||
"reason": map[string]any{
|
||||
"type": "string",
|
||||
"description": "调用计算器工具的原因。",
|
||||
},
|
||||
},
|
||||
"required": []string{"expression"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ExecuteTool(args string) (string, error) {
|
||||
var parsed ToolArgs
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(args)), &parsed); err != nil {
|
||||
return "", fmt.Errorf("解析计算器工具参数失败: %w", err)
|
||||
}
|
||||
expression := strings.TrimSpace(parsed.Expression)
|
||||
if expression == "" {
|
||||
return "", errors.New("计算表达式不能为空")
|
||||
}
|
||||
result, err := Evaluate(expression)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return BuildResultContext(expression, result, parsed.Reason), nil
|
||||
}
|
||||
|
||||
func BuildResultContext(expression string, result float64, routeReason string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("计算器工具结果。请优先使用这里的精确计算结果回答用户,不要重新心算。\n")
|
||||
fmt.Fprintf(&b, "表达式: %s\n", strings.TrimSpace(expression))
|
||||
fmt.Fprintf(&b, "结果: %s\n", FormatNumber(result))
|
||||
if strings.TrimSpace(routeReason) != "" {
|
||||
b.WriteString("调用原因: " + strings.TrimSpace(routeReason) + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func FormatNumber(value float64) string {
|
||||
if math.IsInf(value, 0) || math.IsNaN(value) {
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||
}
|
||||
|
||||
func Evaluate(expression string) (float64, error) {
|
||||
parser := expressionParser{input: normalizeExpression(expression)}
|
||||
value, err := parser.parseExpression()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
parser.skipSpaces()
|
||||
if !parser.atEnd() {
|
||||
return 0, fmt.Errorf("表达式包含不支持的字符: %q", parser.peek())
|
||||
}
|
||||
if math.IsInf(value, 0) || math.IsNaN(value) {
|
||||
return 0, errors.New("计算结果无效")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func normalizeExpression(expression string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"×", "*",
|
||||
"*", "*",
|
||||
"÷", "/",
|
||||
"/", "/",
|
||||
"(", "(",
|
||||
")", ")",
|
||||
"+", "+",
|
||||
"-", "-",
|
||||
",", ".",
|
||||
)
|
||||
return replacer.Replace(expression)
|
||||
}
|
||||
|
||||
type expressionParser struct {
|
||||
input string
|
||||
pos int
|
||||
}
|
||||
|
||||
func (p *expressionParser) parseExpression() (float64, error) {
|
||||
value, err := p.parseTerm()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for {
|
||||
p.skipSpaces()
|
||||
switch p.peek() {
|
||||
case '+':
|
||||
p.pos++
|
||||
rhs, err := p.parseTerm()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
value += rhs
|
||||
case '-':
|
||||
p.pos++
|
||||
rhs, err := p.parseTerm()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
value -= rhs
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *expressionParser) parseTerm() (float64, error) {
|
||||
value, err := p.parseFactor()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for {
|
||||
p.skipSpaces()
|
||||
switch p.peek() {
|
||||
case '*':
|
||||
p.pos++
|
||||
rhs, err := p.parseFactor()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
value *= rhs
|
||||
case '/':
|
||||
p.pos++
|
||||
rhs, err := p.parseFactor()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if rhs == 0 {
|
||||
return 0, errors.New("除数不能为 0")
|
||||
}
|
||||
value /= rhs
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *expressionParser) parseFactor() (float64, error) {
|
||||
p.skipSpaces()
|
||||
switch p.peek() {
|
||||
case '+':
|
||||
p.pos++
|
||||
return p.parseFactor()
|
||||
case '-':
|
||||
p.pos++
|
||||
value, err := p.parseFactor()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return -value, nil
|
||||
case '(':
|
||||
p.pos++
|
||||
value, err := p.parseExpression()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p.skipSpaces()
|
||||
if p.peek() != ')' {
|
||||
return 0, errors.New("缺少右括号")
|
||||
}
|
||||
p.pos++
|
||||
return value, nil
|
||||
default:
|
||||
return p.parseNumber()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *expressionParser) parseNumber() (float64, error) {
|
||||
p.skipSpaces()
|
||||
start := p.pos
|
||||
dotSeen := false
|
||||
for !p.atEnd() {
|
||||
r := p.peek()
|
||||
if r == '.' {
|
||||
if dotSeen {
|
||||
break
|
||||
}
|
||||
dotSeen = true
|
||||
p.pos++
|
||||
continue
|
||||
}
|
||||
if !unicode.IsDigit(rune(r)) {
|
||||
break
|
||||
}
|
||||
p.pos++
|
||||
}
|
||||
if start == p.pos {
|
||||
if p.atEnd() {
|
||||
return 0, errors.New("表达式不完整")
|
||||
}
|
||||
return 0, fmt.Errorf("期望数字,遇到 %q", p.peek())
|
||||
}
|
||||
value, err := strconv.ParseFloat(p.input[start:p.pos], 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("解析数字失败: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (p *expressionParser) skipSpaces() {
|
||||
for !p.atEnd() && unicode.IsSpace(rune(p.peek())) {
|
||||
p.pos++
|
||||
}
|
||||
}
|
||||
|
||||
func (p *expressionParser) peek() byte {
|
||||
if p.atEnd() {
|
||||
return 0
|
||||
}
|
||||
return p.input[p.pos]
|
||||
}
|
||||
|
||||
func (p *expressionParser) atEnd() bool {
|
||||
return p.pos >= len(p.input)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package calculator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvaluateBasicArithmetic(t *testing.T) {
|
||||
tests := []struct {
|
||||
expression string
|
||||
want float64
|
||||
}{
|
||||
{expression: "1+2*3", want: 7},
|
||||
{expression: "(1+2)*3", want: 9},
|
||||
{expression: "12.5*(3+4)/2", want: 43.75},
|
||||
{expression: "-2 + 3", want: 1},
|
||||
{expression: "(8+4)÷3", want: 4},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := Evaluate(tt.expression)
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate(%q) error: %v", tt.expression, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("Evaluate(%q) = %v, want %v", tt.expression, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateErrors(t *testing.T) {
|
||||
for _, expression := range []string{"1/0", "1+", "(1+2", "2^3"} {
|
||||
if _, err := Evaluate(expression); err == nil {
|
||||
t.Fatalf("Evaluate(%q) expected error", expression)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolDefinitionAndExecuteTool(t *testing.T) {
|
||||
definition := ToolDefinition("custom calculator")
|
||||
if definition.Function == nil || definition.Function.Name != ToolName || definition.Function.Description != "custom calculator" {
|
||||
t.Fatalf("unexpected definition: %#v", definition)
|
||||
}
|
||||
text, err := ExecuteTool(`{"expression":"12.5*(3+4)/2","reason":"用户询问计算结果"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"计算器工具结果", "12.5*(3+4)/2", "43.75", "用户询问计算结果"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("tool result missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
agents "aichat/agenttool"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -82,6 +84,10 @@ type State struct {
|
||||
activeName string
|
||||
}
|
||||
|
||||
type LoadedTool struct {
|
||||
state *State
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
@@ -98,6 +104,80 @@ type ToolArgs struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
agents.Register(agents.Descriptor{Name: ToolName, Load: func(path string, options agents.LoadOptions) (agents.LoadedTool, error) {
|
||||
legacy, err := legacyProfilesFromOption(options.Value(agents.LegacySearchProfilesKey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := LoadConfig(path, legacy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state, err := NewState(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LoadedTool{state: state}, nil
|
||||
}})
|
||||
}
|
||||
|
||||
func legacyProfilesFromOption(value any) ([]ProfileConfig, error) {
|
||||
switch profiles := value.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case []ProfileConfig:
|
||||
return profiles, nil
|
||||
case []map[string]any:
|
||||
data, err := yaml.Marshal(profiles)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("转换旧搜索配置失败: %w", err)
|
||||
}
|
||||
var parsed []ProfileConfig
|
||||
if err := yaml.Unmarshal(data, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("解析旧搜索配置失败: %w", err)
|
||||
}
|
||||
return parsed, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (t *LoadedTool) Name() string { return ToolName }
|
||||
|
||||
func (t *LoadedTool) Enabled() bool { return t != nil && t.state != nil && t.state.Enabled() }
|
||||
|
||||
func (t *LoadedTool) ToolDefinition(description string) *model.Tool {
|
||||
if t == nil || t.state == nil {
|
||||
return nil
|
||||
}
|
||||
return t.state.ToolDefinition(description)
|
||||
}
|
||||
|
||||
func (t *LoadedTool) Execute(ctx context.Context, args string, runtime agents.Runtime) (string, error) {
|
||||
if runtime.Emit != nil {
|
||||
runtime.Emit(agents.Frame{Type: "trace", Tool: ToolName, Stage: "request", Status: "running", Message: "正在联网搜索"})
|
||||
}
|
||||
result, err := t.state.ExecuteTool(ctx, args)
|
||||
if runtime.Emit != nil {
|
||||
status := "success"
|
||||
messageText := "联网搜索完成"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
messageText = "联网搜索失败"
|
||||
}
|
||||
runtime.Emit(agents.Frame{Type: "trace", Tool: ToolName, Stage: "results", Status: status, Message: messageText})
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (t *LoadedTool) RawState() any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return t.state
|
||||
}
|
||||
|
||||
type braveSearchResponse struct {
|
||||
Web struct {
|
||||
Results []Result `json:"results"`
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
agents "aichat/agenttool"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -69,6 +71,10 @@ type State struct {
|
||||
cacheAt time.Time
|
||||
}
|
||||
|
||||
type LoadedTool struct {
|
||||
state *State
|
||||
}
|
||||
|
||||
type database struct {
|
||||
cfg DatabaseConfig
|
||||
db *sql.DB
|
||||
@@ -90,6 +96,66 @@ type ToolArgs struct {
|
||||
|
||||
type SQLGenerator func(ctx context.Context, prompt string, maxTokens int) (string, error)
|
||||
|
||||
func init() {
|
||||
agents.Register(agents.Descriptor{Name: ToolName, Load: func(path string, options agents.LoadOptions) (agents.LoadedTool, error) {
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state, err := NewState(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LoadedTool{state: state}, nil
|
||||
}})
|
||||
}
|
||||
|
||||
func (t *LoadedTool) Name() string { return ToolName }
|
||||
|
||||
func (t *LoadedTool) Enabled() bool { return t != nil && t.state != nil && t.state.Enabled() }
|
||||
|
||||
func (t *LoadedTool) ToolDefinition(description string) *model.Tool {
|
||||
if t == nil || t.state == nil {
|
||||
return nil
|
||||
}
|
||||
return t.state.ToolDefinition(description)
|
||||
}
|
||||
|
||||
func (t *LoadedTool) Execute(ctx context.Context, args string, runtime agents.Runtime) (string, error) {
|
||||
if runtime.Emit != nil {
|
||||
runtime.Emit(agents.Frame{Type: "trace", Tool: ToolName, Stage: "execute", Status: "running", Message: "正在查询数据库"})
|
||||
}
|
||||
generator := runtime.CompleteText
|
||||
if generator == nil {
|
||||
return "", errors.New("SQL 查询工具缺少文本生成器")
|
||||
}
|
||||
result, err := t.state.ExecuteTool(ctx, args, generator)
|
||||
if runtime.Emit != nil {
|
||||
status := "success"
|
||||
messageText := "数据库查询完成"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
messageText = "数据库查询失败"
|
||||
}
|
||||
runtime.Emit(agents.Frame{Type: "trace", Tool: ToolName, Stage: "execute", Status: status, Message: messageText})
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (t *LoadedTool) RawState() any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return t.state
|
||||
}
|
||||
|
||||
func (t *LoadedTool) Close() error {
|
||||
if t == nil || t.state == nil {
|
||||
return nil
|
||||
}
|
||||
return t.state.Close()
|
||||
}
|
||||
|
||||
type GenerationResult struct {
|
||||
Database string `json:"database"`
|
||||
SQL string `json:"sql"`
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package timeagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agents "aichat/agenttool"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -14,10 +20,98 @@ const (
|
||||
ActivationPrompt = "提供当前日期、时间和常用时间范围。当用户问题包含今天、今日、明天、昨天、本周、本月、本年、最近、历史上的今天、日程安排等相对时间表达时,应先调用此工具;如果后续还需要联网搜索或查数据库,可继续调用 search 或 sql。"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
ActivationPrompt string `yaml:"activation_prompt" json:"activation_prompt"`
|
||||
}
|
||||
|
||||
type ToolArgs struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type LoadedTool struct {
|
||||
cfg *Config
|
||||
}
|
||||
|
||||
func NewLoadedTool(cfg *Config) *LoadedTool {
|
||||
if cfg == nil {
|
||||
defaultCfg := defaultConfig()
|
||||
cfg = &defaultCfg
|
||||
}
|
||||
return &LoadedTool{cfg: cfg}
|
||||
}
|
||||
|
||||
func init() {
|
||||
agents.Register(agents.Descriptor{Name: ToolName, Load: func(path string, options agents.LoadOptions) (agents.LoadedTool, error) {
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewLoadedTool(cfg), nil
|
||||
}})
|
||||
}
|
||||
|
||||
func defaultConfig() Config {
|
||||
return Config{Enabled: true, ActivationPrompt: ActivationPrompt}
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("检查时间工具配置失败: %w", err)
|
||||
}
|
||||
cfg := defaultConfig()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建时间工具配置目录失败: %w", err)
|
||||
}
|
||||
data, err := yaml.Marshal(&cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成时间工具配置失败: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return nil, fmt.Errorf("写入时间工具配置失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取时间工具配置失败: %w", err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析时间工具配置失败: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(cfg.ActivationPrompt) == "" {
|
||||
cfg.ActivationPrompt = ActivationPrompt
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (t *LoadedTool) Name() string { return ToolName }
|
||||
|
||||
func (t *LoadedTool) Enabled() bool { return t != nil && t.cfg != nil && t.cfg.Enabled }
|
||||
|
||||
func (t *LoadedTool) ToolDefinition(description string) *model.Tool {
|
||||
if strings.TrimSpace(description) == "" && t != nil && t.cfg != nil {
|
||||
description = t.cfg.ActivationPrompt
|
||||
}
|
||||
return ToolDefinition(description)
|
||||
}
|
||||
|
||||
func (t *LoadedTool) Execute(ctx context.Context, args string, runtime agents.Runtime) (string, error) {
|
||||
now := runtime.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
result, err := ExecuteTool(args, now)
|
||||
if err == nil && runtime.Emit != nil {
|
||||
runtime.Emit(agents.Frame{Type: "trace", Tool: ToolName, Stage: "resolve", Status: "success", Message: "已获取当前时间上下文"})
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (t *LoadedTool) RawState() any { return nil }
|
||||
|
||||
type Range struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
const LegacySearchProfilesKey = "legacy_search_profiles"
|
||||
|
||||
type LoadOptions struct {
|
||||
Values map[string]any
|
||||
}
|
||||
|
||||
func (o LoadOptions) Value(key string) any {
|
||||
if o.Values == nil {
|
||||
return nil
|
||||
}
|
||||
return o.Values[key]
|
||||
}
|
||||
|
||||
type Frame struct {
|
||||
Type string
|
||||
Tool string
|
||||
Stage string
|
||||
Status string
|
||||
Message string
|
||||
Data map[string]any
|
||||
Error string
|
||||
Text string
|
||||
}
|
||||
|
||||
type EmitFunc func(frame any)
|
||||
|
||||
type Runtime struct {
|
||||
Profile any
|
||||
CompleteText func(context.Context, string, int) (string, error)
|
||||
Emit EmitFunc
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
type LoadedTool interface {
|
||||
Name() string
|
||||
Enabled() bool
|
||||
ToolDefinition(description string) *model.Tool
|
||||
Execute(context.Context, string, Runtime) (string, error)
|
||||
RawState() any
|
||||
}
|
||||
|
||||
type Descriptor struct {
|
||||
Name string
|
||||
Load func(path string, options LoadOptions) (LoadedTool, error)
|
||||
}
|
||||
|
||||
var (
|
||||
registryMu sync.RWMutex
|
||||
registry = map[string]Descriptor{}
|
||||
)
|
||||
|
||||
func Register(descriptor Descriptor) {
|
||||
name := strings.ToLower(strings.TrimSpace(descriptor.Name))
|
||||
if name == "" {
|
||||
panic("agenttool: tool name is empty")
|
||||
}
|
||||
if descriptor.Load == nil {
|
||||
panic(fmt.Sprintf("agenttool: %s load function is nil", name))
|
||||
}
|
||||
descriptor.Name = name
|
||||
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
if _, ok := registry[name]; ok {
|
||||
panic(fmt.Sprintf("agenttool: tool %s already registered", name))
|
||||
}
|
||||
registry[name] = descriptor
|
||||
}
|
||||
|
||||
func Lookup(name string) (Descriptor, bool) {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
descriptor, ok := registry[strings.ToLower(strings.TrimSpace(name))]
|
||||
return descriptor, ok
|
||||
}
|
||||
|
||||
func Names() []string {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(registry))
|
||||
for name := range registry {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
+6
-51
@@ -5,8 +5,6 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
searchagent "aichat/agents/search"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -17,9 +15,7 @@ const (
|
||||
defaultToolRouterTimeout = 30
|
||||
defaultToolRouterMaxTokens = 512
|
||||
defaultToolRouterSystemText = `你可以按需直接调用可用工具来回答用户问题。
|
||||
如果用户问题包含今天、今日、明天、昨天、本周、本月、本年、最近等相对时间,且后续需要搜索或查询数据库,应先调用 time 获取绝对日期范围。
|
||||
需要实时网页资料、新闻、当前版本、近期事件、网页核验或用户明确要求联网时,调用 search。
|
||||
需要查询本地业务数据、日程、会议、待办、记录、统计或时间范围内数据时,调用 sql。
|
||||
每个工具的 description 描述了它的适用场景和调用条件。
|
||||
工具结果优先于模型内置知识;工具失败时必须如实说明,不要编造结果。
|
||||
只调用确实必要的工具。`
|
||||
)
|
||||
@@ -43,7 +39,7 @@ type ToolRouterConfig struct {
|
||||
Timeout int `yaml:"timeout" json:"timeout"`
|
||||
MaxTokens int `yaml:"max_tokens" json:"max_tokens"`
|
||||
SystemPrompt string `yaml:"system_prompt" json:"system_prompt"`
|
||||
Tools []ToolRouteConfig `yaml:"tools" json:"tools"`
|
||||
Tools []ToolRouteConfig `yaml:"tools,omitempty" json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type ToolRouteConfig struct {
|
||||
@@ -104,11 +100,6 @@ func DefaultToolRouterConfig() ToolRouterConfig {
|
||||
Timeout: defaultToolRouterTimeout,
|
||||
MaxTokens: defaultToolRouterMaxTokens,
|
||||
SystemPrompt: defaultToolRouterSystemText,
|
||||
Tools: []ToolRouteConfig{
|
||||
{Name: "time", Enabled: true, Description: ""},
|
||||
{Name: "search", Enabled: true, Description: ""},
|
||||
{Name: "sql", Enabled: true, Description: ""},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +112,7 @@ func Default() Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, []searchagent.ProfileConfig, error) {
|
||||
func Load(path string) (*Config, []map[string]any, error) {
|
||||
if err := ensureFile(path); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -283,7 +274,6 @@ func NormalizeToolRouterConfig(cfg *Config) (bool, error) {
|
||||
|
||||
func normalizeToolRouterConfig(cfg *Config) (bool, error) {
|
||||
changed := false
|
||||
defaults := DefaultToolRouterConfig()
|
||||
cfg.ToolRouter.OpenAIName = strings.TrimSpace(cfg.ToolRouter.OpenAIName)
|
||||
if cfg.ToolRouter.Timeout <= 0 {
|
||||
cfg.ToolRouter.Timeout = defaultToolRouterTimeout
|
||||
@@ -301,10 +291,6 @@ func normalizeToolRouterConfig(cfg *Config) (bool, error) {
|
||||
cfg.ToolRouter.SystemPrompt = systemPrompt
|
||||
changed = true
|
||||
}
|
||||
if len(cfg.ToolRouter.Tools) == 0 {
|
||||
cfg.ToolRouter.Tools = defaults.Tools
|
||||
changed = true
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i := range cfg.ToolRouter.Tools {
|
||||
tool := &cfg.ToolRouter.Tools[i]
|
||||
@@ -322,48 +308,17 @@ func normalizeToolRouterConfig(cfg *Config) (bool, error) {
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
byName := map[string]ToolRouteConfig{}
|
||||
for _, tool := range cfg.ToolRouter.Tools {
|
||||
byName[tool.Name] = tool
|
||||
}
|
||||
merged := make([]ToolRouteConfig, 0, len(cfg.ToolRouter.Tools)+len(defaults.Tools))
|
||||
used := map[string]bool{}
|
||||
for _, tool := range defaults.Tools {
|
||||
if existing, ok := byName[tool.Name]; ok {
|
||||
merged = append(merged, existing)
|
||||
} else {
|
||||
merged = append(merged, tool)
|
||||
changed = true
|
||||
}
|
||||
used[tool.Name] = true
|
||||
}
|
||||
for _, tool := range cfg.ToolRouter.Tools {
|
||||
if !used[tool.Name] {
|
||||
merged = append(merged, tool)
|
||||
}
|
||||
}
|
||||
if len(merged) != len(cfg.ToolRouter.Tools) {
|
||||
changed = true
|
||||
} else {
|
||||
for i := range merged {
|
||||
if merged[i].Name != cfg.ToolRouter.Tools[i].Name {
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg.ToolRouter.Tools = merged
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func readLegacySearchProfiles(data []byte) []searchagent.ProfileConfig {
|
||||
func readLegacySearchProfiles(data []byte) []map[string]any {
|
||||
var legacy struct {
|
||||
Search searchagent.ProfileConfigs `yaml:"search"`
|
||||
Search []map[string]any `yaml:"search"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &legacy); err != nil {
|
||||
return nil
|
||||
}
|
||||
return []searchagent.ProfileConfig(legacy.Search)
|
||||
return legacy.Search
|
||||
}
|
||||
|
||||
func Write(path string, cfg Config) error {
|
||||
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
searchagent "aichat/agents/search"
|
||||
sqlquery "aichat/agents/sql"
|
||||
agents "aichat/agenttool"
|
||||
"aichat/config"
|
||||
"aichat/conversation"
|
||||
"aichat/llm"
|
||||
"aichat/server"
|
||||
"aichat/toolmanager"
|
||||
"aichat/toolrouter"
|
||||
)
|
||||
|
||||
@@ -27,27 +27,12 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "OpenAI 配置初始化失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
searchConfig, err := searchagent.LoadConfig("agents/search/config.yaml", legacySearchProfiles)
|
||||
toolManager, err := toolmanager.Load("agents", agents.LoadOptions{Values: map[string]any{agents.LegacySearchProfilesKey: legacySearchProfiles}})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "联网搜索配置加载失败:", err)
|
||||
fmt.Fprintln(os.Stderr, "工具加载失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
searchState, err := searchagent.NewState(searchConfig)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "联网搜索初始化失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
sqlConfig, err := sqlquery.LoadConfig("agents/sql/config.yaml")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "SQL 查询插件配置加载失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
sqlState, err := sqlquery.NewState(sqlConfig)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "SQL 查询插件初始化失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer sqlState.Close()
|
||||
defer toolManager.Close()
|
||||
toolRouterState, err := toolrouter.NewState(&cfg.ToolRouter, aiState)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "工具路由配置初始化失败:", err)
|
||||
@@ -55,7 +40,7 @@ func main() {
|
||||
}
|
||||
store := conversation.NewStore("conversations")
|
||||
|
||||
app := server.New(cfg, aiState, searchState, sqlState, toolRouterState, store)
|
||||
app := server.New(cfg, aiState, toolManager, toolRouterState, store)
|
||||
cfg.Server.Mode = strings.ToLower(cfg.Server.Mode)
|
||||
if err := app.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "服务异常退出:", err)
|
||||
|
||||
+22
-11
@@ -7,10 +7,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"aichat/agents/time"
|
||||
agents "aichat/agenttool"
|
||||
"aichat/config"
|
||||
"aichat/llm"
|
||||
"aichat/message"
|
||||
"aichat/stream"
|
||||
"aichat/toolmanager"
|
||||
"aichat/toolrouter"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
@@ -18,6 +21,13 @@ import (
|
||||
|
||||
const testOpenAIBaseURL = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
|
||||
func newTestToolManager(tools ...agents.LoadedTool) *toolmanager.Manager {
|
||||
if len(tools) == 0 {
|
||||
tools = []agents.LoadedTool{timeagent.NewLoadedTool(nil)}
|
||||
}
|
||||
return toolmanager.NewForTest(tools...)
|
||||
}
|
||||
|
||||
func newTestAI(t *testing.T, configs []config.OpenAIConfig) *llm.State {
|
||||
t.Helper()
|
||||
ai, err := llm.NewState(configs)
|
||||
@@ -46,7 +56,7 @@ func TestNormalizeToolRouterConfigDefaults(t *testing.T) {
|
||||
if strings.TrimSpace(cfg.ToolRouter.SystemPrompt) == "" {
|
||||
t.Fatal("system prompt should be defaulted")
|
||||
}
|
||||
if len(cfg.ToolRouter.Tools) != 3 || cfg.ToolRouter.Tools[0].Name != "time" || cfg.ToolRouter.Tools[1].Name != "search" || cfg.ToolRouter.Tools[2].Name != "sql" || !cfg.ToolRouter.Tools[0].Enabled || !cfg.ToolRouter.Tools[1].Enabled || !cfg.ToolRouter.Tools[2].Enabled {
|
||||
if len(cfg.ToolRouter.Tools) != 0 {
|
||||
t.Fatalf("unexpected tools: %#v", cfg.ToolRouter.Tools)
|
||||
}
|
||||
}
|
||||
@@ -66,14 +76,14 @@ func TestNormalizeOpenAIConfigDefaultsContextWindow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeToolRouterConfigAddsTimeBeforeSQL(t *testing.T) {
|
||||
func TestNormalizeToolRouterConfigKeepsConfiguredTools(t *testing.T) {
|
||||
cfg := &config.Config{ToolRouter: config.ToolRouterConfig{
|
||||
Enabled: true,
|
||||
Timeout: 1,
|
||||
MaxTokens: 1,
|
||||
SystemPrompt: "tools",
|
||||
Tools: []config.ToolRouteConfig{
|
||||
{Name: "search", Enabled: true},
|
||||
{Name: " Search ", Enabled: true},
|
||||
{Name: "sql", Enabled: true},
|
||||
},
|
||||
}}
|
||||
@@ -82,9 +92,9 @@ func TestNormalizeToolRouterConfigAddsTimeBeforeSQL(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("expected time tool to be added")
|
||||
t.Fatal("expected configured tools to be normalized")
|
||||
}
|
||||
if len(cfg.ToolRouter.Tools) < 3 || cfg.ToolRouter.Tools[0].Name != "time" || cfg.ToolRouter.Tools[2].Name != "sql" {
|
||||
if len(cfg.ToolRouter.Tools) != 2 || cfg.ToolRouter.Tools[0].Name != "search" || cfg.ToolRouter.Tools[1].Name != "sql" {
|
||||
t.Fatalf("unexpected tool order: %#v", cfg.ToolRouter.Tools)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +122,7 @@ func TestAvailableAgentToolsUsesConfigOrderAndEnabled(t *testing.T) {
|
||||
Enabled: true,
|
||||
Tools: []config.ToolRouteConfig{
|
||||
{Name: "search", Enabled: true},
|
||||
{Name: "calculator", Enabled: true, Description: "custom calculator"},
|
||||
{Name: "time", Enabled: true, Description: "custom time"},
|
||||
{Name: "sql", Enabled: false},
|
||||
},
|
||||
@@ -120,12 +131,12 @@ func TestAvailableAgentToolsUsesConfigOrderAndEnabled(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tools := toolrouter.AvailableAgentTools(router, ai.ActiveProfile(), nil, nil, nil)
|
||||
tools := toolrouter.AvailableAgentTools(router, ai.ActiveProfile(), newTestToolManager(), nil)
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("tools length = %d", len(tools))
|
||||
}
|
||||
if tools[0].Name() != "time" {
|
||||
t.Fatalf("tool name = %s", tools[0].Name())
|
||||
t.Fatalf("unexpected tools: %#v", tools)
|
||||
}
|
||||
definition := tools[0].Definition()
|
||||
if definition.Function == nil || definition.Function.Description != "custom time" {
|
||||
@@ -159,7 +170,7 @@ func TestRunAgentToolLoopAppendsToolMessages(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
messages, err := toolrouter.RunAgentToolLoop(context.Background(), router, ai.ActiveProfile(), []message.ChatMessage{{Role: "user", Content: "今天几号"}}, nil, nil, nil)
|
||||
messages, err := toolrouter.RunAgentToolLoop(context.Background(), router, ai.ActiveProfile(), []message.ChatMessage{{Role: "user", Content: "今天几号"}}, newTestToolManager(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -207,7 +218,7 @@ func TestRunAgentToolLoopMaxIterations(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
messages, err := toolrouter.RunAgentToolLoop(context.Background(), router, ai.ActiveProfile(), []message.ChatMessage{{Role: "user", Content: "今天"}}, nil, nil, nil)
|
||||
messages, err := toolrouter.RunAgentToolLoop(context.Background(), router, ai.ActiveProfile(), []message.ChatMessage{{Role: "user", Content: "今天"}}, newTestToolManager(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -343,7 +354,7 @@ func TestRunAgentToolLoopImageUsesTextOnlyDecisionMessages(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
messages, err := toolrouter.RunAgentToolLoop(context.Background(), router, ai.ActiveProfile(), []message.ChatMessage{{Role: "user", Content: "描述这张图", ImageURL: "data:image/png;base64,aGVsbG8="}}, nil, nil, nil)
|
||||
messages, err := toolrouter.RunAgentToolLoop(context.Background(), router, ai.ActiveProfile(), []message.ChatMessage{{Role: "user", Content: "描述这张图", ImageURL: "data:image/png;base64,aGVsbG8="}}, newTestToolManager(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -380,7 +391,7 @@ func TestRunAgentToolLoopUsesConfiguredRouterProfile(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = toolrouter.RunAgentToolLoop(context.Background(), router, ai.ActiveProfile(), []message.ChatMessage{{Role: "user", Content: "今天"}}, nil, nil, nil)
|
||||
_, err = toolrouter.RunAgentToolLoop(context.Background(), router, ai.ActiveProfile(), []message.ChatMessage{{Role: "user", Content: "今天"}}, newTestToolManager(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+26
-3
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
searchagent "aichat/agents/search"
|
||||
"aichat/contextwindow"
|
||||
"aichat/conversation"
|
||||
"aichat/llm"
|
||||
@@ -61,17 +62,39 @@ func (s *Server) switchOpenAIHandler(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) searchState() (*searchagent.State, bool) {
|
||||
if s == nil || s.toolManager == nil {
|
||||
return nil, false
|
||||
}
|
||||
raw, ok := s.toolManager.RawState(searchagent.ToolName)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
state, ok := raw.(*searchagent.State)
|
||||
return state, ok && state != nil
|
||||
}
|
||||
|
||||
func (s *Server) listSearchHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, s.searchState.ListProfiles())
|
||||
state, ok := s.searchState()
|
||||
if !ok {
|
||||
c.JSON(http.StatusOK, searchagent.ListResponse{})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, state.ListProfiles())
|
||||
}
|
||||
|
||||
func (s *Server) switchSearchHandler(c *gin.Context) {
|
||||
state, ok := s.searchState()
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "搜索工具未加载"})
|
||||
return
|
||||
}
|
||||
var req activeProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
profile, err := s.searchState.SwitchActive(req.Name)
|
||||
profile, err := state.SwitchActive(req.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -219,7 +242,7 @@ func (s *Server) chatHandler(c *gin.Context) {
|
||||
contextMessages = chatWindow.ChatMessages
|
||||
|
||||
// 用 Function Calling 工具循环替代旧的路由+隐藏上下文机制
|
||||
messages, err := toolrouter.RunAgentToolLoop(ctx, s.toolRouterState, profile, contextMessages, s.searchState, s.sqlState, emit)
|
||||
messages, err := toolrouter.RunAgentToolLoop(ctx, s.toolRouterState, profile, contextMessages, s.toolManager, emit)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Agent 工具循环失败:", err)
|
||||
messages, err = message.BuildArkMessages(contextMessages)
|
||||
|
||||
+4
-7
@@ -6,11 +6,10 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
searchagent "aichat/agents/search"
|
||||
sqlquery "aichat/agents/sql"
|
||||
"aichat/config"
|
||||
"aichat/conversation"
|
||||
"aichat/llm"
|
||||
"aichat/toolmanager"
|
||||
"aichat/toolrouter"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -19,19 +18,17 @@ import (
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
aiState *llm.State
|
||||
searchState *searchagent.State
|
||||
sqlState *sqlquery.State
|
||||
toolManager *toolmanager.Manager
|
||||
toolRouterState *toolrouter.State
|
||||
store *conversation.Store
|
||||
router *gin.Engine
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, aiState *llm.State, searchState *searchagent.State, sqlState *sqlquery.State, toolRouterState *toolrouter.State, store *conversation.Store) *Server {
|
||||
func New(cfg *config.Config, aiState *llm.State, toolManager *toolmanager.Manager, toolRouterState *toolrouter.State, store *conversation.Store) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
aiState: aiState,
|
||||
searchState: searchState,
|
||||
sqlState: sqlState,
|
||||
toolManager: toolManager,
|
||||
toolRouterState: toolRouterState,
|
||||
store: store,
|
||||
router: gin.Default(),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package toolmanager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
_ "aichat/agents/calculator"
|
||||
_ "aichat/agents/search"
|
||||
_ "aichat/agents/sql"
|
||||
_ "aichat/agents/time"
|
||||
agents "aichat/agenttool"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
tools map[string]agents.LoadedTool
|
||||
order []string
|
||||
}
|
||||
|
||||
func Load(root string, options agents.LoadOptions) (*Manager, error) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描工具目录失败: %w", err)
|
||||
}
|
||||
|
||||
manager := &Manager{tools: map[string]agents.LoadedTool{}}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(entry.Name()))
|
||||
descriptor, ok := agents.Lookup(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tool, err := descriptor.Load(filepath.Join(root, entry.Name(), "config.yaml"), options)
|
||||
if err != nil {
|
||||
manager.Close()
|
||||
return nil, fmt.Errorf("加载工具 %s 失败: %w", name, err)
|
||||
}
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
toolName := strings.ToLower(strings.TrimSpace(tool.Name()))
|
||||
if toolName == "" {
|
||||
toolName = name
|
||||
}
|
||||
if _, ok := manager.tools[toolName]; ok {
|
||||
manager.Close()
|
||||
return nil, fmt.Errorf("工具名称重复: %s", toolName)
|
||||
}
|
||||
manager.tools[toolName] = tool
|
||||
manager.order = append(manager.order, toolName)
|
||||
}
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func NewForTest(tools ...agents.LoadedTool) *Manager {
|
||||
manager := &Manager{tools: map[string]agents.LoadedTool{}}
|
||||
for _, tool := range tools {
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(tool.Name()))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := manager.tools[name]; !ok {
|
||||
manager.order = append(manager.order, name)
|
||||
}
|
||||
manager.tools[name] = tool
|
||||
}
|
||||
return manager
|
||||
}
|
||||
|
||||
func (m *Manager) Tools() []agents.LoadedTool {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
tools := make([]agents.LoadedTool, 0, len(m.order))
|
||||
for _, name := range m.order {
|
||||
if tool := m.tools[name]; tool != nil {
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func (m *Manager) Get(name string) (agents.LoadedTool, bool) {
|
||||
if m == nil {
|
||||
return nil, false
|
||||
}
|
||||
tool, ok := m.tools[strings.ToLower(strings.TrimSpace(name))]
|
||||
return tool, ok
|
||||
}
|
||||
|
||||
func (m *Manager) RawState(name string) (any, bool) {
|
||||
tool, ok := m.Get(name)
|
||||
if !ok || tool == nil {
|
||||
return nil, false
|
||||
}
|
||||
return tool.RawState(), true
|
||||
}
|
||||
|
||||
func (m *Manager) Close() error {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
var errs []string
|
||||
for _, tool := range m.Tools() {
|
||||
if closer, ok := tool.(interface{ Close() error }); ok {
|
||||
if err := closer.Close(); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
sort.Strings(errs)
|
||||
return errors.New(strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+3
-4
@@ -6,11 +6,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
searchagent "aichat/agents/search"
|
||||
sqlquery "aichat/agents/sql"
|
||||
"aichat/llm"
|
||||
"aichat/message"
|
||||
"aichat/stream"
|
||||
"aichat/toolmanager"
|
||||
"aichat/utils"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
@@ -18,7 +17,7 @@ import (
|
||||
|
||||
const maxAgentToolIterations = 6
|
||||
|
||||
func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, chatMessages []message.ChatMessage, searchState *searchagent.State, sqlState *sqlquery.State, emit stream.EmitFunc) ([]*model.ChatCompletionMessage, error) {
|
||||
func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, chatMessages []message.ChatMessage, manager *toolmanager.Manager, emit stream.EmitFunc) ([]*model.ChatCompletionMessage, error) {
|
||||
finalMessages, err := message.BuildArkMessages(chatMessages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -27,7 +26,7 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, c
|
||||
if state != nil {
|
||||
routerProfile = state.RouterProfile(profile)
|
||||
}
|
||||
tools := availableAgentTools(state, routerProfile, searchState, sqlState, emit)
|
||||
tools := availableAgentTools(state, routerProfile, manager, emit)
|
||||
if len(tools) == 0 {
|
||||
return finalMessages, nil
|
||||
}
|
||||
|
||||
+81
-74
@@ -6,13 +6,13 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
searchagent "aichat/agents/search"
|
||||
sqlquery "aichat/agents/sql"
|
||||
timeagent "aichat/agents/time"
|
||||
agents "aichat/agenttool"
|
||||
"aichat/completion"
|
||||
"aichat/config"
|
||||
"aichat/llm"
|
||||
"aichat/message"
|
||||
"aichat/stream"
|
||||
"aichat/toolmanager"
|
||||
"aichat/utils"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
@@ -32,89 +32,96 @@ func (t AgentTool) Name() string { return t.name }
|
||||
|
||||
func (t AgentTool) Definition() *model.Tool { return t.definition }
|
||||
|
||||
func AvailableAgentTools(state *State, profile *llm.Profile, searchState *searchagent.State, sqlState *sqlquery.State, emit stream.EmitFunc) []AgentTool {
|
||||
return availableAgentTools(state, profile, searchState, sqlState, emit)
|
||||
func AvailableAgentTools(state *State, profile *llm.Profile, manager *toolmanager.Manager, emit stream.EmitFunc) []AgentTool {
|
||||
return availableAgentTools(state, profile, manager, emit)
|
||||
}
|
||||
|
||||
func availableAgentTools(state *State, profile *llm.Profile, searchState *searchagent.State, sqlState *sqlquery.State, emit stream.EmitFunc) []AgentTool {
|
||||
if state == nil || state.cfg == nil || !state.cfg.Enabled {
|
||||
func availableAgentTools(state *State, profile *llm.Profile, manager *toolmanager.Manager, emit stream.EmitFunc) []AgentTool {
|
||||
if state == nil || state.cfg == nil || !state.cfg.Enabled || manager == nil {
|
||||
return nil
|
||||
}
|
||||
tools := make([]AgentTool, 0, len(state.cfg.Tools))
|
||||
overrides := map[string]config.ToolRouteConfig{}
|
||||
for _, item := range state.cfg.Tools {
|
||||
if !item.Enabled {
|
||||
name := strings.ToLower(strings.TrimSpace(item.Name))
|
||||
if name != "" {
|
||||
overrides[name] = item
|
||||
}
|
||||
}
|
||||
|
||||
if len(state.cfg.Tools) > 0 {
|
||||
tools := make([]AgentTool, 0, len(state.cfg.Tools))
|
||||
for _, item := range state.cfg.Tools {
|
||||
name := strings.ToLower(strings.TrimSpace(item.Name))
|
||||
if name == "" || !item.Enabled {
|
||||
continue
|
||||
}
|
||||
tool, ok := manager.Get(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if agentTool, ok := buildAgentTool(tool, strings.TrimSpace(item.Description), profile, emit); ok {
|
||||
tools = append(tools, agentTool)
|
||||
}
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
loaded := manager.Tools()
|
||||
tools := make([]AgentTool, 0, len(loaded))
|
||||
for _, tool := range loaded {
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
description := strings.TrimSpace(item.Description)
|
||||
switch item.Name {
|
||||
case timeagent.ToolName:
|
||||
tools = append(tools, AgentTool{
|
||||
name: timeagent.ToolName,
|
||||
definition: timeagent.ToolDefinition(description),
|
||||
execute: func(ctx context.Context, args string) (string, error) {
|
||||
result, err := timeagent.ExecuteTool(args, time.Now())
|
||||
if err == nil && emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: timeagent.ToolName, Stage: "resolve", Status: "success", Message: "已获取当前时间上下文"})
|
||||
}
|
||||
return result, err
|
||||
},
|
||||
})
|
||||
case searchagent.ToolName:
|
||||
if searchState == nil || !searchState.Enabled() {
|
||||
continue
|
||||
}
|
||||
tools = append(tools, AgentTool{
|
||||
name: searchagent.ToolName,
|
||||
definition: searchState.ToolDefinition(description),
|
||||
execute: func(ctx context.Context, args string) (string, error) {
|
||||
if emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: searchagent.ToolName, Stage: "request", Status: "running", Message: "正在联网搜索"})
|
||||
}
|
||||
result, err := searchState.ExecuteTool(ctx, args)
|
||||
if emit != nil {
|
||||
status := "success"
|
||||
messageText := "联网搜索完成"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
messageText = "联网搜索失败"
|
||||
}
|
||||
emit(stream.Frame{Type: "trace", Tool: searchagent.ToolName, Stage: "results", Status: status, Message: messageText})
|
||||
}
|
||||
return result, err
|
||||
},
|
||||
})
|
||||
case sqlquery.ToolName:
|
||||
if sqlState == nil || !sqlState.Enabled() {
|
||||
continue
|
||||
}
|
||||
tools = append(tools, AgentTool{
|
||||
name: sqlquery.ToolName,
|
||||
definition: sqlState.ToolDefinition(description),
|
||||
execute: func(ctx context.Context, args string) (string, error) {
|
||||
if emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: sqlquery.ToolName, Stage: "execute", Status: "running", Message: "正在查询数据库"})
|
||||
}
|
||||
generator := func(ctx context.Context, prompt string, maxTokens int) (string, error) {
|
||||
return completion.CompleteText(ctx, profile, []message.ChatMessage{{Role: "system", Content: prompt}}, maxTokens)
|
||||
}
|
||||
result, err := sqlState.ExecuteTool(ctx, args, generator)
|
||||
if emit != nil {
|
||||
status := "success"
|
||||
messageText := "数据库查询完成"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
messageText = "数据库查询失败"
|
||||
}
|
||||
emit(stream.Frame{Type: "trace", Tool: sqlquery.ToolName, Stage: "execute", Status: status, Message: messageText})
|
||||
}
|
||||
return result, err
|
||||
},
|
||||
})
|
||||
item := overrides[tool.Name()]
|
||||
if agentTool, ok := buildAgentTool(tool, strings.TrimSpace(item.Description), profile, emit); ok {
|
||||
tools = append(tools, agentTool)
|
||||
}
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func buildAgentTool(tool agents.LoadedTool, description string, profile *llm.Profile, emit stream.EmitFunc) (AgentTool, bool) {
|
||||
if tool == nil || !tool.Enabled() {
|
||||
return AgentTool{}, false
|
||||
}
|
||||
definition := tool.ToolDefinition(description)
|
||||
if definition == nil || definition.Function == nil {
|
||||
return AgentTool{}, false
|
||||
}
|
||||
name := tool.Name()
|
||||
return AgentTool{
|
||||
name: name,
|
||||
definition: definition,
|
||||
execute: func(ctx context.Context, args string) (string, error) {
|
||||
runtime := agents.Runtime{
|
||||
Profile: profile,
|
||||
Now: time.Now(),
|
||||
Emit: wrapAgentEmit(emit),
|
||||
}
|
||||
if profile != nil {
|
||||
runtime.CompleteText = func(ctx context.Context, prompt string, maxTokens int) (string, error) {
|
||||
return completion.CompleteText(ctx, profile, []message.ChatMessage{{Role: "system", Content: prompt}}, maxTokens)
|
||||
}
|
||||
}
|
||||
return tool.Execute(ctx, args, runtime)
|
||||
},
|
||||
}, true
|
||||
}
|
||||
|
||||
func wrapAgentEmit(emit stream.EmitFunc) agents.EmitFunc {
|
||||
if emit == nil {
|
||||
return nil
|
||||
}
|
||||
return func(frame any) {
|
||||
switch value := frame.(type) {
|
||||
case agents.Frame:
|
||||
emit(stream.Frame{Type: value.Type, Tool: value.Tool, Stage: value.Stage, Status: value.Status, Message: value.Message, Data: value.Data, Error: value.Error, Text: value.Text})
|
||||
case stream.Frame:
|
||||
emit(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ExecuteAgentToolCall(ctx context.Context, call *model.ToolCall, tools map[string]AgentTool, emit stream.EmitFunc) string {
|
||||
if call == nil || call.Type != model.ToolTypeFunction {
|
||||
result := "工具调用无效:仅支持 function 类型工具。"
|
||||
|
||||
Reference in New Issue
Block a user