## 症状
单封邮件的元信息三行都不对(生产实测那封 12:12:12):
发件 jianf.邮件驱动·多智能体协作平台-完整设计文档-一、项目概述-11-项目定位
收件 pi@/home/program/agentmail
抄送 pi@/home/program/agentmail.new
人指定的是「投进 pi 的那条会话」,而界面把会话别名拼给了**发件人**。
## 三处错
**1. 别名拼错了一方。** `name@path.session` 三段才唯一确定「哪个 Agent、
在哪个目录、哪条线索」—— 别名必须跟 Agent 走。拼给发件人之后收件人变成
`pi@/home/program/agentmail`,那指向**默认会话**而不是人指定的那条。
**2. 人不该有目录和会话位。** 人没有工作目录,发给人就是进收件箱。
`jianf.某会话` 是把 Agent 的三维语义硬套在人身上,而且因为 from_workspace
为空,拼出来的形态连 ParseAddress 都还原不了 —— 没有 `@` 时整串被当成
**名字**(实测 name="jianf.某会话别名"),投递必然 404。
**3. 抄送残留 `.new`。** 它是一次性动作,建完会话就失效;留着会让人以为
再发一次还能投进同一条会话,实际会开出第三条。
## 修法
`identityAddress` → `participantAddress(name, workspace, alias)`,
判据是有没有 workspace:
Agent → pi@/home/program/agentmail.日程提醒:… 三段齐全
人 → jianf 裸名字
`ccAddress` 按同一判据分流;`.new` 换成当前会话别名。
六处手工拼接(MailView / MailList ×2 / ThreadView)统一走这两个函数。
## 顺带修掉 `dsh@dsh`
改的时候实测发现:**`mails.from_workspace` 对 Agent 存的是 Agent 名而不是
路径**(历史遗留,见 db/migrate.go 里 sessions.workspace 的注释)。
拿它当路径拼,Agent 发来的信显示成 `dsh@dsh`。
会话的 workspace 才是权威来源 → `models.Mail` 新增 `SessionWorkspace`,
六处查询补 `s.workspace`:GetMailByID / ListInbox / GetSessionMails /
GetSessionMailByID / ListSentBy / threadCols。
## formatAddress 与后端对齐
第一版我改成「path 为空时舍弃 session 返回裸名字」,对着后端 ParseAddress
跑了一遍才发现搞反了 —— **正确形态是保留 `@`**:
jianf@.任务 → name=jianf path="" session=任务 ✓
jianf.任务 → name="jianf.任务" ✗
现在两端六个 case 逐例一致(这个分支只在内部逻辑上用得到;
展示一律走 participantAddress,人根本不带会话位)。
## 取舍
列表行与对话树节点**不带会话位**:列表的分组头已单独显示别名,
树的每个节点都在同一条线索上 —— 重复无信息量,而 92 字节的别名会把那行挤没。
## homeagent 日程工具的两个修复(同批)
**查询串手拼吃掉了时区。** RFC3339 的 `+08:00` 里那个 `+` 在查询串里正是
空格的转义形式,服务端 ParseQuery 还原成空格 → time.Parse 失败 →
AgentListCalendarEvents **静默退回默认区间**(不报错)。表现为「明明有日程
却说一条都没有」。改走 url.Values.Encode()。
**默认窗口 3 个月太窄。** yearly / lunar_yearly 的下一次触发随时落在窗口外,
模型问「我建过什么」得到空结果,然后照着空结果再建一条重复的。改成 14 个月。
空结果的话术也从「你还没有建过日程」改成说出实际查询区间 —— 前者在窗口外
有事件时是假话。
## 验收
- web 182 例(replyTarget 24 → 46);tsc 无错;Gateway 7 包全过
- 新增 test/manual/addr-verify.mjs:真渲染两个方向都验过
人 → Agent:jianf / pi@/home/program/agentmail.日程提醒:…
Agent → 人:dsh@/home/program/agentmail.查看工程与插件适配指南 / jianf
判据含「Agent 的 path 必须是真路径而不是 Agent 名」(锁 dsh@dsh 那个 bug)
503 lines
18 KiB
Go
503 lines
18 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 日程与待办工具。
|
||
//
|
||
// # 为什么要有这一组
|
||
//
|
||
// 在这之前「明天九点提醒我看 CI 结果」只能靠插件进程里的定时器 ——
|
||
// homed 一重启定时器就消失,那条提醒静默不见且无处留痕。
|
||
// 交给 Gateway 之后由数据库与调度器保证:插件重启、换机器都不影响。
|
||
//
|
||
// 更重要的是它让**跨 Agent 的任务交接**成立:模型可以给 dsh 设一条
|
||
// 「明天交周报」的提醒,到点由 Gateway 发一封邮件把 dsh 唤起来。
|
||
// 这件事模型自己做不到 —— 它没法让另一个进程在未来某刻醒来。
|
||
//
|
||
// # 时间格式是这组工具最容易出错的地方
|
||
//
|
||
// 模型写时间有三种典型错法:只给「明天九点」这样的自然语言、给了
|
||
// RFC3339 但忘了时区、年份写成过去。前两种在这里被 parseEventTime 兜住,
|
||
// 第三种由服务端拦(一次性日程设在过去会立刻触发,几乎总是笔误)。
|
||
//
|
||
// 刻意**不**接受自然语言:解析「下周三」需要知道模型以为的今天是哪天,
|
||
// 而它的上下文里那个日期可能是错的。要求显式时间戳会让它先去查当前时间,
|
||
// 那一步本身就消除了大部分歧义。
|
||
|
||
// scheduleFieldsHint 是几个工具共用的参数说明。
|
||
//
|
||
// 抽出来是因为这段话必须逐字一致:模型看到 create 与 update 的描述不同时,
|
||
// 会以为两者语义有别,于是在 update 里重复传所有字段(那正是我们想避免的)。
|
||
const scheduleFieldsHint = "时间用 RFC3339(如 2026-09-10T09:00:00+08:00,必须带时区偏移)。" +
|
||
"recurrence 可选 none/daily/weekly/monthly/yearly/lunar_monthly/lunar_yearly," +
|
||
"其中 lunar_* 按农历推进(每农历月同一日 / 每农历年同月同日,适合农历生日与祭日)。"
|
||
|
||
// handleCreateSchedule 建一条日程提醒。
|
||
func (p *Plugin) handleCreateSchedule(args map[string]interface{}) (interface{}, error) {
|
||
title := strings.TrimSpace(argString(args, "title"))
|
||
if title == "" {
|
||
return nil, fmt.Errorf("必须给 title")
|
||
}
|
||
when, err := parseEventTime(argString(args, "event_time"))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
payload := map[string]interface{}{
|
||
"title": title,
|
||
"event_time": when.Format(time.RFC3339),
|
||
}
|
||
if v := strings.TrimSpace(argString(args, "description")); v != "" {
|
||
payload["description"] = v
|
||
}
|
||
if v := strings.TrimSpace(argString(args, "reminder_text")); v != "" {
|
||
payload["reminder_text"] = v
|
||
}
|
||
if v := strings.TrimSpace(argString(args, "recurrence")); v != "" {
|
||
payload["recurrence"] = v
|
||
}
|
||
if n, ok := argInt(args, "remind_before"); ok {
|
||
payload["remind_before"] = n
|
||
}
|
||
// 收件人:逗号分隔的地址列表。省略 = 发给自己(最常见的用法,
|
||
// 每次都要求写出自己的名字只会让模型忘记然后拿到 400)。
|
||
recipients := parseRecipients(argString(args, "recipients"))
|
||
if len(recipients) > 0 {
|
||
payload["recipients"] = recipients
|
||
}
|
||
if v := strings.TrimSpace(argString(args, "delivery_mode")); v != "" {
|
||
payload["delivery_mode"] = v
|
||
}
|
||
if v := strings.TrimSpace(argString(args, "recurrence_end")); v != "" {
|
||
end, eErr := parseEventTime(v)
|
||
if eErr != nil {
|
||
return nil, fmt.Errorf("recurrence_end 解析失败:%w", eErr)
|
||
}
|
||
payload["recurrence_end"] = end.Format(time.RFC3339)
|
||
}
|
||
|
||
var ev scheduleEvent
|
||
if err := p.post("/agent/calendar/events", payload, &ev); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
lines := []string{
|
||
fmt.Sprintf("日程已创建:%s", ev.Title),
|
||
fmt.Sprintf(" 时间:%s", formatEventWhen(ev)),
|
||
fmt.Sprintf(" 提醒发给:%s", strings.Join(ev.effectiveRecipients(), ", ")),
|
||
}
|
||
if ev.Recurrence != "" && ev.Recurrence != "none" {
|
||
lines = append(lines, fmt.Sprintf(" 重复:%s", describeRecurrence(ev.Recurrence)))
|
||
}
|
||
if len(ev.effectiveRecipients()) > 1 {
|
||
lines = append(lines, fmt.Sprintf(" 投递方式:%s", describeDelivery(ev.DeliveryMode)))
|
||
}
|
||
// 回传 id:改时间或删掉都要用它,不给的话模型只能再 list 一次
|
||
lines = append(lines, fmt.Sprintf(" 日程 ID:%s", ev.EventID))
|
||
return textResult(strings.Join(lines, "\n")), nil
|
||
}
|
||
|
||
// handleListSchedules 列出自己建的日程。
|
||
//
|
||
// 查询串必须走 url.Values 编码,**不能手拼**。
|
||
//
|
||
// RFC3339 的时区偏移带一个 `+`(2026-12-11T09:00:00+08:00),而 `+` 在查询串里
|
||
// 正是空格的转义形式:手拼进 URL 后服务端 ParseQuery 把它还原成空格,
|
||
// time.Parse(RFC3339) 随即失败 —— 而 AgentListCalendarEvents 解析失败时是
|
||
// **静默退回默认区间**(now-24h ~ now+3月),不报错。
|
||
// 于是这里传的 from/to 全部无效,表现为「明明有日程却说一条都没有」。
|
||
// 真实踩过一次:先怀疑了数据库、密钥和权限,最后才发现是这个加号。
|
||
func (p *Plugin) handleListSchedules(args map[string]interface{}) (interface{}, error) {
|
||
q := url.Values{}
|
||
q.Set("status", argStringOr(args, "status", "active"))
|
||
if v := strings.TrimSpace(argString(args, "from")); v != "" {
|
||
if t, err := parseEventTime(v); err == nil {
|
||
q.Set("from", t.Format(time.RFC3339))
|
||
}
|
||
}
|
||
if v := strings.TrimSpace(argString(args, "to")); v != "" {
|
||
if t, err := parseEventTime(v); err == nil {
|
||
q.Set("to", t.Format(time.RFC3339))
|
||
}
|
||
} else {
|
||
// 默认往后 14 个月,而不是沿用服务端的 3 个月。
|
||
//
|
||
// 生日、纪念日这类 yearly / lunar_yearly 的下一次触发随时会落在
|
||
// 3 个月之外。窗口太窄时模型问「我建过什么」得到的是空 ——
|
||
// 然后它会照着空结果再建一条重复的。14 个月保证任何年度重复
|
||
// 都至少露一次脸。
|
||
q.Set("to", time.Now().AddDate(1, 2, 0).Format(time.RFC3339))
|
||
}
|
||
|
||
var res struct {
|
||
Events []scheduleEvent `json:"events"`
|
||
ActiveLimit int `json:"active_limit"`
|
||
}
|
||
if err := p.get(p.gwURL+"/api/v1/agent/calendar/events?"+q.Encode(), &res); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if len(res.Events) == 0 {
|
||
// 不能说「你还没有建过日程」——窗口外或别的 status 下还有事件时
|
||
// 这是假话,而模型会照着这句话去建一条重复的。
|
||
// 把实际查询区间说出来,让它知道该往哪放宽。
|
||
from := q.Get("from")
|
||
if from == "" {
|
||
from = "默认起点(昨天)"
|
||
}
|
||
return textResult(fmt.Sprintf(
|
||
"这个区间内没有日程:%s ~ %s,status=%s。\n"+
|
||
"想找更早/更远的请放宽 from/to,或用 status=all 看已暂停与已取消的。\n"+
|
||
"确认真的没有再用 create_schedule 建 —— 别照着空结果建重复的。",
|
||
from, q.Get("to"), q.Get("status"))), nil
|
||
}
|
||
|
||
var sb strings.Builder
|
||
fmt.Fprintf(&sb, "你的日程(%d 条", len(res.Events))
|
||
// 接近上限时主动说出来。只在撞墙时才报错等于让模型一直蒙在鼓里,
|
||
// 而它撞墙那一刻往往正在做别的事,没有余裕去清理。
|
||
if res.ActiveLimit > 0 {
|
||
fmt.Fprintf(&sb, ",生效上限 %d", res.ActiveLimit)
|
||
if len(res.Events)*4 >= res.ActiveLimit*3 {
|
||
sb.WriteString(" —— 已接近上限,建议删掉不需要的")
|
||
}
|
||
}
|
||
sb.WriteString("):\n")
|
||
|
||
for _, ev := range res.Events {
|
||
fmt.Fprintf(&sb, "\n· %s\n", ev.Title)
|
||
fmt.Fprintf(&sb, " 时间:%s\n", formatEventWhen(ev))
|
||
if ev.Recurrence != "" && ev.Recurrence != "none" {
|
||
fmt.Fprintf(&sb, " 重复:%s\n", describeRecurrence(ev.Recurrence))
|
||
}
|
||
if r := ev.effectiveRecipients(); len(r) > 0 {
|
||
fmt.Fprintf(&sb, " 发给:%s", strings.Join(r, ", "))
|
||
if len(r) > 1 {
|
||
fmt.Fprintf(&sb, "(%s)", describeDelivery(ev.DeliveryMode))
|
||
}
|
||
sb.WriteString("\n")
|
||
}
|
||
if ev.Status != "" && ev.Status != "active" {
|
||
fmt.Fprintf(&sb, " 状态:%s\n", describeStatus(ev.Status))
|
||
}
|
||
fmt.Fprintf(&sb, " ID:%s\n", ev.EventID)
|
||
}
|
||
return textResult(sb.String()), nil
|
||
}
|
||
|
||
// handleUpdateSchedule 改一条日程。
|
||
//
|
||
// **只传要改的字段**:服务端按字段是否出现决定改不改,省略的保持原值。
|
||
// 这一点必须在工具描述里写清楚 —— 模型的默认倾向是回传它记得的全部字段,
|
||
// 而它记错一个就会把提醒正文或收件人覆盖掉,且没有任何报错。
|
||
func (p *Plugin) handleUpdateSchedule(args map[string]interface{}) (interface{}, error) {
|
||
id := strings.TrimSpace(argString(args, "event_id"))
|
||
if id == "" {
|
||
return nil, fmt.Errorf("必须给 event_id(用 list_schedules 查)")
|
||
}
|
||
|
||
payload := map[string]interface{}{}
|
||
if v, ok := args["title"].(string); ok {
|
||
payload["title"] = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := args["description"].(string); ok {
|
||
payload["description"] = v
|
||
}
|
||
if v, ok := args["reminder_text"].(string); ok {
|
||
payload["reminder_text"] = v
|
||
}
|
||
if v, ok := args["recurrence"].(string); ok && strings.TrimSpace(v) != "" {
|
||
payload["recurrence"] = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := args["status"].(string); ok && strings.TrimSpace(v) != "" {
|
||
payload["status"] = strings.TrimSpace(v)
|
||
}
|
||
if v, ok := args["delivery_mode"].(string); ok && strings.TrimSpace(v) != "" {
|
||
payload["delivery_mode"] = strings.TrimSpace(v)
|
||
}
|
||
if n, ok := argInt(args, "remind_before"); ok {
|
||
payload["remind_before"] = n
|
||
}
|
||
if v, ok := args["event_time"].(string); ok && strings.TrimSpace(v) != "" {
|
||
t, err := parseEventTime(v)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
payload["event_time"] = t.Format(time.RFC3339)
|
||
}
|
||
if v, ok := args["recurrence_end"].(string); ok && strings.TrimSpace(v) != "" {
|
||
t, err := parseEventTime(v)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("recurrence_end 解析失败:%w", err)
|
||
}
|
||
payload["recurrence_end"] = t.Format(time.RFC3339)
|
||
}
|
||
if v, ok := args["recipients"].(string); ok && strings.TrimSpace(v) != "" {
|
||
payload["recipients"] = parseRecipients(v)
|
||
}
|
||
|
||
if len(payload) == 0 {
|
||
return nil, fmt.Errorf("没有要改的字段。只传需要修改的那几个即可(其余保持原值)")
|
||
}
|
||
|
||
var ev scheduleEvent
|
||
if err := p.put("/agent/calendar/events/"+id, payload, &ev); err != nil {
|
||
return nil, err
|
||
}
|
||
return textResult(strings.Join([]string{
|
||
fmt.Sprintf("日程已更新:%s", ev.Title),
|
||
fmt.Sprintf(" 时间:%s", formatEventWhen(ev)),
|
||
fmt.Sprintf(" 重复:%s", describeRecurrence(ev.Recurrence)),
|
||
fmt.Sprintf(" 发给:%s", strings.Join(ev.effectiveRecipients(), ", ")),
|
||
fmt.Sprintf(" 状态:%s", describeStatus(ev.Status)),
|
||
}, "\n")), nil
|
||
}
|
||
|
||
// handleDeleteSchedule 删一条日程。
|
||
func (p *Plugin) handleDeleteSchedule(args map[string]interface{}) (interface{}, error) {
|
||
id := strings.TrimSpace(argString(args, "event_id"))
|
||
if id == "" {
|
||
return nil, fmt.Errorf("必须给 event_id(用 list_schedules 查)")
|
||
}
|
||
if err := p.delete("/agent/calendar/events/" + id); err != nil {
|
||
return nil, err
|
||
}
|
||
return textResult(fmt.Sprintf("日程 %s 已删除,不再提醒。", id)), nil
|
||
}
|
||
|
||
// ─── 数据结构与格式化 ───
|
||
|
||
// scheduleEvent 是 Gateway 返回的事件。
|
||
//
|
||
// 只声明用到的字段:多声明的字段一旦服务端改名会静默变成零值,
|
||
// 而少声明的字段 json 解码本来就忽略。
|
||
type scheduleEvent struct {
|
||
EventID string `json:"event_id"`
|
||
Title string `json:"title"`
|
||
Description string `json:"description"`
|
||
ReminderText string `json:"reminder_text"`
|
||
AgentName string `json:"agent_name"`
|
||
ToAddress string `json:"to_address"`
|
||
Recipients []string `json:"recipients"`
|
||
DeliveryMode string `json:"delivery_mode"`
|
||
EventTime string `json:"event_time"`
|
||
RemindBefore int `json:"remind_before"`
|
||
Recurrence string `json:"recurrence"`
|
||
Status string `json:"status"`
|
||
}
|
||
|
||
// effectiveRecipients 与服务端 EffectiveRecipients() 同一条兜底链。
|
||
//
|
||
// 必须有:服务端对旧数据会退回 to_address/agent_name,
|
||
// 这里若只读 recipients,那些事件会显示成「发给:」后面空白。
|
||
func (e scheduleEvent) effectiveRecipients() []string {
|
||
out := make([]string, 0, len(e.Recipients))
|
||
for _, r := range e.Recipients {
|
||
if r = strings.TrimSpace(r); r != "" {
|
||
out = append(out, r)
|
||
}
|
||
}
|
||
if len(out) > 0 {
|
||
return out
|
||
}
|
||
if v := strings.TrimSpace(e.ToAddress); v != "" {
|
||
return []string{v}
|
||
}
|
||
if v := strings.TrimSpace(e.AgentName); v != "" {
|
||
return []string{v}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// formatEventWhen 把事件时间渲染成本地时间,并写出实际提醒时刻。
|
||
//
|
||
// 提醒时刻必须单独写出来:模型设了「提前 30 分钟」之后,
|
||
// 只看事件时间会以为那才是收到通知的时候。
|
||
func formatEventWhen(e scheduleEvent) string {
|
||
t, err := time.Parse(time.RFC3339, e.EventTime)
|
||
if err != nil {
|
||
return e.EventTime
|
||
}
|
||
local := t.Local()
|
||
out := local.Format("2006-01-02 15:04 (Mon)")
|
||
if e.RemindBefore > 0 {
|
||
remind := local.Add(-time.Duration(e.RemindBefore) * time.Minute)
|
||
out += fmt.Sprintf(",提前 %s 提醒(%s)",
|
||
describeMinutes(e.RemindBefore), remind.Format("01-02 15:04"))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func describeMinutes(min int) string {
|
||
switch {
|
||
case min <= 0:
|
||
return "0 分钟"
|
||
case min < 60:
|
||
return strconv.Itoa(min) + " 分钟"
|
||
case min%60 == 0:
|
||
return strconv.Itoa(min/60) + " 小时"
|
||
default:
|
||
return fmt.Sprintf("%d 小时 %d 分钟", min/60, min%60)
|
||
}
|
||
}
|
||
|
||
func describeRecurrence(r string) string {
|
||
switch r {
|
||
case "", "none":
|
||
return "不重复"
|
||
case "daily":
|
||
return "每天"
|
||
case "weekly":
|
||
return "每周"
|
||
case "monthly":
|
||
return "每月(公历同一日)"
|
||
case "yearly":
|
||
return "每年(公历同月日)"
|
||
case "lunar_monthly":
|
||
return "每农历月(同一日)"
|
||
case "lunar_yearly":
|
||
return "每农历年(同月同日)"
|
||
default:
|
||
return r
|
||
}
|
||
}
|
||
|
||
func describeDelivery(m string) string {
|
||
if m == "together" {
|
||
return "一起发送:首个是主收件人,其余抄送,共享同一条线索"
|
||
}
|
||
return "分别发送:各自独立会话,互相看不到"
|
||
}
|
||
|
||
func describeStatus(s string) string {
|
||
switch s {
|
||
case "paused":
|
||
return "已暂停(不再触发提醒)"
|
||
case "cancelled":
|
||
return "已取消"
|
||
default:
|
||
return "生效中"
|
||
}
|
||
}
|
||
|
||
// ─── 参数解析 ───
|
||
|
||
// parseEventTime 解析时间字符串。
|
||
//
|
||
// 接受三种形态,按严格程度递减:
|
||
//
|
||
// 1. 完整 RFC3339 带时区(`2026-09-10T09:00:00+08:00`)—— 推荐
|
||
// 2. 不带时区(`2026-09-10T09:00:00` / `2026-09-10 09:00`)—— 按**本机时区**解释
|
||
// 3. 只有日期(`2026-09-10`)—— 当天 09:00
|
||
//
|
||
// 第 2 种要兜住是因为模型经常忘时区,而拒绝它只会让它重试同样的写法。
|
||
// 按本机时区解释是唯一合理的猜测:Agent 与 Gateway 跑在同一台机器上,
|
||
// 人说「九点」指的就是这台机器上的九点。
|
||
//
|
||
// **刻意不接受自然语言**(「明天九点」):解析它需要知道模型以为今天是哪天,
|
||
// 而它上下文里那个日期常常是错的 —— 一个静默偏移一天的提醒比报错糟得多。
|
||
func parseEventTime(raw string) (time.Time, error) {
|
||
s := strings.TrimSpace(raw)
|
||
if s == "" {
|
||
return time.Time{}, fmt.Errorf("必须给 event_time,例如 2026-09-10T09:00:00+08:00")
|
||
}
|
||
|
||
// 带时区的标准形态
|
||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||
return t, nil
|
||
}
|
||
|
||
// 无时区:按本机时区解释
|
||
for _, layout := range []string{
|
||
"2006-01-02T15:04:05",
|
||
"2006-01-02T15:04",
|
||
"2006-01-02 15:04:05",
|
||
"2006-01-02 15:04",
|
||
} {
|
||
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
|
||
return t, nil
|
||
}
|
||
}
|
||
|
||
// 只有日期:默认上午九点(比零点有用 —— 零点的提醒没人看)
|
||
if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil {
|
||
return t.Add(9 * time.Hour), nil
|
||
}
|
||
|
||
return time.Time{}, fmt.Errorf(
|
||
"时间 %q 无法解析。请用 RFC3339 带时区,例如 2026-09-10T09:00:00+08:00;"+
|
||
"当前本机时间是 %s。不支持「明天」「下周三」这类相对表述 —— "+
|
||
"请先确认当前日期再算出具体时间戳。",
|
||
s, time.Now().Format(time.RFC3339))
|
||
}
|
||
|
||
// parseRecipients 把逗号分隔的地址串切成列表。
|
||
//
|
||
// 用字符串而不是数组参数:几个平台的工具参数 schema 对数组的支持不一致,
|
||
// 而逗号分隔在所有平台上都是一个普通 string。中英文逗号都接受 ——
|
||
// 中文输入法下打出「,」是常态,因为这个报错去改提示词不值得。
|
||
func parseRecipients(raw string) []string {
|
||
s := strings.TrimSpace(raw)
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
s = strings.ReplaceAll(s, ",", ",")
|
||
parts := strings.Split(s, ",")
|
||
out := make([]string, 0, len(parts))
|
||
seen := map[string]bool{}
|
||
for _, x := range parts {
|
||
x = strings.TrimSpace(x)
|
||
if x == "" || seen[x] {
|
||
continue
|
||
}
|
||
seen[x] = true
|
||
out = append(out, x)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func argString(args map[string]interface{}, key string) string {
|
||
if v, ok := args[key].(string); ok {
|
||
return v
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func argStringOr(args map[string]interface{}, key, fallback string) string {
|
||
if v := strings.TrimSpace(argString(args, key)); v != "" {
|
||
return v
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// argInt 读一个整数参数。
|
||
//
|
||
// JSON 数字解码成 float64,但模型也常传字符串("30")——
|
||
// 两种都收,否则「提前 30 分钟」会因为引号而静默失效。
|
||
func argInt(args map[string]interface{}, key string) (int, bool) {
|
||
switch v := args[key].(type) {
|
||
case float64:
|
||
return int(v), true
|
||
case int:
|
||
return v, true
|
||
case string:
|
||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
||
return n, true
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// textResult 包一个纯文本工具结果。
|
||
func textResult(text string) map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"content": []map[string]interface{}{{"type": "text", "text": text}},
|
||
}
|
||
}
|