Files
smartmate/backend/agent/node/quicknote_tool.go
Losita 4fbf9397d2 Version: 0.9.27.dev.260418
后端:
1. SSE 心跳保活——解决 Vite dev proxy 在 LLM thinking 静默期判 idle 断连
- api/agent.go:ChatAgent 新增 5 秒 heartbeat ticker,select 增加 heartbeat.C 分支,每 5 秒写入 SSE 注释行 : ping\n\n 并 Flush
- service/agentsvc/agent_newagent.go:graph 执行失败时增加 context.Canceled / requestCtx.Err() 判断,客户端断连只记 warn 不推 errChan 也不跑 fallback,消除 "错误通道已满" 日志噪音
2. 随口记工具(quick_note_create)接入新 Agent 链路
- agent/node/quicknote.go:parseOptionalDeadlineWithNow / quickNoteLocation 首字母大写导出,供新链路复用旧链路成熟的时间解析和时区能力
- agent/node/quicknote_tool.go:parseOptionalDeadline / quickNoteLocation 同步导出,补充调用目的注释
- newAgent/tools/quicknote.go:新增 QuickNoteToolHandler,实现新链路 quick_note_create 工具的参数校验、时间解析、写库调用
- newAgent/tools/registry.go:DefaultRegistryDeps 新增 QuickNote 字段;新增 RequiresScheduleState 方法和 scheduleFreeTools 集合;注册 quick_note_create 工具(不加入 writeTools,不走 confirm 确认)
- cmd/start.go:NewDefaultRegistryWithDeps 注入 QuickNote.CreateTask 闭包,捕获 taskRepo 实例写库
3. Execute 节点随口记 speak 清空 + 非 ScheduleState 工具支持
- newAgent/node/execute.go:新增非写工具 confirm→continue 自动降级逻辑;新增 quick_note_create speak 强制清空,收口统一交给 deliver,避免 execute + deliver 重复废话
- newAgent/node/execute.go:executeToolCall / executePendingTool 中 scheduleState nil 检查改为仅拦截 RequiresScheduleState 的工具;为不依赖 ScheduleState 的工具自动注入 _user_id 参数
- newAgent/prompt/execute.go:有 plan / ReAct 两套系统 prompt 中,"写操作"规则细化为"日程写操作";新增 quick_note_create 专属执行规则:speak 必须留空,收口由 deliver 完成,调用成功后可 continue 处理多任务
- newAgent/prompt/chat.go:execute 路由描述补充"记录任务/提醒"场景

前端:
1. Vite dev proxy SSE 透传配置
- vite.config.ts:/api 代理新增 configure 回调,设置 x-accel-buffering: no 和 cache-control: no-cache,禁用代理缓冲
2.SSE 流式处理修复
- AssistantPanel.vue:reasoning_content 守卫放宽,移除 !assistantMessage.content.trim() 外层条件,正文回流后仍允许追加 reasoning(工具调用摘要、阶段状态等),不再吞掉 execute/deliver 的 reasoning_content
- AssistantPanel.vue:流式完成后跳过 loadConversationMessages,避免 persistVisibleMessage 尚未落库时 merge 产生重复或丢失

仓库:无
2026-04-18 11:20:49 +08:00

