feat: Round 5 - Memory Service, Tool Engine, Call Records, Thinking Logs

- Fix: Session history flash (race condition + WS guard)
- Fix: Chat background overlay + sidebar transparency
- Fix: IoT device control (Chinese action names, status field)
- Feat: Independent memory-service (port 8091, 13 endpoints)
- Feat: Independent tool-engine service (port 8092, 13 tools)
- Feat: Tool call logs with paginated DevTools panel
- Feat: Thinking log records with DevTools panel
- Feat: Future development roadmap document
- Chore: Updated .gitignore, go.work, DevTools config
- Chore: 5-service health check, project review docs
This commit is contained in:
2026-05-18 20:05:14 +08:00
parent b6ec36886c
commit 78e3f450c2
54 changed files with 7846 additions and 106 deletions
@@ -0,0 +1,342 @@
package tools
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"unicode"
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
)
// CalculatorTool performs safe mathematical expression evaluation.
type CalculatorTool struct{}
// NewCalculatorTool creates a calculator tool.
func NewCalculatorTool() *CalculatorTool {
return &CalculatorTool{}
}
// Definition returns the tool definition for LLM function calling.
func (t *CalculatorTool) Definition() model.ToolDefinition {
return model.ToolDefinition{
Name: "calculator",
Description: "执行数学计算。用于精确计算数学表达式,支持四则运算、三角函数、对数、幂运算等。适用于LLM不擅长的复杂计算场景。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"expression": map[string]interface{}{
"type": "string",
"description": "数学表达式,如 \"2 + 3 * 4\"、\"sqrt(16) + sin(pi/2)\"。支持运算符: + - * / % ^。支持函数: sqrt, sin, cos, tan, abs, floor, ceil, round, log, ln, pow。支持常量: pi, e。",
},
},
"required": []string{"expression"},
},
}
}
// Execute evaluates a mathematical expression.
func (t *CalculatorTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
expression, ok := arguments["expression"].(string)
if !ok || strings.TrimSpace(expression) == "" {
return &model.ToolResult{
ID: "",
Error: "缺少 expression 参数",
}, nil
}
result, err := evaluate(expression)
if err != nil {
return &model.ToolResult{
ID: "",
Error: fmt.Sprintf("计算错误: %v", err),
}, nil
}
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("表达式: %s\n结果: %s", expression, formatResult(result)),
}, nil
}
func formatResult(v float64) string {
if v == math.Trunc(v) && math.Abs(v) < 1e15 {
return strconv.FormatInt(int64(v), 10)
}
return strconv.FormatFloat(v, 'g', -1, 64)
}
type tokenKind int
const (
tokNumber tokenKind = iota
tokIdent
tokOp
tokLParen
tokRParen
tokComma
tokEOF
)
type token struct {
kind tokenKind
value string
}
type lexer struct {
input []rune
pos int
}
func newLexer(s string) *lexer {
return &lexer{input: []rune(s), pos: 0}
}
func (l *lexer) next() token {
l.skipWhitespace()
if l.pos >= len(l.input) {
return token{kind: tokEOF}
}
ch := l.input[l.pos]
if unicode.IsDigit(ch) || ch == '.' {
start := l.pos
hasDot := ch == '.'
l.pos++
for l.pos < len(l.input) && (unicode.IsDigit(l.input[l.pos]) || l.input[l.pos] == '.') {
if l.input[l.pos] == '.' {
if hasDot {
break
}
hasDot = true
}
l.pos++
}
return token{kind: tokNumber, value: string(l.input[start:l.pos])}
}
if unicode.IsLetter(ch) || ch == '_' {
start := l.pos
l.pos++
for l.pos < len(l.input) && (unicode.IsLetter(l.input[l.pos]) || unicode.IsDigit(l.input[l.pos]) || l.input[l.pos] == '_') {
l.pos++
}
return token{kind: tokIdent, value: string(l.input[start:l.pos])}
}
switch ch {
case '+', '-', '*', '/', '%', '^':
l.pos++
return token{kind: tokOp, value: string(ch)}
case '(':
l.pos++
return token{kind: tokLParen}
case ')':
l.pos++
return token{kind: tokRParen}
case ',':
l.pos++
return token{kind: tokComma}
}
return token{kind: tokEOF}
}
func (l *lexer) skipWhitespace() {
for l.pos < len(l.input) && unicode.IsSpace(l.input[l.pos]) {
l.pos++
}
}
type parser struct {
lex *lexer
cur token
peek token
}
func newParser(lex *lexer) *parser {
p := &parser{lex: lex}
p.cur = lex.next()
p.peek = lex.next()
return p
}
func (p *parser) advance() {
p.cur = p.peek
p.peek = p.lex.next()
}
func evaluate(expr string) (float64, error) {
lex := newLexer(expr)
par := newParser(lex)
result, err := par.parseExpression()
if err != nil {
return 0, err
}
if par.cur.kind != tokEOF {
return 0, fmt.Errorf("表达式末尾存在意外字符")
}
return result, nil
}
func (p *parser) parseExpression() (float64, error) {
left, err := p.parseTerm()
if err != nil {
return 0, err
}
for p.cur.kind == tokOp && (p.cur.value == "+" || p.cur.value == "-") {
op := p.cur.value
p.advance()
right, err := p.parseTerm()
if err != nil {
return 0, err
}
if op == "+" {
left += right
} else {
left -= right
}
}
return left, nil
}
func (p *parser) parseTerm() (float64, error) {
left, err := p.parseUnary()
if err != nil {
return 0, err
}
for p.cur.kind == tokOp && (p.cur.value == "*" || p.cur.value == "/" || p.cur.value == "%" || p.cur.value == "^") {
op := p.cur.value
p.advance()
right, err := p.parseUnary()
if err != nil {
return 0, err
}
switch op {
case "*":
left *= right
case "/":
if right == 0 {
return 0, fmt.Errorf("除数不能为零")
}
left /= right
case "%":
left = math.Mod(left, right)
case "^":
left = math.Pow(left, right)
}
}
return left, nil
}
func (p *parser) parseUnary() (float64, error) {
if p.cur.kind == tokOp && p.cur.value == "-" {
p.advance()
val, err := p.parseUnary()
if err != nil {
return 0, err
}
return -val, nil
}
if p.cur.kind == tokOp && p.cur.value == "+" {
p.advance()
return p.parseUnary()
}
return p.parseAtom()
}
func (p *parser) parseAtom() (float64, error) {
switch p.cur.kind {
case tokNumber:
val, err := strconv.ParseFloat(p.cur.value, 64)
if err != nil {
return 0, fmt.Errorf("无效数字: %s", p.cur.value)
}
p.advance()
return val, nil
case tokIdent:
name := strings.ToLower(p.cur.value)
p.advance()
switch name {
case "pi":
return math.Pi, nil
case "e":
return math.E, nil
}
if p.cur.kind != tokLParen {
return 0, fmt.Errorf("未知标识符: %s (如果是函数需要加括号)", name)
}
p.advance()
arg, err := p.parseExpression()
if err != nil {
return 0, err
}
if p.cur.kind != tokRParen {
return 0, fmt.Errorf("函数 %s 缺少右括号", name)
}
p.advance()
return applyFunc(name, arg)
case tokLParen:
p.advance()
val, err := p.parseExpression()
if err != nil {
return 0, err
}
if p.cur.kind != tokRParen {
return 0, fmt.Errorf("缺少右括号")
}
p.advance()
return val, nil
default:
return 0, fmt.Errorf("意外的 token: %v", p.cur.value)
}
}
func applyFunc(name string, arg float64) (float64, error) {
switch name {
case "sqrt":
if arg < 0 {
return 0, fmt.Errorf("sqrt 参数不能为负数")
}
return math.Sqrt(arg), nil
case "sin":
return math.Sin(arg), nil
case "cos":
return math.Cos(arg), nil
case "tan":
return math.Tan(arg), nil
case "abs":
return math.Abs(arg), nil
case "floor":
return math.Floor(arg), nil
case "ceil":
return math.Ceil(arg), nil
case "round":
return math.Round(arg), nil
case "log":
if arg <= 0 {
return 0, fmt.Errorf("log 参数必须大于0")
}
return math.Log10(arg), nil
case "ln":
if arg <= 0 {
return 0, fmt.Errorf("ln 参数必须大于0")
}
return math.Log(arg), nil
case "pow":
return 0, fmt.Errorf("pow 需要两个参数,请使用 ^ 运算符代替")
default:
return 0, fmt.Errorf("未知函数: %s", name)
}
}
@@ -0,0 +1,186 @@
package tools
import (
"context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"fmt"
"hash"
"net/url"
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
)
// CryptoTool provides cryptographic and encoding utilities for the LLM.
type CryptoTool struct{}
// NewCryptoTool creates a crypto/encoding tool.
func NewCryptoTool() *CryptoTool {
return &CryptoTool{}
}
// Definition returns the tool definition for LLM function calling.
func (t *CryptoTool) Definition() model.ToolDefinition {
return model.ToolDefinition{
Name: "crypto",
Description: "加密哈希与编码工具。计算MD5/SHA哈希值,执行Base64编码/解码,URL编码/解码。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"enum": []string{"hash", "base64_encode", "base64_decode", "url_encode", "url_decode"},
"description": "操作类型。hash: 计算哈希值;base64_encode: Base64编码;base64_decode: Base64解码;url_encode: URL编码;url_decode: URL解码",
},
"input": map[string]interface{}{
"type": "string",
"description": "输入数据,需要处理的字符串",
},
"algorithm": map[string]interface{}{
"type": "string",
"enum": []string{"md5", "sha1", "sha256", "sha512"},
"description": "哈希算法(用于 hash 操作),默认 sha256",
},
},
"required": []string{"action", "input"},
},
}
}
// Execute performs crypto/encoding operations.
func (t *CryptoTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
action, ok := arguments["action"].(string)
if !ok || action == "" {
return &model.ToolResult{ID: "", Error: "缺少 action 参数"}, nil
}
input, ok := arguments["input"].(string)
if !ok {
return &model.ToolResult{ID: "", Error: "缺少 input 参数"}, nil
}
switch action {
case "hash":
return t.handleHash(arguments)
case "base64_encode":
return t.handleBase64Encode(input)
case "base64_decode":
return t.handleBase64Decode(input)
case "url_encode":
return t.handleURLEncode(input)
case "url_decode":
return t.handleURLDecode(input)
default:
return &model.ToolResult{
ID: "",
Error: fmt.Sprintf("未知操作: %s,支持: hash, base64_encode, base64_decode, url_encode, url_decode", action),
}, nil
}
}
func (t *CryptoTool) handleHash(arguments map[string]interface{}) (*model.ToolResult, error) {
input, _ := arguments["input"].(string)
algorithm, _ := arguments["algorithm"].(string)
if algorithm == "" {
algorithm = "sha256"
}
var h hash.Hash
switch algorithm {
case "md5":
h = md5.New()
case "sha1":
h = sha1.New()
case "sha256":
h = sha256.New()
case "sha512":
h = sha512.New()
default:
return &model.ToolResult{
ID: "",
Error: fmt.Sprintf("不支持的哈希算法: %s,支持: md5, sha1, sha256, sha512", algorithm),
}, nil
}
h.Write([]byte(input))
hashBytes := h.Sum(nil)
hashHex := fmt.Sprintf("%x", hashBytes)
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("哈希算法: %s\n输入长度: %d 字节\n哈希值 (hex): %s\n哈希长度: %d 位",
algorithm, len(input), hashHex, len(hashBytes)*8),
}, nil
}
func (t *CryptoTool) handleBase64Encode(input string) (*model.ToolResult, error) {
encoded := base64.StdEncoding.EncodeToString([]byte(input))
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("Base64 编码结果:\n原始 (%d 字节): %s\n编码 (%d 字符): %s",
len(input), truncate(input, 100), len(encoded), encoded),
}, nil
}
func (t *CryptoTool) handleBase64Decode(input string) (*model.ToolResult, error) {
decoded, err := base64.StdEncoding.DecodeString(input)
if err != nil {
decoded, err = base64.RawStdEncoding.DecodeString(input)
if err != nil {
decoded, err = base64.URLEncoding.DecodeString(input)
if err != nil {
decoded, err = base64.RawURLEncoding.DecodeString(input)
if err != nil {
return &model.ToolResult{
ID: "",
Error: "Base64 解码失败: 输入不是有效的 Base64 字符串",
}, nil
}
}
}
}
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("Base64 解码结果:\n原始 (%d 字符): %s\n解码 (%d 字节): %s",
len(input), truncate(input, 100), len(decoded), truncate(string(decoded), 200)),
}, nil
}
func (t *CryptoTool) handleURLEncode(input string) (*model.ToolResult, error) {
encoded := url.QueryEscape(input)
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("URL 编码结果:\n原始 (%d 字节): %s\n编码 (%d 字节): %s",
len(input), truncate(input, 100), len(encoded), encoded),
}, nil
}
func (t *CryptoTool) handleURLDecode(input string) (*model.ToolResult, error) {
decoded, err := url.QueryUnescape(input)
if err != nil {
return &model.ToolResult{
ID: "",
Error: fmt.Sprintf("URL 解码失败: %v", err),
}, nil
}
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("URL 解码结果:\n原始 (%d 字节): %s\n解码 (%d 字节): %s",
len(input), truncate(input, 100), len(decoded), truncate(decoded, 200)),
}, nil
}
func truncate(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "..."
}
@@ -0,0 +1,360 @@
package tools
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"unicode"
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
)
// DateTimeTool provides date/time operations for the LLM.
type DateTimeTool struct{}
// NewDateTimeTool creates a date/time tool.
func NewDateTimeTool() *DateTimeTool {
return &DateTimeTool{}
}
// Definition returns the tool definition for LLM function calling.
func (t *DateTimeTool) Definition() model.ToolDefinition {
return model.ToolDefinition{
Name: "datetime",
Description: "日期时间工具。获取当前时间、格式化日期、日期加减、计算日期差、查看可用时区。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"enum": []string{"now", "format", "add", "diff", "timezone_list"},
"description": "操作类型。now: 获取当前时间;format: 格式化日期;add: 日期加减;diff: 计算两个日期的差值;timezone_list: 列出常用时区",
},
"format": map[string]interface{}{
"type": "string",
"description": "日期格式串(Go风格)。默认 \"2006-01-02 15:04:05\"。常用: \"2006-01-02\"(仅日期)、\"15:04:05\"(仅时间)",
},
"timezone": map[string]interface{}{
"type": "string",
"description": "时区标识,如 \"Asia/Shanghai\"、\"America/New_York\"、\"UTC\"。默认使用服务器本地时区",
},
"date": map[string]interface{}{
"type": "string",
"description": "基准日期,格式为 \"2006-01-02 15:04:05\" 或 \"2006-01-02\"",
},
"duration": map[string]interface{}{
"type": "string",
"description": "时长字符串,如 \"24h\"、\"7d\"、\"30m\"、\"1h30m\"。支持单位: s(秒), m(分钟), h(小时), d(天), w(周), M(月), y(年)",
},
"date2": map[string]interface{}{
"type": "string",
"description": "第二个日期(用于 diff 操作),格式同 date",
},
},
"required": []string{"action"},
},
}
}
// Execute performs date/time operations.
func (t *DateTimeTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
action, ok := arguments["action"].(string)
if !ok || action == "" {
return &model.ToolResult{
ID: "",
Error: "缺少 action 参数",
}, nil
}
switch action {
case "now":
return t.handleNow(arguments)
case "format":
return t.handleFormat(arguments)
case "add":
return t.handleAdd(arguments)
case "diff":
return t.handleDiff(arguments)
case "timezone_list":
return t.handleTimezoneList()
default:
return &model.ToolResult{
ID: "",
Error: fmt.Sprintf("未知操作: %s,支持: now, format, add, diff, timezone_list", action),
}, nil
}
}
func (t *DateTimeTool) handleNow(arguments map[string]interface{}) (*model.ToolResult, error) {
tz, err := t.getTimezone(arguments)
if err != nil {
return &model.ToolResult{ID: "", Error: err.Error()}, nil
}
format := t.getFormat(arguments)
now := time.Now().In(tz)
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("当前时间: %s\n时区: %s\nUnix时间戳: %d",
now.Format(format), tz.String(), now.Unix()),
}, nil
}
func (t *DateTimeTool) handleFormat(arguments map[string]interface{}) (*model.ToolResult, error) {
dateStr, _ := arguments["date"].(string)
if dateStr == "" {
return &model.ToolResult{ID: "", Error: "format 操作需要 date 参数"}, nil
}
parsed, err := t.parseDate(dateStr)
if err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("日期解析失败: %v", err)}, nil
}
tz, err := t.getTimezone(arguments)
if err != nil {
return &model.ToolResult{ID: "", Error: err.Error()}, nil
}
format := t.getFormat(arguments)
formatted := parsed.In(tz).Format(format)
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("原始: %s\n格式化: %s\n时区: %s", dateStr, formatted, tz.String()),
}, nil
}
func (t *DateTimeTool) handleAdd(arguments map[string]interface{}) (*model.ToolResult, error) {
durationStr, _ := arguments["duration"].(string)
if durationStr == "" {
return &model.ToolResult{ID: "", Error: "add 操作需要 duration 参数"}, nil
}
dateStr, _ := arguments["date"].(string)
var base time.Time
if dateStr != "" {
var err error
base, err = t.parseDate(dateStr)
if err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("日期解析失败: %v", err)}, nil
}
} else {
tz, _ := t.getTimezone(arguments)
base = time.Now().In(tz)
}
dur, err := t.parseDuration(durationStr)
if err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("时长解析失败: %v", err)}, nil
}
tz, _ := t.getTimezone(arguments)
result := base.In(tz)
months := extractDurationUnit(durationStr, 'M')
years := extractDurationUnit(durationStr, 'y')
if months != 0 || years != 0 {
result = result.AddDate(years, months, 0)
}
if dur != 0 {
result = result.Add(dur)
}
format := t.getFormat(arguments)
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("基准日期: %s\n操作: %s\n结果: %s",
base.In(tz).Format(format), durationStr, result.Format(format)),
}, nil
}
func (t *DateTimeTool) handleDiff(arguments map[string]interface{}) (*model.ToolResult, error) {
dateStr, _ := arguments["date"].(string)
date2Str, _ := arguments["date2"].(string)
if dateStr == "" || date2Str == "" {
return &model.ToolResult{ID: "", Error: "diff 操作需要 date 和 date2 参数"}, nil
}
d1, err := t.parseDate(dateStr)
if err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("date 解析失败: %v", err)}, nil
}
d2, err := t.parseDate(date2Str)
if err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("date2 解析失败: %v", err)}, nil
}
diff := d2.Sub(d1)
absDiff := diff
if absDiff < 0 {
absDiff = -absDiff
}
days := int(absDiff.Hours() / 24)
hours := int(absDiff.Hours()) % 24
minutes := int(absDiff.Minutes()) % 60
seconds := int(absDiff.Seconds()) % 60
sign := ""
if diff < 0 {
sign = "-"
}
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("日期1: %s\n日期2: %s\n差值: %s%d天 %d小时 %d分钟 %d秒 (总计 %s%.0f秒)",
dateStr, date2Str, sign, days, hours, minutes, seconds, sign, absDiff.Seconds()),
}, nil
}
func (t *DateTimeTool) handleTimezoneList() (*model.ToolResult, error) {
zones := []string{
"UTC",
"Asia/Shanghai (北京时间)",
"Asia/Tokyo (东京时间)",
"Asia/Seoul (首尔时间)",
"Asia/Singapore (新加坡时间)",
"Asia/Kolkata (印度时间)",
"Asia/Dubai (迪拜时间)",
"Europe/London (伦敦时间)",
"Europe/Paris (巴黎时间)",
"Europe/Berlin (柏林时间)",
"Europe/Moscow (莫斯科时间)",
"America/New_York (纽约时间)",
"America/Chicago (芝加哥时间)",
"America/Denver (丹佛时间)",
"America/Los_Angeles (洛杉矶时间)",
"America/Sao_Paulo (圣保罗时间)",
"Australia/Sydney (悉尼时间)",
"Pacific/Auckland (奥克兰时间)",
}
var result strings.Builder
result.WriteString("常用时区列表:\n\n")
for i, z := range zones {
result.WriteString(fmt.Sprintf(" %2d. %s\n", i+1, z))
}
return &model.ToolResult{
ID: "",
Output: result.String(),
}, nil
}
func (t *DateTimeTool) getTimezone(arguments map[string]interface{}) (*time.Location, error) {
tzName, _ := arguments["timezone"].(string)
if tzName == "" {
return time.Local, nil
}
loc, err := time.LoadLocation(tzName)
if err != nil {
return nil, fmt.Errorf("无效时区: %s", tzName)
}
return loc, nil
}
func (t *DateTimeTool) getFormat(arguments map[string]interface{}) string {
format, _ := arguments["format"].(string)
if format == "" {
return "2006-01-02 15:04:05"
}
return format
}
func (t *DateTimeTool) parseDate(s string) (time.Time, error) {
formats := []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05Z",
"2006-01-02T15:04:05",
"2006-01-02",
"2006/01/02 15:04:05",
"2006/01/02",
time.RFC3339,
time.RFC3339Nano,
}
for _, f := range formats {
if t, err := time.Parse(f, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("无法解析日期: %s", s)
}
func (t *DateTimeTool) parseDuration(s string) (time.Duration, error) {
if d, err := time.ParseDuration(s); err == nil {
return d, nil
}
var total time.Duration
remaining := s
for len(remaining) > 0 {
numStart := 0
for numStart < len(remaining) && !unicode.IsDigit(rune(remaining[numStart])) && remaining[numStart] != '-' {
numStart++
}
if numStart >= len(remaining) {
break
}
numEnd := numStart
for numEnd < len(remaining) && (unicode.IsDigit(rune(remaining[numEnd])) || remaining[numEnd] == '.') {
numEnd++
}
val, err := strconv.ParseFloat(remaining[numStart:numEnd], 64)
if err != nil {
return 0, fmt.Errorf("无效时长数字: %s", remaining[numStart:numEnd])
}
unitEnd := numEnd
for unitEnd < len(remaining) && unicode.IsLetter(rune(remaining[unitEnd])) {
unitEnd++
}
unit := remaining[numEnd:unitEnd]
switch unit {
case "s":
total += time.Duration(val * float64(time.Second))
case "m":
total += time.Duration(val * float64(time.Minute))
case "h":
total += time.Duration(val * float64(time.Hour))
case "d":
total += time.Duration(val * 24 * float64(time.Hour))
case "w":
total += time.Duration(val * 7 * 24 * float64(time.Hour))
}
remaining = remaining[unitEnd:]
}
return total, nil
}
func extractDurationUnit(s string, unit byte) int {
for i := 0; i < len(s); i++ {
if s[i] == unit {
j := i - 1
for j >= 0 && (unicode.IsDigit(rune(s[j])) || s[j] == '.') {
j--
}
numStr := s[j+1 : i]
val, err := strconv.Atoi(numStr)
if err != nil {
return 0
}
return val
}
}
return 0
}
@@ -0,0 +1,234 @@
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
)
// FileTool provides sandboxed file system operations for the LLM.
type FileTool struct {
dataDir string
}
// NewFileTool creates a file operation tool with the given data directory.
func NewFileTool(dataDir string) *FileTool {
if dataDir == "" {
dataDir = "/tmp/cyrene_data"
}
return &FileTool{dataDir: dataDir}
}
// Definition returns the tool definition for LLM function calling.
func (t *FileTool) Definition() model.ToolDefinition {
return model.ToolDefinition{
Name: "file_ops",
Description: "文件操作工具。在服务端安全沙盒内读写文件、列出目录、检查文件是否存在、删除文件。所有操作限制在数据目录内,无法访问系统文件。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"enum": []string{"read", "write", "list", "exists", "delete"},
"description": "操作类型。read: 读取文件;write: 写入文件(覆盖或创建);list: 列出目录内容;exists: 检查路径是否存在;delete: 删除文件",
},
"path": map[string]interface{}{
"type": "string",
"description": "文件或目录路径(相对于数据目录),如 \"notes/todo.txt\"",
},
"content": map[string]interface{}{
"type": "string",
"description": "写入内容(write 操作时必需)",
},
},
"required": []string{"action", "path"},
},
}
}
// Execute performs file operations.
func (t *FileTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
action, ok := arguments["action"].(string)
if !ok || action == "" {
return &model.ToolResult{ID: "", Error: "缺少 action 参数"}, nil
}
relPath, ok := arguments["path"].(string)
if !ok || relPath == "" {
return &model.ToolResult{ID: "", Error: "缺少 path 参数"}, nil
}
safePath, err := t.resolveSafePath(relPath)
if err != nil {
return &model.ToolResult{ID: "", Error: err.Error()}, nil
}
switch action {
case "read":
return t.handleRead(safePath, relPath)
case "write":
content, _ := arguments["content"].(string)
return t.handleWrite(safePath, relPath, content)
case "list":
return t.handleList(safePath, relPath)
case "exists":
return t.handleExists(safePath, relPath)
case "delete":
return t.handleDelete(safePath, relPath)
default:
return &model.ToolResult{
ID: "",
Error: fmt.Sprintf("未知操作: %s,支持: read, write, list, exists, delete", action),
}, nil
}
}
func (t *FileTool) resolveSafePath(relPath string) (string, error) {
clean := filepath.Clean(relPath)
if err := os.MkdirAll(t.dataDir, 0755); err != nil {
return "", fmt.Errorf("创建数据目录失败: %v", err)
}
abs := filepath.Join(t.dataDir, clean)
realPath, err := filepath.EvalSymlinks(abs)
if err != nil {
if os.IsNotExist(err) {
if !strings.HasPrefix(filepath.Clean(abs), filepath.Clean(t.dataDir)+string(filepath.Separator)) &&
filepath.Clean(abs) != filepath.Clean(t.dataDir) {
return "", fmt.Errorf("路径穿越检测: %s 不在允许的数据目录内", relPath)
}
return abs, nil
}
return "", fmt.Errorf("路径解析失败: %v", err)
}
if !strings.HasPrefix(realPath, filepath.Clean(t.dataDir)+string(filepath.Separator)) &&
realPath != filepath.Clean(t.dataDir) {
return "", fmt.Errorf("路径穿越检测: %s 不在允许的数据目录内", relPath)
}
return realPath, nil
}
func (t *FileTool) handleRead(absPath, relPath string) (*model.ToolResult, error) {
const maxSize = 100 * 1024
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("文件不存在: %s", relPath)}, nil
}
return &model.ToolResult{ID: "", Error: fmt.Sprintf("读取文件失败: %v", err)}, nil
}
if info.IsDir() {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("路径是目录,不能用 read 操作: %s", relPath)}, nil
}
if info.Size() > maxSize {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("文件过大 (%d bytes),超过限制 (%d bytes)", info.Size(), maxSize)}, nil
}
data, err := os.ReadFile(absPath)
if err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("读取文件失败: %v", err)}, nil
}
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("文件: %s\n大小: %d bytes\n---\n%s", relPath, len(data), string(data)),
}, nil
}
func (t *FileTool) handleWrite(absPath, relPath, content string) (*model.ToolResult, error) {
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("创建目录失败: %v", err)}, nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("写入文件失败: %v", err)}, nil
}
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("已写入文件: %s (%d bytes)", relPath, len(content)),
}, nil
}
func (t *FileTool) handleList(absPath, relPath string) (*model.ToolResult, error) {
entries, err := os.ReadDir(absPath)
if err != nil {
if os.IsNotExist(err) {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("目录不存在: %s", relPath)}, nil
}
return &model.ToolResult{ID: "", Error: fmt.Sprintf("读取目录失败: %v", err)}, nil
}
if len(entries) == 0 {
return &model.ToolResult{ID: "", Output: fmt.Sprintf("目录: %s\n(空目录)", relPath)}, nil
}
var result strings.Builder
result.WriteString(fmt.Sprintf("目录: %s\n共 %d 项:\n", relPath, len(entries)))
for _, entry := range entries {
icon := "📄"
if entry.IsDir() {
icon = "📁"
}
info, _ := entry.Info()
size := ""
if info != nil && !entry.IsDir() {
size = fmt.Sprintf(" (%d bytes)", info.Size())
}
result.WriteString(fmt.Sprintf(" %s %s%s\n", icon, entry.Name(), size))
}
return &model.ToolResult{ID: "", Output: result.String()}, nil
}
func (t *FileTool) handleExists(absPath, relPath string) (*model.ToolResult, error) {
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return &model.ToolResult{ID: "", Output: fmt.Sprintf("路径不存在: %s", relPath)}, nil
}
return &model.ToolResult{ID: "", Error: fmt.Sprintf("检查路径失败: %v", err)}, nil
}
kind := "文件"
if info.IsDir() {
kind = "目录"
}
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("路径存在: %s (%s, %d bytes)", relPath, kind, info.Size()),
}, nil
}
func (t *FileTool) handleDelete(absPath, relPath string) (*model.ToolResult, error) {
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("文件不存在: %s", relPath)}, nil
}
return &model.ToolResult{ID: "", Error: fmt.Sprintf("删除文件失败: %v", err)}, nil
}
if info.IsDir() {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("不能删除目录(安全限制): %s", relPath)}, nil
}
if err := os.Remove(absPath); err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("删除文件失败: %v", err)}, nil
}
return &model.ToolResult{ID: "", Output: fmt.Sprintf("已删除文件: %s", relPath)}, nil
}
@@ -0,0 +1,157 @@
package tools
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
)
// HTTPTool sends arbitrary HTTP requests, more flexible than web_fetch.
type HTTPTool struct {
client *http.Client
}
// NewHTTPTool creates an HTTP request tool.
func NewHTTPTool() *HTTPTool {
return &HTTPTool{
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// Definition returns the tool definition for LLM function calling.
func (t *HTTPTool) Definition() model.ToolDefinition {
return model.ToolDefinition{
Name: "http_request",
Description: "发送任意HTTP请求。比web_fetch更灵活,支持自定义请求方法、请求头和请求体。返回状态码、响应头和响应体。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{
"type": "string",
"description": "请求URL,必须是完整的 http:// 或 https:// 链接",
},
"method": map[string]interface{}{
"type": "string",
"enum": []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"},
"description": "HTTP方法,默认GET",
},
"headers": map[string]interface{}{
"type": "object",
"description": "请求头,键值对格式,如 {\"Content-Type\": \"application/json\", \"Authorization\": \"Bearer token123\"}",
},
"body": map[string]interface{}{
"type": "string",
"description": "请求体内容",
},
"timeout": map[string]interface{}{
"type": "number",
"description": "超时秒数,默认10秒",
},
},
"required": []string{"url"},
},
}
}
// Execute sends an HTTP request.
func (t *HTTPTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
url, ok := arguments["url"].(string)
if !ok || url == "" {
return &model.ToolResult{ID: "", Error: "缺少 url 参数"}, nil
}
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return &model.ToolResult{ID: "", Error: "仅支持 http:// 或 https:// 链接"}, nil
}
method, _ := arguments["method"].(string)
if method == "" {
method = "GET"
}
method = strings.ToUpper(method)
validMethods := map[string]bool{
"GET": true, "POST": true, "PUT": true, "DELETE": true,
"PATCH": true, "HEAD": true, "OPTIONS": true,
}
if !validMethods[method] {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("不支持的HTTP方法: %s", method)}, nil
}
timeoutSec := 10.0
if timeoutVal, ok := arguments["timeout"].(float64); ok && timeoutVal > 0 {
timeoutSec = timeoutVal
}
client := &http.Client{
Timeout: time.Duration(timeoutSec * float64(time.Second)),
}
var bodyReader io.Reader
bodyStr, _ := arguments["body"].(string)
if bodyStr != "" {
bodyReader = strings.NewReader(bodyStr)
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("创建请求失败: %v", err)}, nil
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyreneBot/1.0)")
if headersRaw, ok := arguments["headers"].(map[string]interface{}); ok {
for k, v := range headersRaw {
val, ok := v.(string)
if !ok {
val = fmt.Sprintf("%v", v)
}
req.Header.Set(k, val)
}
}
resp, err := client.Do(req)
if err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("请求失败: %v", err)}, nil
}
defer resp.Body.Close()
const maxBodySize = 50 * 1024
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxBodySize)))
if err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("读取响应失败: %v", err)}, nil
}
var headerLines []string
for k, vals := range resp.Header {
for _, v := range vals {
headerLines = append(headerLines, fmt.Sprintf("%s: %s", k, v))
}
}
headersStr := strings.Join(headerLines, "\n")
bodyTruncated := ""
if len(bodyBytes) > maxBodySize {
bodyTruncated = fmt.Sprintf("\n... [响应体已截断,原大小约 %d bytes]", len(bodyBytes))
}
result := fmt.Sprintf(
"请求: %s %s\n状态: %d %s\n响应头:\n%s\n\n响应体 (%d bytes):\n%s%s",
method, url,
resp.StatusCode, resp.Status,
headersStr,
len(bodyBytes), string(bodyBytes), bodyTruncated,
)
return &model.ToolResult{
ID: "",
Output: result,
}, nil
}
@@ -0,0 +1,191 @@
package tools
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sync"
"time"
)
// IoTClient IoT 调试服务 HTTP 客户端
type IoTClient struct {
baseURL string
client *http.Client
// 缓存控制
mu sync.RWMutex
cache []IoTDevice
cacheTime time.Time
cacheTTL time.Duration
}
// NewIoTClient 创建 IoT 客户端
func NewIoTClient(baseURL string) *IoTClient {
return &IoTClient{
baseURL: baseURL,
client: &http.Client{
Timeout: 5 * time.Second,
},
cacheTTL: 60 * time.Second,
}
}
// GetAllDevices 获取所有设备列表(带缓存)
func (c *IoTClient) GetAllDevices() ([]IoTDevice, error) {
// 检查缓存
c.mu.RLock()
if c.cache != nil && time.Since(c.cacheTime) < c.cacheTTL {
devices := make([]IoTDevice, len(c.cache))
copy(devices, c.cache)
c.mu.RUnlock()
return devices, nil
}
c.mu.RUnlock()
// 请求 API
resp, err := c.client.Get(c.baseURL + "/api/v1/devices")
if err != nil {
log.Printf("[IoT客户端] 请求失败: %v", err)
return nil, fmt.Errorf("获取设备列表失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取设备列表返回状态码 %d", resp.StatusCode)
}
var result struct {
Devices []IoTDevice `json:"devices"`
Total int `json:"total"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("解析设备列表失败: %w", err)
}
// 更新缓存
c.mu.Lock()
c.cache = result.Devices
c.cacheTime = time.Now()
c.mu.Unlock()
return result.Devices, nil
}
// GetDevice 获取单个设备详情
func (c *IoTClient) GetDevice(id string) (*IoTDevice, error) {
resp, err := c.client.Get(c.baseURL + "/api/v1/devices/" + id)
if err != nil {
return nil, fmt.Errorf("获取设备 %s 失败: %w", id, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("设备 %s 不存在", id)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取设备 %s 返回状态码 %d", id, resp.StatusCode)
}
var result struct {
Device IoTDevice `json:"device"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("解析设备信息失败: %w", err)
}
return &result.Device, nil
}
// ToggleDevice 切换设备开关状态
func (c *IoTClient) ToggleDevice(id string) error {
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/toggle", nil)
if err != nil {
return fmt.Errorf("创建切换请求失败: %w", err)
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("切换设备 %s 失败: %w", id, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("设备 %s 不存在", id)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("切换设备 %s 返回状态码 %d", id, resp.StatusCode)
}
// 切换后清除缓存,确保下次查询获取最新状态
c.mu.Lock()
c.cache = nil
c.mu.Unlock()
return nil
}
// SetDeviceProperty 设置设备属性(温度、亮度、位置、模式、颜色等)
func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}) error {
body, err := json.Marshal(map[string]interface{}{
"field": field,
"value": value,
})
if err != nil {
return fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1/devices/"+id+"/set", nil)
if err != nil {
return fmt.Errorf("创建设置请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Body = io.NopCloser(bytes.NewReader(body))
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("设置设备 %s 属性失败: %w", id, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("设备 %s 不存在", id)
}
if resp.StatusCode != http.StatusOK {
var errResp struct {
Error string `json:"error"`
}
json.NewDecoder(resp.Body).Decode(&errResp)
if errResp.Error != "" {
return fmt.Errorf("设置设备 %s 属性失败: %s", id, errResp.Error)
}
return fmt.Errorf("设置设备 %s 属性返回状态码 %d", id, resp.StatusCode)
}
// 修改后清除缓存
c.mu.Lock()
c.cache = nil
c.mu.Unlock()
return nil
}
// GetDevicesForContext 获取设备状态摘要(供上下文注入使用,失败不报错)
func (c *IoTClient) GetDevicesForContext() []IoTDevice {
devices, err := c.GetAllDevices()
if err != nil {
log.Printf("[IoT客户端] 获取设备状态摘要失败: %v", err)
return nil
}
return devices
}
// InvalidateCache 使缓存失效
func (c *IoTClient) InvalidateCache() {
c.mu.Lock()
c.cache = nil
c.mu.Unlock()
}
@@ -0,0 +1,439 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
)
// IoTControlTool IoT 设备控制工具
type IoTControlTool struct {
iotClient IoTClientInterface
}
// NewIoTControlTool 创建 IoT 控制工具
func NewIoTControlTool(iotClient IoTClientInterface) *IoTControlTool {
return &IoTControlTool{iotClient: iotClient}
}
// Definition 返回工具定义
func (t *IoTControlTool) Definition() model.ToolDefinition {
return model.ToolDefinition{
Name: "iot_control",
Description: "【仅当开拓者明确要求控制设备时才使用此工具】控制家中智能设备。可以开关灯光、空调、窗帘、门锁等设备,也可以调节温度、亮度、位置、模式、颜色等属性。" +
"\n⚠️ 重要约束:" +
"\n - 不要在开拓者只是询问设备状态时调用此工具(查询设备请用 iot_query" +
"\n - 不要自行决定执行操作,必须等开拓者明确说出「打开」「关闭」「调到」「设置」等控制指令" +
"\n - 不要因为之前对话中提到过某个设备就主动控制它" +
"\n支持的操作:toggle(切换开关状态)、turn_on(打开设备)、turn_off(关闭设备)、" +
"set_temperature(设置空调温度,需要 value 参数,单位°C)、" +
"set_brightness(设置灯光亮度,需要 value 参数,0-100)、" +
"set_position(设置窗帘位置,需要 value 参数,0-1000=关闭 100=全开)、" +
"set_mode(设置空调模式,需要 value 参数,可选值: cool/heat/auto)、" +
"set_color(设置灯光颜色,需要 value 参数,可选值: warm_white/cool_white/colorful",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"device_id": map[string]interface{}{
"type": "string",
"description": "要控制的设备ID。可选值: light-livingroom, light-bedroom, ac-livingroom, ac-bedroom, curtain-livingroom, lock-door",
},
"action": map[string]interface{}{
"type": "string",
"enum": []string{"toggle", "turn_on", "turn_off", "set_temperature", "set_brightness", "set_position", "set_mode", "set_color"},
"description": "要执行的操作。toggle:切换开关状态;turn_on:打开设备;turn_off:关闭设备;set_temperature:设置空调温度(需配合value参数);set_brightness:设置灯光亮度(需配合value参数);set_position:设置窗帘位置(需配合value参数);set_mode:设置空调模式(需配合value参数);set_color:设置灯光颜色(需配合value参数)",
},
"value": map[string]interface{}{
"type": "number",
"description": "操作的值。set_temperature 时表示目标温度(°C),set_brightness 时表示亮度百分比(0-100),set_position 时表示窗帘开合程度(0-100)。action 为 set_temperature/set_brightness/set_position 时必须提供。set_mode 时为字符串(cool/heat/auto),set_color 时为字符串(warm_white/cool_white/colorful",
},
},
"required": []string{"device_id", "action"},
},
}
}
// normalizeAction 标准化 action 参数,支持中文别名、power 参数等
func normalizeAction(arguments map[string]interface{}) string {
action, _ := arguments["action"].(string)
// 如果 action 为空,检查 power/status 参数
if action == "" {
// power 参数: "off"/"关"/"关闭" → turn_off, "on"/"开"/"打开" → turn_on
if pv, ok := arguments["power"]; ok {
switch v := pv.(type) {
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "off", "false", "关", "关闭":
return "turn_off"
case "on", "true", "开", "打开", "开启":
return "turn_on"
}
case bool:
if !v {
return "turn_off"
}
return "turn_on"
}
}
// status 参数同理
if sv, ok := arguments["status"]; ok {
switch v := sv.(type) {
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "off", "false", "关", "关闭":
return "turn_off"
case "on", "true", "开", "打开", "开启":
return "turn_on"
}
case bool:
if !v {
return "turn_off"
}
return "turn_on"
}
}
// 默认 toggle
return "toggle"
}
// 标准化中文 action 名
switch strings.ToLower(strings.TrimSpace(action)) {
case "打开", "开启", "开":
return "turn_on"
case "关闭", "关":
return "turn_off"
case "切换":
return "toggle"
case "设置温度", "调温度", "set_temp":
return "set_temperature"
case "设置亮度", "调亮度", "set_light":
return "set_brightness"
case "设置位置", "调位置":
return "set_position"
case "设置模式", "调模式", "切换模式":
return "set_mode"
case "设置颜色", "调颜色", "换颜色":
return "set_color"
}
return action
}
// Execute 执行设备控制
func (t *IoTControlTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
if t.iotClient == nil {
return &model.ToolResult{
Output: "",
Error: "IoT 客户端未初始化",
}, nil
}
// 参数别名:entity_id → device_id
deviceID, _ := arguments["device_id"].(string)
if deviceID == "" {
deviceID, _ = arguments["entity_id"].(string)
}
action := normalizeAction(arguments)
if deviceID == "" {
return &model.ToolResult{
Output: "",
Error: "缺少设备ID(请使用 device_id 参数)",
}, nil
}
// 先获取设备名用于友好的返回消息(失败不影响后续流程)
deviceName := deviceID
if dev, err := t.iotClient.GetDevice(deviceID); err == nil {
deviceName = dev.Name
}
// 处理属性设置类操作
switch action {
case "set_temperature":
return t.handleSetTemperature(deviceID, arguments)
case "set_brightness":
return t.handleSetBrightness(deviceID, arguments)
case "set_position":
return t.handleSetPosition(deviceID, arguments)
case "set_mode":
return t.handleSetMode(deviceID, arguments)
case "set_color":
return t.handleSetColor(deviceID, arguments)
case "turn_off":
// 声明式关闭:使用 SetDeviceProperty status/off 而非 toggle
// 即使设备已经关闭,SetProperty 也会幂等处理
if err := t.iotClient.SetDeviceProperty(deviceID, "status", "off"); err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("关闭设备失败: %v", err),
}, nil
}
return &model.ToolResult{
Output: fmt.Sprintf("已关闭设备: %s", deviceName),
Error: "",
}, nil
case "turn_on":
// 声明式打开:使用 SetDeviceProperty status/on 而非 toggle
if err := t.iotClient.SetDeviceProperty(deviceID, "status", "on"); err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("打开设备失败: %v", err),
}, nil
}
return &model.ToolResult{
Output: fmt.Sprintf("已打开设备: %s", deviceName),
Error: "",
}, nil
default: // "toggle"
if err := t.iotClient.ToggleDevice(deviceID); err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("操作设备失败: %v", err),
}, nil
}
// 获取切换后的状态
updatedDevice, err := t.iotClient.GetDevice(deviceID)
if err != nil {
return &model.ToolResult{
Output: fmt.Sprintf("已成功切换设备 %s 的状态。", deviceName),
Error: "",
}, nil
}
return &model.ToolResult{
Output: fmt.Sprintf("已成功操作设备: %s\n当前状态: %s", updatedDevice.Name, formatDeviceLine(*updatedDevice)),
Error: "",
}, nil
}
}
// extractValue 从 arguments 中提取 value 参数(支持 value/Value 及数字/字符串类型)
func extractValue(arguments map[string]interface{}) interface{} {
if v, ok := arguments["value"]; ok {
return v
}
return nil
}
// handleSetTemperature 处理设置温度
func (t *IoTControlTool) handleSetTemperature(deviceID string, arguments map[string]interface{}) (*model.ToolResult, error) {
val := extractValue(arguments)
if val == nil {
return &model.ToolResult{
Output: "",
Error: "缺少 value 参数,请指定目标温度(如 24)",
}, nil
}
// 先获取当前设备信息
currentDevice, err := t.iotClient.GetDevice(deviceID)
if err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("获取设备状态失败: %v", err),
}, nil
}
temperature, ok := toFloat64(val)
if !ok {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("温度值无效: %v", val),
}, nil
}
if err := t.iotClient.SetDeviceProperty(deviceID, "temperature", temperature); err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("设置温度失败: %v", err),
}, nil
}
return &model.ToolResult{
Output: fmt.Sprintf("已将 %s 温度从 %.1f°C 调整为 %.1f°C", currentDevice.Name, currentDevice.Temperature, temperature),
Error: "",
}, nil
}
// handleSetBrightness 处理设置亮度
func (t *IoTControlTool) handleSetBrightness(deviceID string, arguments map[string]interface{}) (*model.ToolResult, error) {
val := extractValue(arguments)
if val == nil {
return &model.ToolResult{
Output: "",
Error: "缺少 value 参数,请指定亮度值(0-100",
}, nil
}
// 先获取当前设备信息
currentDevice, err := t.iotClient.GetDevice(deviceID)
if err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("获取设备状态失败: %v", err),
}, nil
}
brightness, ok := toFloat64(val)
if !ok {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("亮度值无效: %v", val),
}, nil
}
if err := t.iotClient.SetDeviceProperty(deviceID, "brightness", brightness); err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("设置亮度失败: %v", err),
}, nil
}
return &model.ToolResult{
Output: fmt.Sprintf("已将 %s 亮度调整为 %d%%", currentDevice.Name, int(brightness)),
Error: "",
}, nil
}
// handleSetPosition 处理设置窗帘位置
func (t *IoTControlTool) handleSetPosition(deviceID string, arguments map[string]interface{}) (*model.ToolResult, error) {
val := extractValue(arguments)
if val == nil {
return &model.ToolResult{
Output: "",
Error: "缺少 value 参数,请指定位置值(0=关闭, 100=全开)",
}, nil
}
currentDevice, err := t.iotClient.GetDevice(deviceID)
if err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("获取设备状态失败: %v", err),
}, nil
}
position, ok := toFloat64(val)
if !ok {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("位置值无效: %v", val),
}, nil
}
if err := t.iotClient.SetDeviceProperty(deviceID, "position", position); err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("设置窗帘位置失败: %v", err),
}, nil
}
return &model.ToolResult{
Output: fmt.Sprintf("已将 %s 窗帘调整为 %d%%", currentDevice.Name, int(position)),
Error: "",
}, nil
}
// handleSetMode 处理设置空调模式
func (t *IoTControlTool) handleSetMode(deviceID string, arguments map[string]interface{}) (*model.ToolResult, error) {
val := extractValue(arguments)
if val == nil {
return &model.ToolResult{
Output: "",
Error: "缺少 value 参数,请指定模式(cool/heat/auto",
}, nil
}
mode, ok := val.(string)
if !ok {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("模式值无效: %v", val),
}, nil
}
currentDevice, err := t.iotClient.GetDevice(deviceID)
if err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("获取设备状态失败: %v", err),
}, nil
}
if err := t.iotClient.SetDeviceProperty(deviceID, "mode", mode); err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("设置模式失败: %v", err),
}, nil
}
return &model.ToolResult{
Output: fmt.Sprintf("已将 %s 模式切换为 %s", currentDevice.Name, mode),
Error: "",
}, nil
}
// handleSetColor 处理设置灯光颜色
func (t *IoTControlTool) handleSetColor(deviceID string, arguments map[string]interface{}) (*model.ToolResult, error) {
val := extractValue(arguments)
if val == nil {
return &model.ToolResult{
Output: "",
Error: "缺少 value 参数,请指定颜色(warm_white/cool_white/colorful",
}, nil
}
color, ok := val.(string)
if !ok {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("颜色值无效: %v", val),
}, nil
}
currentDevice, err := t.iotClient.GetDevice(deviceID)
if err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("获取设备状态失败: %v", err),
}, nil
}
if err := t.iotClient.SetDeviceProperty(deviceID, "color", color); err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("设置颜色失败: %v", err),
}, nil
}
return &model.ToolResult{
Output: fmt.Sprintf("已将 %s 灯光颜色切换为 %s", currentDevice.Name, color),
Error: "",
}, nil
}
// toFloat64 将 interface{} 转换为 float64
func toFloat64(v interface{}) (float64, bool) {
switch val := v.(type) {
case float64:
return val, true
case float32:
return float64(val), true
case int:
return float64(val), true
case int64:
return float64(val), true
case json.Number:
f, err := val.Float64()
return f, err == nil
default:
return 0, false
}
}
@@ -0,0 +1,131 @@
package tools
import (
"context"
"fmt"
"strings"
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
)
// IoTQueryTool IoT 设备查询工具
type IoTQueryTool struct {
iotClient IoTClientInterface
}
// NewIoTQueryTool 创建 IoT 查询工具
func NewIoTQueryTool(iotClient IoTClientInterface) *IoTQueryTool {
return &IoTQueryTool{iotClient: iotClient}
}
// Definition 返回工具定义
func (t *IoTQueryTool) Definition() model.ToolDefinition {
return model.ToolDefinition{
Name: "iot_query",
Description: "查询家中智能设备状态。注意:当前设备状态通常已自动注入到系统提示词中,你通常不需要调用此工具即可回答设备状态问题。只有在设备状态信息陈旧或明显不完整时才调用此工具刷新。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"device_id": map[string]interface{}{
"type": "string",
"description": "要查询的设备ID(可选,不填则返回所有设备)。可选值: light-livingroom, light-bedroom, ac-livingroom, ac-bedroom, curtain-livingroom, sensor-temperature, sensor-humidity, lock-door",
},
},
},
}
}
// Execute 执行查询
func (t *IoTQueryTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
if t.iotClient == nil {
return &model.ToolResult{
Output: "",
Error: "IoT 客户端未初始化",
}, nil
}
deviceID, _ := arguments["device_id"].(string)
if deviceID != "" {
// 查询单个设备
device, err := t.iotClient.GetDevice(deviceID)
if err != nil {
return &model.ToolResult{
Output: "",
Error: err.Error(),
}, nil
}
return &model.ToolResult{
Output: formatSingleDevice(device),
Error: "",
}, nil
}
// 查询所有设备
devices, err := t.iotClient.GetAllDevices()
if err != nil {
return &model.ToolResult{
Output: "",
Error: err.Error(),
}, nil
}
var result strings.Builder
result.WriteString(fmt.Sprintf("当前共有 %d 台智能设备:\n\n", len(devices)))
for _, d := range devices {
result.WriteString(formatDeviceLine(d) + "\n")
}
return &model.ToolResult{
Output: result.String(),
Error: "",
}, nil
}
func formatSingleDevice(d *IoTDevice) string {
return fmt.Sprintf("设备: %s (%s)\n状态: %s", d.Name, d.Type, formatDeviceLine(*d))
}
func formatDeviceLine(d IoTDevice) string {
switch d.Type {
case "light":
if d.Status == "on" {
return fmt.Sprintf("💡 %s: 开启 (亮度%d%%, %s)", d.Name, d.Brightness, d.Color)
}
return fmt.Sprintf("💡 %s: 关闭", d.Name)
case "ac":
if d.Status == "on" {
mode := d.Mode
switch mode {
case "cool":
mode = "制冷"
case "heat":
mode = "制热"
case "auto":
mode = "自动"
}
return fmt.Sprintf("❄️ %s: 运行中 (%s %.0f°C)", d.Name, mode, d.Temperature)
}
return fmt.Sprintf("❄️ %s: 关闭", d.Name)
case "curtain":
if d.Status == "open" {
return fmt.Sprintf("🪟 %s: 已打开", d.Name)
}
return fmt.Sprintf("🪟 %s: 已关闭", d.Name)
case "sensor":
unit := d.Unit
if unit == "celsius" {
unit = "°C"
} else if unit == "percent" {
unit = "%"
}
return fmt.Sprintf("🌡️ %s: %.1f%s", d.Name, d.Value, unit)
case "lock":
status := "已锁定"
if d.Status == "unlocked" {
status = "已解锁"
}
return fmt.Sprintf("🔒 %s: %s (电量%d%%)", d.Name, status, d.Battery)
default:
return fmt.Sprintf("%s: %s", d.Name, d.Status)
}
}
@@ -0,0 +1,187 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
)
// JSONTool provides JSON parsing, querying, and validation for the LLM.
type JSONTool struct{}
// NewJSONTool creates a JSON processing tool.
func NewJSONTool() *JSONTool {
return &JSONTool{}
}
// Definition returns the tool definition for LLM function calling.
func (t *JSONTool) Definition() model.ToolDefinition {
return model.ToolDefinition{
Name: "json_ops",
Description: "JSON处理工具。解析JSON字符串并格式化输出、用简单路径查询JSON字段、验证JSON是否合法。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"enum": []string{"parse", "query", "validate"},
"description": "操作类型。parse: 解析JSON并格式化输出;query: 用路径查询JSON中的值(如\"users.0.name\"表示取users数组第0个元素的name字段);validate: 验证JSON字符串是否合法",
},
"json_string": map[string]interface{}{
"type": "string",
"description": "JSON字符串",
},
"path": map[string]interface{}{
"type": "string",
"description": "查询路径(query操作时使用)。支持点分隔和数组索引,如 \"users.0.name\"、\"data.list.2.title\"",
},
},
"required": []string{"action", "json_string"},
},
}
}
// Execute performs JSON operations.
func (t *JSONTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
action, ok := arguments["action"].(string)
if !ok || action == "" {
return &model.ToolResult{ID: "", Error: "缺少 action 参数"}, nil
}
jsonStr, ok := arguments["json_string"].(string)
if !ok || jsonStr == "" {
return &model.ToolResult{ID: "", Error: "缺少 json_string 参数"}, nil
}
switch action {
case "parse":
return t.handleParse(jsonStr)
case "query":
path, _ := arguments["path"].(string)
return t.handleQuery(jsonStr, path)
case "validate":
return t.handleValidate(jsonStr)
default:
return &model.ToolResult{
ID: "",
Error: fmt.Sprintf("未知操作: %s,支持: parse, query, validate", action),
}, nil
}
}
func (t *JSONTool) handleParse(jsonStr string) (*model.ToolResult, error) {
var data interface{}
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("JSON解析失败: %v", err)}, nil
}
pretty, err := json.MarshalIndent(data, "", " ")
if err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("JSON格式化失败: %v", err)}, nil
}
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("解析成功\n格式化输出:\n%s", string(pretty)),
}, nil
}
func (t *JSONTool) handleQuery(jsonStr, path string) (*model.ToolResult, error) {
if path == "" {
return &model.ToolResult{ID: "", Error: "query 操作需要 path 参数"}, nil
}
var data interface{}
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("JSON解析失败: %v", err)}, nil
}
value, err := queryPath(data, path)
if err != nil {
return &model.ToolResult{ID: "", Error: err.Error()}, nil
}
pretty, err := json.MarshalIndent(value, "", " ")
if err != nil {
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("路径: %s\n值: %v", path, value),
}, nil
}
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("路径: %s\n值:\n%s", path, string(pretty)),
}, nil
}
func (t *JSONTool) handleValidate(jsonStr string) (*model.ToolResult, error) {
var data interface{}
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
errStr := err.Error()
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("❌ JSON不合法\n错误: %s", errStr),
}, nil
}
typeName := "object"
switch data.(type) {
case []interface{}:
typeName = "array"
case string:
typeName = "string"
case float64:
typeName = "number"
case bool:
typeName = "boolean"
case nil:
typeName = "null"
}
size := len(jsonStr)
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("✅ JSON合法\n类型: %s\n大小: %d bytes", typeName, size),
}, nil
}
func queryPath(data interface{}, path string) (interface{}, error) {
path = strings.TrimPrefix(path, "$.")
if path == "" || path == "$" {
return data, nil
}
parts := strings.Split(path, ".")
current := data
for _, part := range parts {
switch v := current.(type) {
case map[string]interface{}:
var ok bool
current, ok = v[part]
if !ok {
return nil, fmt.Errorf("路径 '%s' 中字段 '%s' 不存在", path, part)
}
case []interface{}:
idx, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("路径 '%s' 中 '%s' 不是有效的数组索引", path, part)
}
if idx < 0 || idx >= len(v) {
return nil, fmt.Errorf("路径 '%s' 中索引 %d 越界(数组长度 %d)", path, idx, len(v))
}
current = v[idx]
default:
return nil, fmt.Errorf("路径 '%s' 中无法继续导航:'%s' 不是对象或数组", path, part)
}
}
return current, nil
}
@@ -0,0 +1,348 @@
package tools
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
)
// MarkdownTool provides Markdown processing utilities for the LLM.
type MarkdownTool struct{}
// NewMarkdownTool creates a Markdown processing tool.
func NewMarkdownTool() *MarkdownTool {
return &MarkdownTool{}
}
// Definition returns the tool definition for LLM function calling.
func (t *MarkdownTool) Definition() model.ToolDefinition {
return model.ToolDefinition{
Name: "markdown",
Description: "Markdown处理工具。将Markdown转为HTML、提取纯文本、提取链接/代码块、生成目录。用于处理Markdown格式的文档内容。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"enum": []string{"to_html", "to_text", "extract_links", "extract_code", "table_of_contents"},
"description": "操作类型。to_html: 转换为HTMLto_text: 提取纯文本;extract_links: 提取所有链接;extract_code: 提取所有代码块;table_of_contents: 生成目录",
},
"markdown": map[string]interface{}{
"type": "string",
"description": "Markdown格式文本,需要处理的Markdown内容",
},
},
"required": []string{"action", "markdown"},
},
}
}
// Execute performs Markdown processing operations.
func (t *MarkdownTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
action, ok := arguments["action"].(string)
if !ok || action == "" {
return &model.ToolResult{ID: "", Error: "缺少 action 参数"}, nil
}
md, ok := arguments["markdown"].(string)
if !ok || strings.TrimSpace(md) == "" {
return &model.ToolResult{ID: "", Error: "缺少 markdown 参数或内容为空"}, nil
}
switch action {
case "to_html":
return t.handleToHTML(md)
case "to_text":
return t.handleToText(md)
case "extract_links":
return t.handleExtractLinks(md)
case "extract_code":
return t.handleExtractCode(md)
case "table_of_contents":
return t.handleTableOfContents(md)
default:
return &model.ToolResult{
ID: "",
Error: fmt.Sprintf("未知操作: %s,支持: to_html, to_text, extract_links, extract_code, table_of_contents", action),
}, nil
}
}
func (t *MarkdownTool) handleToHTML(md string) (*model.ToolResult, error) {
html := md
codeBlocks := make([]string, 0)
reFence := regexp.MustCompile("(?s)```[^`]*```")
html = reFence.ReplaceAllStringFunc(html, func(match string) string {
codeBlocks = append(codeBlocks, match)
return fmt.Sprintf("\x00CODEBLOCK%d\x00", len(codeBlocks)-1)
})
inlineCodes := make([]string, 0)
reInlineCode := regexp.MustCompile("`[^`]+`")
html = reInlineCode.ReplaceAllStringFunc(html, func(match string) string {
inlineCodes = append(inlineCodes, match)
return fmt.Sprintf("\x00INLINECODE%d\x00", len(inlineCodes)-1)
})
reImage := regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
html = reImage.ReplaceAllString(html, `<img src="$2" alt="$1">`)
reLink := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
html = reLink.ReplaceAllString(html, `<a href="$2">$1</a>`)
reBold := regexp.MustCompile(`\*\*([^*]+)\*\*`)
html = reBold.ReplaceAllString(html, `<strong>$1</strong>`)
reBold2 := regexp.MustCompile(`__([^_]+)__`)
html = reBold2.ReplaceAllString(html, `<strong>$1</strong>`)
reItalic := regexp.MustCompile(`\*([^*]+)\*`)
html = reItalic.ReplaceAllString(html, `<em>$1</em>`)
reItalic2 := regexp.MustCompile(`_([^_]+)_`)
html = reItalic2.ReplaceAllString(html, `<em>$1</em>`)
reStrike := regexp.MustCompile(`~~([^~]+)~~`)
html = reStrike.ReplaceAllString(html, `<del>$1</del>`)
reH6 := regexp.MustCompile(`(?m)^######\s+(.+)$`)
html = reH6.ReplaceAllString(html, `<h6>$1</h6>`)
reH5 := regexp.MustCompile(`(?m)^#####\s+(.+)$`)
html = reH5.ReplaceAllString(html, `<h5>$1</h5>`)
reH4 := regexp.MustCompile(`(?m)^####\s+(.+)$`)
html = reH4.ReplaceAllString(html, `<h4>$1</h4>`)
reH3 := regexp.MustCompile(`(?m)^###\s+(.+)$`)
html = reH3.ReplaceAllString(html, `<h3>$1</h3>`)
reH2 := regexp.MustCompile(`(?m)^##\s+(.+)$`)
html = reH2.ReplaceAllString(html, `<h2>$1</h2>`)
reH1 := regexp.MustCompile(`(?m)^#\s+(.+)$`)
html = reH1.ReplaceAllString(html, `<h1>$1</h1>`)
reHR := regexp.MustCompile(`(?m)^(---|\*\*\*|___)\s*$`)
html = reHR.ReplaceAllString(html, `<hr>`)
html = t.processLists(html, `(?m)^[\-*]\s+`, "ul")
html = t.processLists(html, `(?m)^\d+\.\s+`, "ol")
reBlockquote := regexp.MustCompile(`(?m)^>\s?(.+)$`)
html = reBlockquote.ReplaceAllString(html, `<blockquote>$1</blockquote>`)
html = t.wrapParagraphs(html)
for i, cb := range codeBlocks {
content := strings.TrimPrefix(cb, "```")
content = strings.TrimSuffix(content, "```")
lang := ""
content = strings.TrimSpace(content)
if idx := strings.Index(content, "\n"); idx > 0 {
lang = strings.TrimSpace(content[:idx])
content = strings.TrimSpace(content[idx+1:])
}
if lang != "" {
html = strings.ReplaceAll(html, fmt.Sprintf("\x00CODEBLOCK%d\x00", i),
fmt.Sprintf(`<pre><code class="language-%s">%s</code></pre>`, lang, escapeHTML(content)))
} else {
html = strings.ReplaceAll(html, fmt.Sprintf("\x00CODEBLOCK%d\x00", i),
fmt.Sprintf("<pre><code>%s</code></pre>", escapeHTML(content)))
}
}
for i, ic := range inlineCodes {
content := strings.Trim(ic, "`")
html = strings.ReplaceAll(html, fmt.Sprintf("\x00INLINECODE%d\x00", i),
fmt.Sprintf("<code>%s</code>", escapeHTML(content)))
}
return &model.ToolResult{ID: "", Output: html}, nil
}
func (t *MarkdownTool) handleToText(md string) (*model.ToolResult, error) {
text := md
reFence := regexp.MustCompile("(?s)```[^`]*```")
text = reFence.ReplaceAllString(text, "[代码块]")
reInlineCode := regexp.MustCompile("`[^`]+`")
text = reInlineCode.ReplaceAllString(text, "[代码]")
reImage := regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`)
text = reImage.ReplaceAllString(text, "$1")
reLink := regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`)
text = reLink.ReplaceAllString(text, "$1")
text = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`~~([^~]+)~~`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(text, "")
text = regexp.MustCompile(`(?m)^(---|\*\*\*|___)\s*$`).ReplaceAllString(text, "")
text = regexp.MustCompile(`(?m)^[\-*]\s+`).ReplaceAllString(text, "")
text = regexp.MustCompile(`(?m)^\d+\.\s+`).ReplaceAllString(text, "")
text = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(text, "")
text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n")
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("纯文本提取结果 (%d 字符):\n\n%s",
len([]rune(text)), strings.TrimSpace(text)),
}, nil
}
func (t *MarkdownTool) handleExtractLinks(md string) (*model.ToolResult, error) {
reLink := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
matches := reLink.FindAllStringSubmatch(md, -1)
if len(matches) == 0 {
return &model.ToolResult{ID: "", Output: "未找到任何链接"}, nil
}
var result strings.Builder
result.WriteString(fmt.Sprintf("提取链接 (共 %d 个):\n\n", len(matches)))
for i, m := range matches {
result.WriteString(fmt.Sprintf("%d. [%s](%s)\n - 文本: %s\n - URL: %s\n\n",
i+1, m[1], m[2], m[1], m[2]))
}
return &model.ToolResult{ID: "", Output: strings.TrimSpace(result.String())}, nil
}
func (t *MarkdownTool) handleExtractCode(md string) (*model.ToolResult, error) {
reFence := regexp.MustCompile("(?s)```([^`]*)```")
matches := reFence.FindAllStringSubmatch(md, -1)
if len(matches) == 0 {
return &model.ToolResult{ID: "", Output: "未找到任何代码块"}, nil
}
var result strings.Builder
result.WriteString(fmt.Sprintf("提取代码块 (共 %d 个):\n\n", len(matches)))
for i, m := range matches {
content := strings.TrimSpace(m[1])
lang := ""
if idx := strings.Index(content, "\n"); idx > 0 {
lang = strings.TrimSpace(content[:idx])
content = strings.TrimSpace(content[idx+1:])
}
result.WriteString(fmt.Sprintf("--- 代码块 %d", i+1))
if lang != "" {
result.WriteString(fmt.Sprintf(" (语言: %s)", lang))
}
result.WriteString(fmt.Sprintf(" ---\n%s\n\n", truncateText(content, 500)))
}
return &model.ToolResult{ID: "", Output: strings.TrimSpace(result.String())}, nil
}
func (t *MarkdownTool) handleTableOfContents(md string) (*model.ToolResult, error) {
reHeading := regexp.MustCompile(`(?m)^(#{1,6})\s+(.+)$`)
matches := reHeading.FindAllStringSubmatch(md, -1)
if len(matches) == 0 {
return &model.ToolResult{ID: "", Output: "未找到任何标题,无法生成目录"}, nil
}
var result strings.Builder
result.WriteString(fmt.Sprintf("文档目录 (共 %d 个标题):\n\n", len(matches)))
for _, m := range matches {
level := len(m[1])
title := strings.TrimSpace(m[2])
indent := strings.Repeat(" ", level-1)
result.WriteString(fmt.Sprintf("%s%s %s\n", indent, strings.Repeat("#", level), title))
}
return &model.ToolResult{ID: "", Output: result.String()}, nil
}
func (t *MarkdownTool) processLists(html, itemPattern, listTag string) string {
reItem := regexp.MustCompile(itemPattern + `(.+)$`)
lines := strings.Split(html, "\n")
result := make([]string, 0, len(lines))
inList := false
for _, line := range lines {
if reItem.MatchString(line) {
content := reItem.ReplaceAllString(line, "$1")
if !inList {
result = append(result, fmt.Sprintf("<%s>", listTag))
inList = true
}
result = append(result, fmt.Sprintf("<li>%s</li>", content))
} else {
if inList {
result = append(result, fmt.Sprintf("</%s>", listTag))
inList = false
}
result = append(result, line)
}
}
if inList {
result = append(result, fmt.Sprintf("</%s>", listTag))
}
return strings.Join(result, "\n")
}
func (t *MarkdownTool) wrapParagraphs(html string) string {
lines := strings.Split(html, "\n")
result := make([]string, 0, len(lines))
skipTags := map[string]bool{
"<h1>": true, "<h2>": true, "<h3>": true, "<h4>": true, "<h5>": true, "<h6>": true,
"<hr>": true, "<ul>": true, "</ul>": true, "<ol>": true, "</ol>": true,
"<li>": true, "</li>": true, "<blockquote>": true, "</blockquote>": true,
"<pre>": true, "</pre>": true, "<img": true,
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
result = append(result, line)
continue
}
isTag := false
for tag := range skipTags {
if strings.HasPrefix(trimmed, tag) {
isTag = true
break
}
}
if !isTag {
result = append(result, fmt.Sprintf("<p>%s</p>", trimmed))
} else {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
func escapeHTML(s string) string {
replacer := strings.NewReplacer(
"&", "&"+"amp;",
"<", "&"+"lt;",
">", "&"+"gt;",
"\"", "&"+"quot;",
)
return replacer.Replace(s)
}
func truncateText(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "..."
}
@@ -0,0 +1,318 @@
package tools
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"math/big"
mathrand "math/rand"
"strings"
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
)
// RandomTool provides random generation utilities for the LLM.
type RandomTool struct{}
// NewRandomTool creates a random generation tool.
func NewRandomTool() *RandomTool {
return &RandomTool{}
}
// Definition returns the tool definition for LLM function calling.
func (t *RandomTool) Definition() model.ToolDefinition {
return model.ToolDefinition{
Name: "random",
Description: "随机生成工具。生成随机数、UUID、安全密码,或从列表中随机选取/打乱元素。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"enum": []string{"number", "uuid", "password", "pick", "shuffle"},
"description": "操作类型。number: 生成随机整数;uuid: 生成UUID v4password: 生成安全密码;pick: 从列表随机选取;shuffle: 随机打乱列表",
},
"min": map[string]interface{}{
"type": "number",
"description": "随机数最小值(用于 number 操作),默认 0",
},
"max": map[string]interface{}{
"type": "number",
"description": "随机数最大值(用于 number 操作),默认 100",
},
"length": map[string]interface{}{
"type": "integer",
"description": "密码长度(用于 password 操作),默认 16",
},
"items": map[string]interface{}{
"type": "array",
"description": "列表项(用于 pick/shuffle 操作),字符串数组",
"items": map[string]interface{}{
"type": "string",
},
},
"count": map[string]interface{}{
"type": "integer",
"description": "选取数量(用于 pick 操作),默认 1",
},
},
"required": []string{"action"},
},
}
}
// Execute performs random generation operations.
func (t *RandomTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
action, ok := arguments["action"].(string)
if !ok || action == "" {
return &model.ToolResult{ID: "", Error: "缺少 action 参数"}, nil
}
switch action {
case "number":
return t.handleNumber(arguments)
case "uuid":
return t.handleUUID()
case "password":
return t.handlePassword(arguments)
case "pick":
return t.handlePick(arguments)
case "shuffle":
return t.handleShuffle(arguments)
default:
return &model.ToolResult{
ID: "",
Error: fmt.Sprintf("未知操作: %s,支持: number, uuid, password, pick, shuffle", action),
}, nil
}
}
func (t *RandomTool) handleNumber(arguments map[string]interface{}) (*model.ToolResult, error) {
minVal := getFloatArg(arguments, "min", 0)
maxVal := getFloatArg(arguments, "max", 100)
if minVal > maxVal {
minVal, maxVal = maxVal, minVal
}
minI := int64(minVal)
maxI := int64(maxVal)
rangeVal := maxI - minI + 1
if rangeVal <= 0 {
return &model.ToolResult{ID: "", Error: "无效的数值范围"}, nil
}
n, err := rand.Int(rand.Reader, big.NewInt(rangeVal))
if err != nil {
result := minI + mathrand.Int63n(rangeVal)
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("随机整数 [%d, %d]: %d", minI, maxI, result),
}, nil
}
result := minI + n.Int64()
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("随机整数 [%d, %d]: %d", minI, maxI, result),
}, nil
}
func (t *RandomTool) handleUUID() (*model.ToolResult, error) {
uuid := make([]byte, 16)
_, err := rand.Read(uuid)
if err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("生成UUID失败: %v", err)}, nil
}
uuid[6] = (uuid[6] & 0x0f) | 0x40
uuid[8] = (uuid[8] & 0x3f) | 0x80
uuidStr := fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:16])
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("UUID v4: %s", uuidStr),
}, nil
}
func (t *RandomTool) handlePassword(arguments map[string]interface{}) (*model.ToolResult, error) {
length := getIntArg(arguments, "length", 16)
if length < 4 {
length = 16
}
if length > 128 {
length = 128
}
uppercase := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
lowercase := "abcdefghijklmnopqrstuvwxyz"
digits := "0123456789"
symbols := "!@#$%^&*()_+-=[]{}|;:,.<>?"
allChars := uppercase + lowercase + digits + symbols
password := make([]byte, length)
password[0] = uppercase[secureIndex(len(uppercase))]
password[1] = lowercase[secureIndex(len(lowercase))]
password[2] = digits[secureIndex(len(digits))]
password[3] = symbols[secureIndex(len(symbols))]
for i := 4; i < length; i++ {
password[i] = allChars[secureIndex(len(allChars))]
}
shuffleBytes(password)
passwordStr := string(password)
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("安全密码 (长度: %d):\n%s\n\n字符集: 大写字母 + 小写字母 + 数字 + 特殊符号",
length, passwordStr),
}, nil
}
func (t *RandomTool) handlePick(arguments map[string]interface{}) (*model.ToolResult, error) {
items := getStringSliceArg(arguments, "items")
if len(items) == 0 {
return &model.ToolResult{ID: "", Error: "缺少 items 参数或列表为空"}, nil
}
count := getIntArg(arguments, "count", 1)
if count < 1 {
count = 1
}
if count > len(items) {
count = len(items)
}
indices := make([]int, len(items))
for i := range indices {
indices[i] = i
}
shuffleInts(indices)
picked := make([]string, 0, count)
for i := 0; i < count; i++ {
picked = append(picked, items[indices[i]])
}
var result strings.Builder
result.WriteString(fmt.Sprintf("从 %d 个选项中随机选取 %d 个:\n", len(items), count))
for i, p := range picked {
result.WriteString(fmt.Sprintf(" %d. %s\n", i+1, p))
}
return &model.ToolResult{ID: "", Output: result.String()}, nil
}
func (t *RandomTool) handleShuffle(arguments map[string]interface{}) (*model.ToolResult, error) {
items := getStringSliceArg(arguments, "items")
if len(items) == 0 {
return &model.ToolResult{ID: "", Error: "缺少 items 参数或列表为空"}, nil
}
shuffled := make([]string, len(items))
copy(shuffled, items)
shuffleStrings(shuffled)
var result strings.Builder
result.WriteString(fmt.Sprintf("随机打乱结果 (共 %d 项):\n", len(shuffled)))
for i, s := range shuffled {
result.WriteString(fmt.Sprintf(" %d. %s\n", i+1, s))
}
return &model.ToolResult{ID: "", Output: result.String()}, nil
}
// --- Helper functions ---
func getFloatArg(arguments map[string]interface{}, key string, fallback float64) float64 {
if v, ok := arguments[key]; ok {
switch val := v.(type) {
case float64:
return val
case int:
return float64(val)
case int64:
return float64(val)
case json.Number:
f, err := val.Float64()
if err == nil {
return f
}
}
}
return fallback
}
func getIntArg(arguments map[string]interface{}, key string, fallback int) int {
if v, ok := arguments[key]; ok {
switch val := v.(type) {
case float64:
return int(val)
case int:
return val
case int64:
return int(val)
}
}
return fallback
}
func getStringSliceArg(arguments map[string]interface{}, key string) []string {
if v, ok := arguments[key]; ok {
switch val := v.(type) {
case []interface{}:
result := make([]string, 0, len(val))
for _, item := range val {
if s, ok := item.(string); ok {
result = append(result, s)
} else {
result = append(result, fmt.Sprintf("%v", item))
}
}
return result
case []string:
return val
}
}
return nil
}
func secureIndex(max int) int {
if max <= 1 {
return 0
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
if err != nil {
return mathrand.Intn(max)
}
return int(n.Int64())
}
func shuffleBytes(data []byte) {
for i := len(data) - 1; i > 0; i-- {
j := secureIndex(i + 1)
data[i], data[j] = data[j], data[i]
}
}
func shuffleInts(data []int) {
for i := len(data) - 1; i > 0; i-- {
j := secureIndex(i + 1)
data[i], data[j] = data[j], data[i]
}
}
func shuffleStrings(data []string) {
for i := len(data) - 1; i > 0; i-- {
j := secureIndex(i + 1)
data[i], data[j] = data[j], data[i]
}
}
@@ -0,0 +1,41 @@
package tools
import (
"context"
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
)
// Tool 工具接口
type Tool interface {
Definition() model.ToolDefinition
Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error)
}
// IoTClientFactory 用于创建 IoT 客户端的工厂函数类型
type IoTClientFactory func() IoTClientInterface
// IoTClientInterface IoT 客户端接口(解耦对 ai-core 的依赖)
type IoTClientInterface interface {
GetAllDevices() ([]IoTDevice, error)
GetDevice(id string) (*IoTDevice, error)
ToggleDevice(id string) error
SetDeviceProperty(id string, field string, value interface{}) error
}
// IoTDevice IoT 设备结构体
type IoTDevice struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Brightness int `json:"brightness,omitempty"`
Color string `json:"color,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Mode string `json:"mode,omitempty"`
Position int `json:"position,omitempty"`
Value float64 `json:"value,omitempty"`
Unit string `json:"unit,omitempty"`
Battery int `json:"battery,omitempty"`
LastUpdated string `json:"last_updated"`
}
+295
View File
@@ -0,0 +1,295 @@
package tools
import (
"context"
"fmt"
"regexp"
"strings"
"unicode"
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
)
// TextTool provides text processing operations for the LLM.
type TextTool struct{}
// NewTextTool creates a text processing tool.
func NewTextTool() *TextTool {
return &TextTool{}
}
// Definition returns the tool definition for LLM function calling.
func (t *TextTool) Definition() model.ToolDefinition {
return model.ToolDefinition{
Name: "text",
Description: "文本处理工具。统计文本、生成摘要、翻译文本、正则提取信息。用于处理用户提供的文本内容。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"enum": []string{"count", "summarize", "translate", "extract"},
"description": "操作类型。count: 统计字符/单词/行/段落数;summarize: 提取首段+关键句生成简单摘要;translate: 翻译文本(需指定target_lang);extract: 正则提取邮箱/电话/URL等",
},
"text": map[string]interface{}{
"type": "string",
"description": "输入文本,需要处理的文本内容",
},
"target_lang": map[string]interface{}{
"type": "string",
"enum": []string{"en", "zh", "ja", "ko", "fr", "de"},
"description": "翻译目标语言代码。en: 英语, zh: 中文, ja: 日语, ko: 韩语, fr: 法语, de: 德语",
},
"pattern": map[string]interface{}{
"type": "string",
"description": "正则表达式模式,用于 extract 操作。常用预设: email(邮箱), phone(电话), url(网址)",
},
},
"required": []string{"action", "text"},
},
}
}
// Execute performs text processing operations.
func (t *TextTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
action, ok := arguments["action"].(string)
if !ok || action == "" {
return &model.ToolResult{ID: "", Error: "缺少 action 参数"}, nil
}
text, ok := arguments["text"].(string)
if !ok || strings.TrimSpace(text) == "" {
return &model.ToolResult{ID: "", Error: "缺少 text 参数或文本为空"}, nil
}
switch action {
case "count":
return t.handleCount(text)
case "summarize":
return t.handleSummarize(text)
case "translate":
return t.handleTranslate(arguments)
case "extract":
return t.handleExtract(arguments)
default:
return &model.ToolResult{
ID: "",
Error: fmt.Sprintf("未知操作: %s,支持: count, summarize, translate, extract", action),
}, nil
}
}
func (t *TextTool) handleCount(text string) (*model.ToolResult, error) {
charCount := len([]rune(text))
byteCount := len(text)
words := strings.Fields(text)
wordCount := len(words)
lines := strings.Split(text, "\n")
lineCount := len(lines)
paragraphs := regexp.MustCompile(`\n\s*\n`).Split(text, -1)
paraCount := 0
for _, p := range paragraphs {
if strings.TrimSpace(p) != "" {
paraCount++
}
}
chineseCount := 0
for _, r := range text {
if unicode.Is(unicode.Han, r) {
chineseCount++
}
}
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("文本统计结果:\n- 字符数 (含空格): %d\n- 字符数 (不含空格): %d\n- 字节数: %d\n- 单词数: %d\n- 行数: %d\n- 段落数: %d\n- 中文字符数: %d",
charCount, len([]rune(strings.ReplaceAll(text, " ", ""))),
byteCount, wordCount, lineCount, paraCount, chineseCount),
}, nil
}
func (t *TextTool) handleSummarize(text string) (*model.ToolResult, error) {
var result strings.Builder
result.WriteString("文本摘要:\n\n")
paragraphs := regexp.MustCompile(`\n\s*\n`).Split(text, -1)
var firstPara string
for _, p := range paragraphs {
if trimmed := strings.TrimSpace(p); trimmed != "" {
firstPara = trimmed
break
}
}
if firstPara != "" {
result.WriteString("【首段】\n")
runes := []rune(firstPara)
if len(runes) > 300 {
firstPara = string(runes[:300]) + "..."
}
result.WriteString(firstPara)
result.WriteString("\n\n")
}
sentences := t.splitSentences(text)
keySentences := t.extractKeySentences(sentences, 5)
if len(keySentences) > 0 {
result.WriteString("【关键句】\n")
for i, s := range keySentences {
result.WriteString(fmt.Sprintf("%d. %s\n", i+1, s))
}
}
lines := strings.Split(text, "\n")
words := strings.Fields(text)
result.WriteString(fmt.Sprintf("\n【概况】共 %d 段、%d 句、%d 词、%d 行",
len(paragraphs), len(sentences), len(words), len(lines)))
return &model.ToolResult{ID: "", Output: result.String()}, nil
}
func (t *TextTool) splitSentences(text string) []string {
re := regexp.MustCompile(`[^。!?.!?\n]+[。!?.!?\n]?`)
return re.FindAllString(text, -1)
}
func (t *TextTool) extractKeySentences(sentences []string, maxCount int) []string {
type scored struct {
text string
score int
}
var scoredList []scored
keywords := []string{"重要", "关键", "核心", "主要", "首先", "最后", "因此", "所以", "总结",
"important", "key", "critical", "significant", "therefore", "conclusion", "summary"}
for _, s := range sentences {
trimmed := strings.TrimSpace(s)
if len([]rune(trimmed)) < 10 {
continue
}
score := len([]rune(trimmed))
lower := strings.ToLower(trimmed)
for _, kw := range keywords {
if strings.Contains(lower, kw) {
score += 50
}
}
scoredList = append(scoredList, scored{text: trimmed, score: score})
}
for i := 0; i < len(scoredList); i++ {
for j := i + 1; j < len(scoredList); j++ {
if scoredList[j].score > scoredList[i].score {
scoredList[i], scoredList[j] = scoredList[j], scoredList[i]
}
}
}
result := make([]string, 0, maxCount)
for i := 0; i < len(scoredList) && i < maxCount; i++ {
result = append(result, scoredList[i].text)
}
return result
}
func (t *TextTool) handleTranslate(arguments map[string]interface{}) (*model.ToolResult, error) {
text, _ := arguments["text"].(string)
targetLang, _ := arguments["target_lang"].(string)
if targetLang == "" {
targetLang = "zh"
}
langNames := map[string]string{
"en": "英语",
"zh": "中文",
"ja": "日语",
"ko": "韩语",
"fr": "法语",
"de": "德语",
}
langName, ok := langNames[targetLang]
if !ok {
langName = targetLang
}
return &model.ToolResult{
ID: "",
Output: fmt.Sprintf("【翻译请求】\n目标语言: %s (%s)\n原文 (%d 字符):\n---\n%s\n---\n\n提示: 实际翻译由LLM完成,请基于以上原文和目标语言进行翻译。",
langName, targetLang, len([]rune(text)), text),
}, nil
}
func (t *TextTool) handleExtract(arguments map[string]interface{}) (*model.ToolResult, error) {
text, _ := arguments["text"].(string)
pattern, _ := arguments["pattern"].(string)
presets := map[string]string{
"email": `[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`,
"phone": `(?:\+?86[\-\s]?)?1[3-9]\d{9}`,
"url": `https?://[^\s<>"{}|\\^` + "`" + `\[\]]+`,
}
if preset, ok := presets[strings.ToLower(pattern)]; ok {
pattern = preset
}
if pattern == "" {
var result strings.Builder
result.WriteString("文本提取结果:\n\n")
for name, p := range presets {
re, err := regexp.Compile(p)
if err != nil {
continue
}
matches := re.FindAllString(text, -1)
if len(matches) > 0 {
result.WriteString(fmt.Sprintf("【%s】(共 %d 个):\n", name, len(matches)))
seen := make(map[string]bool)
for _, m := range matches {
if !seen[m] {
result.WriteString(fmt.Sprintf(" - %s\n", m))
seen[m] = true
}
}
result.WriteString("\n")
}
}
if result.Len() == len("文本提取结果:\n\n") {
return &model.ToolResult{ID: "", Output: "未提取到匹配的内容(邮箱、电话、URL)"}, nil
}
return &model.ToolResult{ID: "", Output: result.String()}, nil
}
re, err := regexp.Compile(pattern)
if err != nil {
return &model.ToolResult{ID: "", Error: fmt.Sprintf("正则表达式无效: %v", err)}, nil
}
matches := re.FindAllString(text, -1)
if len(matches) == 0 {
return &model.ToolResult{ID: "", Output: fmt.Sprintf("未找到匹配模式 '%s' 的内容", pattern)}, nil
}
var result strings.Builder
result.WriteString(fmt.Sprintf("正则提取结果 (模式: %s, 共 %d 个匹配):\n", pattern, len(matches)))
seen := make(map[string]bool)
for _, m := range matches {
if !seen[m] {
result.WriteString(fmt.Sprintf(" - %s\n", m))
seen[m] = true
}
}
return &model.ToolResult{ID: "", Output: result.String()}, nil
}
@@ -0,0 +1,154 @@
package tools
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
)
// WebFetchTool 网络访问工具 - 允许昔涟获取网页内容
type WebFetchTool struct {
client *http.Client
timeout time.Duration
}
// NewWebFetchTool 创建网络访问工具
func NewWebFetchTool() *WebFetchTool {
return &WebFetchTool{
client: &http.Client{
Timeout: 15 * time.Second,
},
timeout: 15 * time.Second,
}
}
// Definition 返回工具定义
func (t *WebFetchTool) Definition() model.ToolDefinition {
return model.ToolDefinition{
Name: "web_fetch",
Description: "获取指定URL的网页内容。用于查阅新闻、文档、资料等。返回纯文本摘要(前2000字符)。仅支持 HTTP/HTTPS URL。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{
"type": "string",
"description": "要获取的网页URL,必须是完整的 http:// 或 https:// 链接",
},
},
"required": []string{"url"},
},
}
}
// Execute 执行网页获取
func (t *WebFetchTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
rawURL, ok := arguments["url"].(string)
if !ok || rawURL == "" {
return &model.ToolResult{
Output: "",
Error: "缺少 url 参数",
}, nil
}
// 安全检查:只允许 HTTP/HTTPS
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
return &model.ToolResult{
Output: "",
Error: "仅支持 http:// 或 https:// 链接",
}, nil
}
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
if err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("创建请求失败: %v", err),
}, nil
}
// 模拟常见浏览器 User-Agent,避免被拒
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyreneBot/1.0; +https://github.com/AskaEth/Cyrene)")
req.Header.Set("Accept", "text/html,text/plain,*/*")
resp, err := t.client.Do(req)
if err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("请求失败: %v", err),
}, nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
}, nil
}
// 限制读取大小(最多 100KB
limitedReader := io.LimitReader(resp.Body, 100*1024)
body, err := io.ReadAll(limitedReader)
if err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("读取响应失败: %v", err),
}, nil
}
// 提取纯文本摘要(去除 HTML 标签)
text := extractText(string(body))
// 截断到 2000 字符
if len([]rune(text)) > 2000 {
runes := []rune(text)
text = string(runes[:2000]) + "\n\n... [内容已截断,共" + fmt.Sprintf("%d", len(runes)) + "字符]"
}
result := fmt.Sprintf("URL: %s\n状态: %d\n内容类型: %s\n\n%s",
rawURL, resp.StatusCode, resp.Header.Get("Content-Type"), text)
return &model.ToolResult{
Output: result,
Error: "",
}, nil
}
// extractText 从 HTML/文本中提取纯文本
func extractText(raw string) string {
// 简单的 HTML 标签去除
text := raw
inTag := false
var result []rune
for _, r := range text {
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
result = append(result, r)
}
}
// 去除多余空白
trimmed := strings.TrimSpace(string(result))
// 压缩连续空行
lines := strings.Split(trimmed, "\n")
var cleanLines []string
for _, line := range lines {
trimLine := strings.TrimSpace(line)
if trimLine != "" {
cleanLines = append(cleanLines, trimLine)
}
}
return strings.Join(cleanLines, "\n")
}
@@ -0,0 +1,223 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/yourname/cyrene-ai/tool-engine/internal/model"
)
// WebSearchTool 网页搜索工具 - 基于 DuckDuckGo Instant Answer API
type WebSearchTool struct {
client *http.Client
timeout time.Duration
}
// NewWebSearchTool 创建网页搜索工具
func NewWebSearchTool() *WebSearchTool {
return &WebSearchTool{
client: &http.Client{
Timeout: 10 * time.Second,
},
timeout: 10 * time.Second,
}
}
// Definition 返回工具定义
func (t *WebSearchTool) Definition() model.ToolDefinition {
return model.ToolDefinition{
Name: "web_search",
Description: "搜索互联网信息。用于查找新闻、资料、知识等。返回搜索结果摘要(最多5条)。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{
"type": "string",
"description": "搜索关键词",
},
},
"required": []string{"query"},
},
}
}
// duckDuckGoResponse DuckDuckGo API 响应
type duckDuckGoResponse struct {
AbstractText string `json:"AbstractText"`
AbstractURL string `json:"AbstractURL"`
AbstractSource string `json:"AbstractSource"`
Heading string `json:"Heading"`
Answer string `json:"Answer"`
AnswerType string `json:"AnswerType"`
RelatedTopics []duckDuckGoRelated `json:"RelatedTopics"`
Results []duckDuckGoResult `json:"Results"`
}
type duckDuckGoRelated struct {
Text string `json:"Text"`
FirstURL string `json:"FirstURL"`
}
type duckDuckGoResult struct {
Text string `json:"Text"`
FirstURL string `json:"FirstURL"`
}
// Execute 执行网页搜索
func (t *WebSearchTool) Execute(ctx context.Context, arguments map[string]interface{}) (*model.ToolResult, error) {
query, ok := arguments["query"].(string)
if !ok || query == "" {
return &model.ToolResult{
Output: "",
Error: "缺少 query 参数",
}, nil
}
// 使用 DuckDuckGo Instant Answer API
apiURL := fmt.Sprintf("https://api.duckduckgo.com/?q=%s&format=json&no_html=1&skip_disambig=1",
url.QueryEscape(query))
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("创建请求失败: %v", err),
}, nil
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyreneBot/1.0)")
resp, err := t.client.Do(req)
if err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("请求失败: %v", err),
}, nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
}, nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 500*1024))
if err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("读取响应失败: %v", err),
}, nil
}
var ddg duckDuckGoResponse
if err := json.Unmarshal(body, &ddg); err != nil {
return &model.ToolResult{
Output: "",
Error: fmt.Sprintf("解析响应失败: %v", err),
}, nil
}
var result strings.Builder
result.WriteString(fmt.Sprintf("搜索关键词: %s\n\n", query))
// 1. 如果有即时答案
if ddg.Answer != "" {
result.WriteString(fmt.Sprintf("📌 即时答案: %s\n\n", ddg.Answer))
}
// 2. 摘要
if ddg.AbstractText != "" {
abstract := ddg.AbstractText
if len([]rune(abstract)) > 500 {
runes := []rune(abstract)
abstract = string(runes[:500]) + "..."
}
result.WriteString(fmt.Sprintf("摘要: %s\n", abstract))
if ddg.AbstractURL != "" {
result.WriteString(fmt.Sprintf("来源: %s\n", ddg.AbstractURL))
}
result.WriteString("\n")
}
// 3. 相关话题
topics := ddg.RelatedTopics
if len(ddg.Results) > 0 {
// 优先用 Results
count := 0
for _, r := range ddg.Results {
if count >= 5 {
break
}
if r.Text != "" {
text := stripHTML(r.Text)
if len([]rune(text)) > 200 {
runes := []rune(text)
text = string(runes[:200]) + "..."
}
result.WriteString(fmt.Sprintf("\n🔗 %s\n", text))
if r.FirstURL != "" {
result.WriteString(fmt.Sprintf(" %s\n", r.FirstURL))
}
count++
}
}
} else {
count := 0
for _, topic := range topics {
if count >= 5 {
break
}
if topic.Text != "" {
text := stripHTML(topic.Text)
if len([]rune(text)) > 200 {
runes := []rune(text)
text = string(runes[:200]) + "..."
}
result.WriteString(fmt.Sprintf("\n🔗 %s\n", text))
if topic.FirstURL != "" {
result.WriteString(fmt.Sprintf(" %s\n", topic.FirstURL))
}
count++
}
}
}
if result.Len() == 0 {
result.WriteString("未找到相关结果。")
}
return &model.ToolResult{
Output: result.String(),
Error: "",
}, nil
}
// stripHTML 去除 HTML 标签
func stripHTML(s string) string {
inTag := false
var result []rune
for _, r := range s {
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
// 替换常见块级标签为空格
result = append(result, ' ')
continue
}
if !inTag {
result = append(result, r)
}
}
return strings.TrimSpace(string(result))
}