590 lines
18 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agentnode
import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
agentshared "github.com/LoveLosita/smartflow/backend/agent/shared"
"github.com/cloudwego/eino/components/tool"
toolutils "github.com/cloudwego/eino/components/tool/utils"
"github.com/cloudwego/eino/schema"
)
const (
// ToolNameQuickNoteCreateTask 是“AI随口记”写库工具的稳定名称。
ToolNameQuickNoteCreateTask = "quick_note_create_task"
// ToolDescQuickNoteCreateTask 是给大模型看的工具职责说明。
ToolDescQuickNoteCreateTask = "把用户随口提到的事项落库为任务,支持可选截止时间与优先级"
)
var (
quickNoteDeadlineLayouts = []string{
time.RFC3339,
"2006-01-02T15:04",
"2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006/01/02 15:04:05",
"2006/01/02 15:04",
"2006.01.02 15:04:05",
"2006.01.02 15:04",
"2006-01-02",
"2006/01/02",
"2006.01.02",
}
quickNoteDateOnlyLayouts = map[string]struct{}{
"2006-01-02": {},
"2006/01/02": {},
"2006.01.02": {},
}
quickNoteClockHMRegex = regexp.MustCompile(`(\d{1,2})\s*[:]\s*(\d{1,2})`)
quickNoteClockCNRegex = regexp.MustCompile(`(\d{1,2})\s*点\s*(半|(\d{1,2})\s*分?)?`)
quickNoteYMDRegex = regexp.MustCompile(`(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`)
quickNoteMDRegex = regexp.MustCompile(`(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`)
quickNoteDateSepRegex = regexp.MustCompile(`\d{1,4}\s*[-/.]\s*\d{1,2}(\s*[-/.]\s*\d{1,2})?`)
quickNoteWeekdayRegex = regexp.MustCompile(`(下周|下星期|下礼拜|本周|这周|本星期|这星期|周|星期|礼拜)([一二三四五六日天])`)
quickNoteRelativeTokens = []string{
"今天", "今日", "今晚", "今早", "今晨", "明天", "明日", "后天", "大后天", "昨天", "昨日",
"早上", "早晨", "上午", "中午", "下午", "晚上", "傍晚", "夜里", "凌晨",
}
)
// QuickNoteToolDeps 描述随口记工具所需的外部依赖。
type QuickNoteToolDeps struct {
ResolveUserID func(ctx context.Context) (int, error)
CreateTask func(ctx context.Context, req QuickNoteCreateTaskRequest) (*QuickNoteCreateTaskResult, error)
}
func (d QuickNoteToolDeps) Validate() error {
if d.ResolveUserID == nil {
return errors.New("quick note tool deps: ResolveUserID is nil")
}
if d.CreateTask == nil {
return errors.New("quick note tool deps: CreateTask is nil")
}
return nil
}
// QuickNoteToolBundle 是随口记工具集合。
type QuickNoteToolBundle struct {
Tools []tool.BaseTool
ToolInfos []*schema.ToolInfo
}
// QuickNoteCreateTaskRequest 是工具层传给业务层的内部请求。
type QuickNoteCreateTaskRequest struct {
UserID int
Title string
PriorityGroup int
DeadlineAt *time.Time
UrgencyThresholdAt *time.Time
}
// QuickNoteCreateTaskResult 是业务层回给工具层的结构化结果。
type QuickNoteCreateTaskResult struct {
TaskID int
Title string
PriorityGroup int
DeadlineAt *time.Time
UrgencyThresholdAt *time.Time
}
// QuickNoteCreateTaskToolInput 是暴露给模型的工具入参。
type QuickNoteCreateTaskToolInput struct {
Title string `json:"title" jsonschema:"required,description=任务标题,简洁明确"`
// PriorityGroup 与 tasks.priority 保持一致,取值 1~4。
PriorityGroup int `json:"priority_group" jsonschema:"required,enum=1,enum=2,enum=3,enum=4,description=优先级分组(1重要且紧急,2重要不紧急,3简单不重要,4复杂不重要)"`
// DeadlineAt 支持绝对时间与常见中文相对时间。
DeadlineAt string `json:"deadline_at,omitempty" jsonschema:"description=可选截止时间支持RFC3339、yyyy-MM-dd HH:mm:ss、yyyy-MM-dd HH:mm 以及常见中文相对时间"`
// UrgencyThresholdAt 表示何时自动进入紧急象限。
UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty" jsonschema:"description=可选紧急分界时间支持与deadline_at相同格式"`
}
// QuickNoteCreateTaskToolOutput 是返回给模型的结构化结果。
type QuickNoteCreateTaskToolOutput struct {
TaskID int `json:"task_id"`
Title string `json:"title"`
PriorityGroup int `json:"priority_group"`
PriorityLabel string `json:"priority_label"`
DeadlineAt string `json:"deadline_at,omitempty"`
Message string `json:"message"`
}
// BuildQuickNoteToolBundle 构建随口记工具包。
func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*QuickNoteToolBundle, error) {
if err := deps.Validate(); err != nil {
return nil, err
}
createTaskTool, err := toolutils.InferTool(
ToolNameQuickNoteCreateTask,
ToolDescQuickNoteCreateTask,
func(ctx context.Context, input *QuickNoteCreateTaskToolInput) (*QuickNoteCreateTaskToolOutput, error) {
if input == nil {
return nil, errors.New("工具参数不能为空")
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("title 不能为空")
}
if !agentmodel.IsValidTaskPriority(input.PriorityGroup) {
return nil, fmt.Errorf("priority_group=%d 非法,必须在 1~4", input.PriorityGroup)
}
deadline, err := ParseOptionalDeadline(input.DeadlineAt)
if err != nil {
return nil, err
}
urgencyThresholdAt, err := ParseOptionalDeadline(input.UrgencyThresholdAt)
if err != nil {
return nil, err
}
userID, err := deps.ResolveUserID(ctx)
if err != nil {
return nil, fmt.Errorf("解析用户身份失败: %w", err)
}
if userID <= 0 {
return nil, fmt.Errorf("非法 user_id=%d", userID)
}
result, err := deps.CreateTask(ctx, QuickNoteCreateTaskRequest{
UserID: userID,
Title: title,
PriorityGroup: input.PriorityGroup,
DeadlineAt: deadline,
UrgencyThresholdAt: urgencyThresholdAt,
})
if err != nil {
return nil, err
}
if result == nil || result.TaskID <= 0 {
return nil, errors.New("写入任务后返回结果异常")
}
finalTitle := title
if strings.TrimSpace(result.Title) != "" {
finalTitle = strings.TrimSpace(result.Title)
}
finalPriority := input.PriorityGroup
if agentmodel.IsValidTaskPriority(result.PriorityGroup) {
finalPriority = result.PriorityGroup
}
deadlineStr := ""
if result.DeadlineAt != nil {
deadlineStr = result.DeadlineAt.In(QuickNoteLocation()).Format(time.RFC3339)
} else if deadline != nil {
deadlineStr = deadline.In(QuickNoteLocation()).Format(time.RFC3339)
}
return &QuickNoteCreateTaskToolOutput{
TaskID: result.TaskID,
Title: finalTitle,
PriorityGroup: finalPriority,
PriorityLabel: agentmodel.PriorityLabelCN(finalPriority),
DeadlineAt: deadlineStr,
Message: fmt.Sprintf("已记录:%s%s", finalTitle, agentmodel.PriorityLabelCN(finalPriority)),
}, nil
},
)
if err != nil {
return nil, fmt.Errorf("构建随口记工具失败: %w", err)
}
tools := []tool.BaseTool{createTaskTool}
infos, err := collectToolInfos(ctx, tools)
if err != nil {
return nil, err
}
return &QuickNoteToolBundle{
Tools: tools,
ToolInfos: infos,
}, nil
}
// GetInvokableToolByName 通过工具名提取可执行工具实例。
func GetInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.InvokableTool, error) {
if bundle == nil {
return nil, errors.New("tool bundle is nil")
}
return getInvokableToolByName(bundle.Tools, bundle.ToolInfos, name)
}
// ParseOptionalDeadline 解析工具输入中的可选截止时间。
// 调用目的:新链路 quick_note_create 工具复用旧链路成熟的时间解析能力,支持中文相对时间。
func ParseOptionalDeadline(raw string) (*time.Time, error) {
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, nil
}
deadline, hasHint, err := parseOptionalDeadlineFromText(value, quickNoteNowToMinute())
if err != nil {
return nil, err
}
if deadline == nil {
if !hasHint {
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
return nil, fmt.Errorf("deadline_at 无法解析: %s", value)
}
return deadline, nil
}
// ParseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。
// 调用目的:旧链路 intent/priority 节点在已知 now 基准下解析时间,供新链路复用。
func ParseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) {
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, nil
}
deadline, _, err := parseOptionalDeadlineFromText(value, now)
if err != nil {
return nil, err
}
if deadline == nil {
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
return deadline, nil
}
// parseOptionalDeadlineFromUserInput 是“用户原句解析”的宽松入口。
func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time, bool, error) {
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, false, nil
}
deadline, hasHint, err := parseOptionalDeadlineFromText(value, now)
if err != nil {
if hasHint {
return nil, true, err
}
return nil, false, nil
}
if deadline == nil {
if hasHint {
return nil, true, fmt.Errorf("deadline_at 无法解析: %s", value)
}
return nil, false, nil
}
return deadline, true, nil
}
// parseOptionalDeadlineFromText 是内部通用时间解析器。
func parseOptionalDeadlineFromText(value string, now time.Time) (*time.Time, bool, error) {
if strings.TrimSpace(value) == "" {
return nil, false, nil
}
loc := QuickNoteLocation()
now = now.In(loc)
hasHint := hasDeadlineHint(value)
if abs, ok := tryParseAbsoluteDeadline(value, loc); ok {
return abs, true, nil
}
if rel, recognized, err := tryParseRelativeDeadline(value, now, loc); recognized {
if err != nil {
return nil, true, err
}
return rel, true, nil
}
if hasHint {
return nil, true, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
return nil, false, nil
}
func normalizeDeadlineInput(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
replacer := strings.NewReplacer(
"", ":",
"", ",",
"。", ".",
" ", " ",
)
return strings.TrimSpace(replacer.Replace(trimmed))
}
func hasDeadlineHint(value string) bool {
if quickNoteClockHMRegex.MatchString(value) ||
quickNoteClockCNRegex.MatchString(value) ||
quickNoteYMDRegex.MatchString(value) ||
quickNoteMDRegex.MatchString(value) ||
quickNoteDateSepRegex.MatchString(value) ||
quickNoteWeekdayRegex.MatchString(value) {
return true
}
for _, token := range quickNoteRelativeTokens {
if strings.Contains(value, token) {
return true
}
}
return false
}
func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, bool) {
for _, layout := range quickNoteDeadlineLayouts {
var (
parsed time.Time
err error
)
if layout == time.RFC3339 {
parsed, err = time.Parse(layout, value)
if err == nil {
parsed = parsed.In(loc)
}
} else {
parsed, err = time.ParseInLocation(layout, value, loc)
}
if err != nil {
continue
}
if _, dateOnly := quickNoteDateOnlyLayouts[layout]; dateOnly {
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 0, 0, loc)
} else {
parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc)
}
return &parsed, true
}
return nil, false
}
func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (*time.Time, bool, error) {
baseDate, recognized := inferBaseDate(value, now, loc)
if !recognized {
return nil, false, nil
}
hour, minute, hasExplicitClock, err := extractClock(value)
if err != nil {
return nil, true, err
}
if !hasExplicitClock {
hour, minute = defaultClockByHint(value)
}
deadline := time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), hour, minute, 0, 0, loc)
return &deadline, true, nil
}
func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, bool) {
if matched := quickNoteYMDRegex.FindStringSubmatch(value); len(matched) == 4 {
year, _ := strconv.Atoi(matched[1])
month, _ := strconv.Atoi(matched[2])
day, _ := strconv.Atoi(matched[3])
if isValidDate(year, month, day) {
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc), true
}
}
if matched := quickNoteMDRegex.FindStringSubmatch(value); len(matched) == 3 {
month, _ := strconv.Atoi(matched[1])
day, _ := strconv.Atoi(matched[2])
year := now.Year()
if !isValidDate(year, month, day) {
return time.Time{}, false
}
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc)
if candidate.Before(startOfDay(now)) {
year++
if !isValidDate(year, month, day) {
return time.Time{}, false
}
candidate = time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc)
}
return candidate, true
}
if matched := quickNoteWeekdayRegex.FindStringSubmatch(value); len(matched) == 3 {
prefix := matched[1]
target, ok := toWeekday(matched[2])
if ok {
return resolveWeekdayDate(now, prefix, target), true
}
}
today := startOfDay(now)
switch {
case strings.Contains(value, "大后天"):
return today.AddDate(0, 0, 3), true
case strings.Contains(value, "后天"):
return today.AddDate(0, 0, 2), true
case strings.Contains(value, "明天") || strings.Contains(value, "明日"):
return today.AddDate(0, 0, 1), true
case strings.Contains(value, "今天") || strings.Contains(value, "今日") || strings.Contains(value, "今晚") || strings.Contains(value, "今早") || strings.Contains(value, "今晨"):
return today, true
case strings.Contains(value, "昨天") || strings.Contains(value, "昨日"):
return today.AddDate(0, 0, -1), true
default:
return time.Time{}, false
}
}
func extractClock(value string) (int, int, bool, error) {
hour := 0
minute := 0
hasClock := false
if matched := quickNoteClockHMRegex.FindStringSubmatch(value); len(matched) == 3 {
h, errH := strconv.Atoi(matched[1])
m, errM := strconv.Atoi(matched[2])
if errH != nil || errM != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
}
hour = h
minute = m
hasClock = true
} else if matched := quickNoteClockCNRegex.FindStringSubmatch(value); len(matched) >= 2 {
h, errH := strconv.Atoi(matched[1])
if errH != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
}
hour = h
minute = 0
hasClock = true
if len(matched) >= 3 {
if matched[2] == "半" {
minute = 30
} else if len(matched) >= 4 && strings.TrimSpace(matched[3]) != "" {
m, errM := strconv.Atoi(strings.TrimSpace(matched[3]))
if errM != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
}
minute = m
}
}
}
if !hasClock {
return 0, 0, false, nil
}
if isPMHint(value) && hour < 12 {
hour += 12
}
if isNoonHint(value) && hour >= 1 && hour <= 10 {
hour += 12
}
if strings.Contains(value, "凌晨") && hour == 12 {
hour = 0
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return 0, 0, true, fmt.Errorf("deadline_at 时间超出范围: %s", value)
}
return hour, minute, true, nil
}
func defaultClockByHint(value string) (int, int) {
switch {
case strings.Contains(value, "凌晨"):
return 1, 0
case strings.Contains(value, "早上") || strings.Contains(value, "早晨") || strings.Contains(value, "上午") || strings.Contains(value, "今早") || strings.Contains(value, "明早"):
return 9, 0
case strings.Contains(value, "中午"):
return 12, 0
case strings.Contains(value, "下午"):
return 15, 0
case strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚") || strings.Contains(value, "夜里"):
return 20, 0
default:
return 23, 59
}
}
func isPMHint(value string) bool {
return strings.Contains(value, "下午") || strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚")
}
func isNoonHint(value string) bool {
return strings.Contains(value, "中午")
}
func startOfDay(t time.Time) time.Time {
loc := t.Location()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
}
func isValidDate(year, month, day int) bool {
if month < 1 || month > 12 || day < 1 || day > 31 {
return false
}
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
return candidate.Year() == year && int(candidate.Month()) == month && candidate.Day() == day
}
func toWeekday(chinese string) (time.Weekday, bool) {
switch chinese {
case "一":
return time.Monday, true
case "二":
return time.Tuesday, true
case "三":
return time.Wednesday, true
case "四":
return time.Thursday, true
case "五":
return time.Friday, true
case "六":
return time.Saturday, true
case "日", "天":
return time.Sunday, true
default:
return time.Sunday, false
}
}
func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.Time {
today := startOfDay(now)
weekdayOffset := (int(today.Weekday()) + 6) % 7
weekStart := today.AddDate(0, 0, -weekdayOffset)
targetOffset := (int(target) + 6) % 7
candidateThisWeek := weekStart.AddDate(0, 0, targetOffset)
switch {
case strings.HasPrefix(prefix, "下"):
return candidateThisWeek.AddDate(0, 0, 7)
case strings.HasPrefix(prefix, "本"), strings.HasPrefix(prefix, "这"):
return candidateThisWeek
default:
if candidateThisWeek.Before(today) {
return candidateThisWeek.AddDate(0, 0, 7)
}
return candidateThisWeek
}
}
// QuickNoteLocation 返回随口记使用的时区Asia/Shanghai
// 调用目的:新链路 quick_note_create 工具格式化时间输出时复用。
func QuickNoteLocation() *time.Location {
loc, err := time.LoadLocation(agentmodel.QuickNoteTimezoneName)
if err != nil {
return time.Local
}
return loc
}
func quickNoteNowToMinute() time.Time {
return agentshared.NowToMinute()
}
func formatQuickNoteTimeToMinute(t time.Time) string {
return agentshared.FormatMinute(t.In(QuickNoteLocation()))
}