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 产生重复或丢失 仓库:无
This commit is contained in:
@@ -215,7 +215,7 @@ func (n *QuickNoteNodes) Intent(ctx context.Context, st *agentmodel.QuickNoteSta
|
||||
// 2.2 先尝试吃模型返回的 deadline_at,用于减少后续重复推理。
|
||||
st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt)
|
||||
if st.ExtractedDeadlineText != "" {
|
||||
if deadline, deadlineErr := parseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil {
|
||||
if deadline, deadlineErr := ParseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil {
|
||||
st.ExtractedDeadline = deadline
|
||||
}
|
||||
}
|
||||
@@ -296,7 +296,7 @@ func (n *QuickNoteNodes) Priority(ctx context.Context, st *agentmodel.QuickNoteS
|
||||
st.ExtractedPriority = parsed.PriorityGroup
|
||||
st.ExtractedPriorityReason = strings.TrimSpace(parsed.Reason)
|
||||
if strings.TrimSpace(parsed.UrgencyThresholdAt) != "" {
|
||||
urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(strings.TrimSpace(parsed.UrgencyThresholdAt), st.RequestNow)
|
||||
urgencyThreshold, thresholdErr := ParseOptionalDeadlineWithNow(strings.TrimSpace(parsed.UrgencyThresholdAt), st.RequestNow)
|
||||
if thresholdErr == nil {
|
||||
st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, st.ExtractedDeadline)
|
||||
}
|
||||
@@ -328,11 +328,11 @@ func (n *QuickNoteNodes) Persist(ctx context.Context, st *agentmodel.QuickNoteSt
|
||||
|
||||
deadlineText := ""
|
||||
if st.ExtractedDeadline != nil {
|
||||
deadlineText = st.ExtractedDeadline.In(quickNoteLocation()).Format(time.RFC3339)
|
||||
deadlineText = st.ExtractedDeadline.In(QuickNoteLocation()).Format(time.RFC3339)
|
||||
}
|
||||
urgencyThresholdText := ""
|
||||
if st.ExtractedUrgencyThreshold != nil {
|
||||
urgencyThresholdText = st.ExtractedUrgencyThreshold.In(quickNoteLocation()).Format(time.RFC3339)
|
||||
urgencyThresholdText = st.ExtractedUrgencyThreshold.In(QuickNoteLocation()).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
toolInput := QuickNoteCreateTaskToolInput{
|
||||
@@ -430,12 +430,12 @@ func planQuickNoteInSingleCall(ctx context.Context, chatModel *ark.ChatModel, no
|
||||
}
|
||||
}
|
||||
if result.DeadlineText != "" {
|
||||
if deadline, deadlineErr := parseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil {
|
||||
if deadline, deadlineErr := ParseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil {
|
||||
result.Deadline = deadline
|
||||
}
|
||||
}
|
||||
if result.UrgencyThresholdText != "" {
|
||||
if urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(result.UrgencyThresholdText, now); thresholdErr == nil {
|
||||
if urgencyThreshold, thresholdErr := ParseOptionalDeadlineWithNow(result.UrgencyThresholdText, now); thresholdErr == nil {
|
||||
result.UrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, result.Deadline)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,11 +138,11 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
|
||||
return nil, fmt.Errorf("priority_group=%d 非法,必须在 1~4", input.PriorityGroup)
|
||||
}
|
||||
|
||||
deadline, err := parseOptionalDeadline(input.DeadlineAt)
|
||||
deadline, err := ParseOptionalDeadline(input.DeadlineAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
urgencyThresholdAt, err := parseOptionalDeadline(input.UrgencyThresholdAt)
|
||||
urgencyThresholdAt, err := ParseOptionalDeadline(input.UrgencyThresholdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -180,9 +180,9 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
|
||||
|
||||
deadlineStr := ""
|
||||
if result.DeadlineAt != nil {
|
||||
deadlineStr = result.DeadlineAt.In(quickNoteLocation()).Format(time.RFC3339)
|
||||
deadlineStr = result.DeadlineAt.In(QuickNoteLocation()).Format(time.RFC3339)
|
||||
} else if deadline != nil {
|
||||
deadlineStr = deadline.In(quickNoteLocation()).Format(time.RFC3339)
|
||||
deadlineStr = deadline.In(QuickNoteLocation()).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return &QuickNoteCreateTaskToolOutput{
|
||||
@@ -219,8 +219,9 @@ func GetInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.Invo
|
||||
return getInvokableToolByName(bundle.Tools, bundle.ToolInfos, name)
|
||||
}
|
||||
|
||||
// parseOptionalDeadline 解析工具输入中的可选截止时间。
|
||||
func parseOptionalDeadline(raw string) (*time.Time, error) {
|
||||
// ParseOptionalDeadline 解析工具输入中的可选截止时间。
|
||||
// 调用目的:新链路 quick_note_create 工具复用旧链路成熟的时间解析能力,支持中文相对时间。
|
||||
func ParseOptionalDeadline(raw string) (*time.Time, error) {
|
||||
value := normalizeDeadlineInput(raw)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
@@ -239,8 +240,9 @@ func parseOptionalDeadline(raw string) (*time.Time, error) {
|
||||
return deadline, nil
|
||||
}
|
||||
|
||||
// parseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。
|
||||
func parseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) {
|
||||
// ParseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。
|
||||
// 调用目的:旧链路 intent/priority 节点在已知 now 基准下解析时间,供新链路复用。
|
||||
func ParseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) {
|
||||
value := normalizeDeadlineInput(raw)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
@@ -285,7 +287,7 @@ func parseOptionalDeadlineFromText(value string, now time.Time) (*time.Time, boo
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
loc := quickNoteLocation()
|
||||
loc := QuickNoteLocation()
|
||||
now = now.In(loc)
|
||||
hasHint := hasDeadlineHint(value)
|
||||
|
||||
@@ -568,7 +570,9 @@ func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.
|
||||
}
|
||||
}
|
||||
|
||||
func quickNoteLocation() *time.Location {
|
||||
// QuickNoteLocation 返回随口记使用的时区(Asia/Shanghai)。
|
||||
// 调用目的:新链路 quick_note_create 工具格式化时间输出时复用。
|
||||
func QuickNoteLocation() *time.Location {
|
||||
loc, err := time.LoadLocation(agentmodel.QuickNoteTimezoneName)
|
||||
if err != nil {
|
||||
return time.Local
|
||||
@@ -581,5 +585,5 @@ func quickNoteNowToMinute() time.Time {
|
||||
}
|
||||
|
||||
func formatQuickNoteTimeToMinute(t time.Time) string {
|
||||
return agentshared.FormatMinute(t.In(quickNoteLocation()))
|
||||
return agentshared.FormatMinute(t.In(QuickNoteLocation()))
|
||||
}
|
||||
|
||||
@@ -65,6 +65,11 @@ func (api *AgentHandler) ChatAgent(c *gin.Context) {
|
||||
outChan, errChan := api.svc.AgentChat(c.Request.Context(), req.Message, req.Thinking, req.Model, userID, conversationID, req.Extra)
|
||||
|
||||
// 4) 转发 SSE 流
|
||||
// 4.0 心跳保活:LLM thinking 静默期可达 10+ 秒,Vite dev proxy 会判 idle 切断连接。
|
||||
// 每 5 秒发送 SSE 标准注释行 ": ping\n\n",前端 JSON.parse 失败后丢弃,不污染 UI。
|
||||
heartbeat := time.NewTicker(5 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
c.Stream(func(w io.Writer) bool {
|
||||
select {
|
||||
case err, ok := <-errChan:
|
||||
@@ -97,6 +102,11 @@ func (api *AgentHandler) ChatAgent(c *gin.Context) {
|
||||
return true
|
||||
case <-c.Request.Context().Done():
|
||||
return false
|
||||
// 心跳分支:LLM thinking 静默期每 5 秒推送 SSE 注释行,防止代理判 idle 断连。
|
||||
case <-heartbeat.C:
|
||||
io.WriteString(w, ": ping\n\n")
|
||||
c.Writer.(http.Flusher).Flush()
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/api"
|
||||
"github.com/LoveLosita/smartflow/backend/dao"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/LoveLosita/smartflow/backend/memory"
|
||||
memoryobserve "github.com/LoveLosita/smartflow/backend/memory/observe"
|
||||
"github.com/LoveLosita/smartflow/backend/middleware"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
newagentconv "github.com/LoveLosita/smartflow/backend/newAgent/conv"
|
||||
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/web"
|
||||
@@ -179,6 +181,22 @@ func Start() {
|
||||
agentService.SetToolRegistry(newagenttools.NewDefaultRegistryWithDeps(newagenttools.DefaultRegistryDeps{
|
||||
RAGRuntime: ragRuntime,
|
||||
WebSearchProvider: webSearchProvider,
|
||||
QuickNote: newagenttools.QuickNoteDeps{
|
||||
CreateTask: func(userID int, title string, priorityGroup int, deadlineAt *time.Time) (int, error) {
|
||||
// 调用目的:随口记工具通过此闭包写库,捕获 start 层 taskRepo 实例。
|
||||
created, err := taskRepo.AddTask(&model.Task{
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Priority: priorityGroup,
|
||||
IsCompleted: false,
|
||||
DeadlineAt: deadlineAt,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return created.ID, nil
|
||||
},
|
||||
},
|
||||
}))
|
||||
agentService.SetScheduleProvider(newagentconv.NewScheduleProvider(scheduleRepo, taskClassRepo))
|
||||
agentService.SetSchedulePersistor(newagentconv.NewSchedulePersistorAdapter(manager))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -295,6 +295,25 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||
// speak 后处理:补列表序号换行 + 末尾加 \n 防止连续 speak 在前端粘连。
|
||||
decision.Speak = normalizeSpeak(decision.Speak) // 末尾已含 \n
|
||||
|
||||
// 非写工具的 confirm 动作自动降级为 continue。
|
||||
// 调用目的:quick_note_create 等非写工具不应走确认卡片流程;
|
||||
// 即使 LLM 误输出 action=confirm,也在此处强制修正,
|
||||
// 确保 speak 正常推流和持久化,不会因 confirm 卡片跳过 persistVisibleAssistantMessage。
|
||||
if decision.Action == newagentmodel.ExecuteActionConfirm &&
|
||||
decision.ToolCall != nil &&
|
||||
input.ToolRegistry != nil &&
|
||||
!input.ToolRegistry.IsWriteTool(decision.ToolCall.Name) {
|
||||
decision.Action = newagentmodel.ExecuteActionContinue
|
||||
}
|
||||
|
||||
// 随口记工具 speak 清空:
|
||||
// 1. quick_note_create 是轻量记录操作,不需要 execute 阶段向用户输出任何文案;
|
||||
// 2. 收口统一由 deliver 阶段完成,避免 execute + deliver 重复输出导致废话;
|
||||
// 3. 后端强制清空兜底,即使 LLM 误填了 speak 也不会推流到前端。
|
||||
if decision.ToolCall != nil && strings.EqualFold(decision.ToolCall.Name, "quick_note_create") {
|
||||
decision.Speak = ""
|
||||
}
|
||||
|
||||
// 自省校验:next_plan / done 必须附带 goal_check,否则不推进,追加修正让 LLM 重试。
|
||||
if decision.Action == newagentmodel.ExecuteActionNextPlan ||
|
||||
decision.Action == newagentmodel.ExecuteActionDone {
|
||||
@@ -1364,8 +1383,8 @@ func executeToolCall(
|
||||
if registry == nil {
|
||||
return fmt.Errorf("工具注册表未注入")
|
||||
}
|
||||
if scheduleState == nil {
|
||||
return fmt.Errorf("日程状态未加载,无法执行工具")
|
||||
if scheduleState == nil && registry.RequiresScheduleState(toolName) {
|
||||
return fmt.Errorf("日程状态未加载,无法执行工具 %q", toolName)
|
||||
}
|
||||
if !registry.HasTool(toolName) {
|
||||
// LLM 拼错或编造了工具名,走 correction 机制给重试机会,而非直接 fatal。
|
||||
@@ -1410,6 +1429,13 @@ func executeToolCall(
|
||||
}
|
||||
|
||||
beforeDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
// 调用目的:为不依赖 ScheduleState 的工具注入用户身份,工具层通过 args["_user_id"] 提取。
|
||||
if !registry.RequiresScheduleState(toolName) {
|
||||
if toolCall.Arguments == nil {
|
||||
toolCall.Arguments = make(map[string]any)
|
||||
}
|
||||
toolCall.Arguments["_user_id"] = flowState.UserID
|
||||
}
|
||||
result := registry.Execute(scheduleState, toolName, toolCall.Arguments)
|
||||
afterDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
log.Printf(
|
||||
@@ -1514,6 +1540,13 @@ func executePendingTool(
|
||||
|
||||
// 4. 执行工具。
|
||||
beforeDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
// 调用目的:为不依赖 ScheduleState 的工具注入用户身份,工具层通过 args["_user_id"] 提取。
|
||||
if !registry.RequiresScheduleState(pending.ToolName) {
|
||||
if args == nil {
|
||||
args = make(map[string]any)
|
||||
}
|
||||
args["_user_id"] = flowState.UserID
|
||||
}
|
||||
result := registry.Execute(scheduleState, pending.ToolName, args)
|
||||
afterDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||
log.Printf(
|
||||
|
||||
@@ -14,7 +14,7 @@ const chatRoutingSystemPrompt = `
|
||||
|
||||
路由规则:
|
||||
- direct_reply:纯闲聊、简单问答、轻量生活建议、打招呼、感谢等不需要工具、也不需要长链路思考的请求。控制码后直接输出完整回复。
|
||||
- execute:需要用工具处理的请求(查询日程、移动课程、排课等),但不需要先制定计划。控制码后输出简短确认。
|
||||
- execute:需要用工具处理的请求(记录任务/提醒、查询日程、移动课程、排课等),但不需要先制定计划。控制码后输出简短确认。
|
||||
- deep_answer:复杂问题但不需要工具(如分析建议、知识解释、方案比较、深度讨论等),需要深度思考后回答。控制码后不要输出任何占位过渡语,后端会直接进入第二次正式回答。
|
||||
- plan:用户明确要求先制定计划,或涉及多阶段复杂规划。控制码后输出简短确认。
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const executeSystemPromptWithPlan = `
|
||||
你可以做什么:
|
||||
1. 只围绕当前步骤推进,先读后写,逐步完成当前步骤。
|
||||
2. 可调用读工具补充事实,再决定下一步。
|
||||
3. 需要写操作时输出 action=confirm 并附带 tool_call,等待用户确认。
|
||||
3. 日程写操作时输出 action=confirm 并附带 tool_call,等待用户确认。quick_note_create 不需要确认,用 action=continue;若信息足够,必须显式填写 priority_group,若信息不足则先 ask_user,不要盲猜。
|
||||
4. 若用户给出了"二次微调方向"(如负载均衡、某天减负、某类任务后移),优先围绕该方向推进,并在 goal_check 说明满足情况。
|
||||
5. 只有在用户明确允许打乱顺序时,才可使用 min_context_switch 做重排。
|
||||
6. 多任务微调时默认走队列链路:query_target_tasks(enqueue=true) → queue_pop_head → query_available_slots → queue_apply_head_move / queue_skip_head。
|
||||
@@ -38,11 +38,12 @@ const executeSystemPromptWithPlan = `
|
||||
执行规则:
|
||||
1. 只输出严格 JSON,不要输出 markdown,不要在 JSON 外补充文本。
|
||||
2. 读操作:action=continue + tool_call。
|
||||
3. 写操作:action=confirm + tool_call。
|
||||
4. 缺关键上下文且无法通过工具补齐:action=ask_user。
|
||||
5. 仅当当前步骤完成时输出 action=next_plan,并在 goal_check 对照 done_when 给出证据。
|
||||
6. 仅当整体任务完成时输出 action=done,并在 goal_check 总结完成证据。
|
||||
7. 流程应正式终止时输出 action=abort。`
|
||||
3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switch):action=confirm + tool_call。
|
||||
4. quick_note_create(记录任务/提醒):若信息足够,action=continue + tool_call,并显式填写 priority_group;若信息不足且无法可靠推断,action=ask_user 先追问。quick_note_create 调用时和调用后 speak 必须留空,收口由 deliver 阶段统一完成;调用成功后可继续(done/next_plan/continue)处理其他任务,但不要为 quick_note_create 本身补充说明。
|
||||
5. 缺关键上下文且无法通过工具补齐:action=ask_user。
|
||||
6. 仅当当前步骤完成时输出 action=next_plan,并在 goal_check 对照 done_when 给出证据。
|
||||
7. 仅当整体任务完成时输出 action=done,并在 goal_check 总结完成证据。
|
||||
8. 流程应正式终止时输出 action=abort。`
|
||||
|
||||
const executeSystemPromptReAct = `
|
||||
你是 SmartMate 的执行器,当前处于自由执行模式(无预定义 plan 步骤)。
|
||||
@@ -57,7 +58,7 @@ const executeSystemPromptReAct = `
|
||||
1. 你可以基于用户给定的二次微调方向,对 suggested 做定向微调。
|
||||
2. existing 属于已安排事实层,可用于冲突判断和参考,不作为 move/batch_move/spread_even 的目标。
|
||||
3. 你可以先调用读工具补充必要事实(例如 get_overview/query_target_tasks/query_available_slots/get_task_info)。
|
||||
4. 你可以在需要改动时提出 confirm(move/swap/unplace/batch_move/spread_even)。
|
||||
4. 你可以在需要日程写操作时提出 confirm(move/swap/unplace/batch_move/spread_even)。quick_note_create 不需要确认,用 action=continue;若信息足够,必须显式填写 priority_group,若信息不足则先 ask_user。
|
||||
5. 只有用户明确允许打乱顺序时,才可使用 min_context_switch。
|
||||
6. 多任务处理默认使用队列链路:先 query_target_tasks(enqueue=true) 入队,再 queue_pop_head 逐项处理。
|
||||
|
||||
@@ -79,10 +80,11 @@ const executeSystemPromptReAct = `
|
||||
执行规则:
|
||||
1. 只输出严格 JSON,不要输出 markdown,不要在 JSON 外补充文本。
|
||||
2. 读操作:action=continue + tool_call。
|
||||
3. 写操作:action=confirm + tool_call。
|
||||
4. 缺关键上下文且无法通过工具补齐:action=ask_user。
|
||||
5. 任务完成:action=done,并在 goal_check 总结完成证据。
|
||||
6. 流程应正式终止:action=abort。`
|
||||
3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switch):action=confirm + tool_call。
|
||||
4. quick_note_create(记录任务/提醒):若信息足够,action=continue + tool_call,并显式填写 priority_group;若信息不足且无法可靠推断,action=ask_user 先追问。quick_note_create 调用时和调用后 speak 必须留空,收口由 deliver 阶段统一完成;调用成功后可继续(done/next_plan/continue)处理其他任务,但不要为 quick_note_create 本身补充说明。
|
||||
5. 缺关键上下文且无法通过工具补齐:action=ask_user。
|
||||
6. 任务完成:action=done,并在 goal_check 总结完成证据。
|
||||
7. 流程应正式终止:action=abort。`
|
||||
|
||||
// BuildExecuteSystemPrompt 返回执行阶段系统提示词(有 plan 模式)。
|
||||
func BuildExecuteSystemPrompt() string {
|
||||
|
||||
140
backend/newAgent/tools/quicknote.go
Normal file
140
backend/newAgent/tools/quicknote.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package newagenttools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent/model"
|
||||
agentnode "github.com/LoveLosita/smartflow/backend/agent/node"
|
||||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||
)
|
||||
|
||||
// QuickNoteDeps 描述随口记工具所需的外部依赖。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. CreateTask 负责真正写库,工具层不直接依赖 DAO;
|
||||
// 2. UserID 由 execute 节点通过 args["_user_id"] 注入,工具层不自行解析会话身份。
|
||||
type QuickNoteDeps struct {
|
||||
// CreateTask 将解析后的任务字段写入数据库。
|
||||
// 调用目的:解耦工具层与 DAO 层,方便测试和替换。
|
||||
CreateTask func(userID int, title string, priorityGroup int, deadlineAt *time.Time) (taskID int, err error)
|
||||
}
|
||||
|
||||
// QuickNoteCreateResult 是 quick_note_create 工具的结构化返回。
|
||||
type QuickNoteCreateResult struct {
|
||||
TaskID int `json:"task_id"`
|
||||
Title string `json:"title"`
|
||||
PriorityLabel string `json:"priority_label"`
|
||||
DeadlineAt string `json:"deadline_at,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// quickNoteFallbackPriority 根据截止时间推断默认优先级。
|
||||
//
|
||||
// 推断规则:
|
||||
// 1. 有截止时间且距今 ≤48h → 1(重要且紧急);
|
||||
// 2. 有截止时间且距今 >48h → 2(重要不紧急);
|
||||
// 3. 无截止时间 → 3(简单不重要)。
|
||||
func quickNoteFallbackPriority(deadline *time.Time) int {
|
||||
if deadline != nil {
|
||||
if time.Until(*deadline) <= 48*time.Hour {
|
||||
return agentmodel.QuickNotePriorityImportantUrgent
|
||||
}
|
||||
return agentmodel.QuickNotePriorityImportantNotUrgent
|
||||
}
|
||||
return agentmodel.QuickNotePrioritySimpleNotImportant
|
||||
}
|
||||
|
||||
// NewQuickNoteToolHandler 创建 quick_note_create 工具的 handler 闭包。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责参数校验、时间解析、优先级推断、调 deps 写库、组装返回;
|
||||
// 2. 不负责 LLM 交互和会话管理。
|
||||
// 3. state 参数忽略——随口记不需要 ScheduleState,已注册到 scheduleFreeTools。
|
||||
func NewQuickNoteToolHandler(deps QuickNoteDeps) ToolHandler {
|
||||
return func(state *schedule.ScheduleState, args map[string]any) string {
|
||||
_ = state
|
||||
|
||||
// 1. 提取 _user_id(由 execute 节点在调用前注入)。
|
||||
userID := 0
|
||||
if uid, ok := args["_user_id"].(int); ok {
|
||||
userID = uid
|
||||
}
|
||||
if userID <= 0 {
|
||||
return "工具调用失败:无法识别用户身份。"
|
||||
}
|
||||
|
||||
// 2. 提取必填参数 title。
|
||||
title := ""
|
||||
if t, ok := args["title"].(string); ok {
|
||||
title = strings.TrimSpace(t)
|
||||
}
|
||||
if title == "" {
|
||||
return "工具调用失败:缺少必填参数 title(任务标题)。"
|
||||
}
|
||||
|
||||
// 3. 提取可选参数 deadline_at,复用旧链路时间解析能力。
|
||||
var deadline *time.Time
|
||||
if raw, ok := args["deadline_at"].(string); ok {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw != "" {
|
||||
// 调用目的:复用旧链路成熟的中文相对时间解析器,支持"明天下午3点"等格式。
|
||||
parsed, err := agentnode.ParseOptionalDeadline(raw)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("工具调用失败:截止时间格式无法解析(%s)。支持格式:2026-04-20 18:00、明天下午3点、下周一上午9点。", err)
|
||||
}
|
||||
deadline = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 提取可选参数 priority_group;未提供时按截止时间自动推断。
|
||||
priorityGroup := 0
|
||||
if pg, ok := args["priority_group"].(float64); ok {
|
||||
priorityGroup = int(pg)
|
||||
}
|
||||
if !agentmodel.IsValidTaskPriority(priorityGroup) {
|
||||
priorityGroup = quickNoteFallbackPriority(deadline)
|
||||
}
|
||||
|
||||
// 5. 调用依赖写库。
|
||||
taskID, err := deps.CreateTask(userID, title, priorityGroup, deadline)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("工具调用失败:写入任务时出错(%s)。", err)
|
||||
}
|
||||
if taskID <= 0 {
|
||||
return "工具调用失败:写入任务后未返回有效 task_id。"
|
||||
}
|
||||
|
||||
// 6. 组装结构化返回,包含 banter 提示引导 LLM 自然生成调侃。
|
||||
priorityLabel := agentmodel.PriorityLabelCN(priorityGroup)
|
||||
deadlineStr := ""
|
||||
if deadline != nil {
|
||||
deadlineStr = deadline.In(agentnode.QuickNoteLocation()).Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
result := QuickNoteCreateResult{
|
||||
TaskID: taskID,
|
||||
Title: title,
|
||||
PriorityLabel: priorityLabel,
|
||||
DeadlineAt: deadlineStr,
|
||||
}
|
||||
|
||||
// 6.1 成功事实 + banter 提示:通过工具返回值引导 ReAct LLM 在 speak 中自然加入轻松跟进。
|
||||
if deadlineStr != "" {
|
||||
result.Message = fmt.Sprintf("已记录:%s(%s,截止 %s)。回复时请用轻松友好的语气,加一句与任务内容相关的俏皮话(不超过30字)。",
|
||||
title, priorityLabel, deadlineStr)
|
||||
} else {
|
||||
result.Message = fmt.Sprintf("已记录:%s(%s)。回复时请用轻松友好的语气,加一句与任务内容相关的俏皮话(不超过30字)。",
|
||||
title, priorityLabel)
|
||||
}
|
||||
|
||||
jsonBytes, marshalErr := json.Marshal(result)
|
||||
if marshalErr != nil {
|
||||
// 6.2 JSON 序列化失败时降级为纯文本,确保 LLM 仍能拿到关键信息。
|
||||
return result.Message
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ type DefaultRegistryDeps struct {
|
||||
|
||||
// WebSearchProvider Web 搜索供应商。为 nil 时 web_search / web_fetch 返回"暂未启用",不阻断主流程。
|
||||
WebSearchProvider web.SearchProvider
|
||||
|
||||
// QuickNote 随口记工具依赖。CreateTask 为 nil 时 quick_note_create 返回错误提示,不阻断主流程。
|
||||
QuickNote QuickNoteDeps
|
||||
}
|
||||
|
||||
// ToolRegistry 管理工具注册、查找与执行。
|
||||
@@ -100,6 +103,12 @@ func (r *ToolRegistry) IsWriteTool(name string) bool {
|
||||
return writeTools[name]
|
||||
}
|
||||
|
||||
// RequiresScheduleState 判断工具是否依赖 ScheduleState。
|
||||
// 调用目的:execute 节点据此决定是否允许在 ScheduleState 为 nil 时调用该工具。
|
||||
func (r *ToolRegistry) RequiresScheduleState(name string) bool {
|
||||
return !scheduleFreeTools[name]
|
||||
}
|
||||
|
||||
// ==================== 写工具集合 ====================
|
||||
|
||||
var writeTools = map[string]bool{
|
||||
@@ -113,6 +122,15 @@ var writeTools = map[string]bool{
|
||||
"unplace": true,
|
||||
}
|
||||
|
||||
// ==================== 不依赖 ScheduleState 的工具集合 ====================
|
||||
// 调用目的:这些工具不需要日程状态即可执行,execute 节点在 ScheduleState 为 nil 时允许调用。
|
||||
|
||||
var scheduleFreeTools = map[string]bool{
|
||||
"quick_note_create": true,
|
||||
"web_search": true,
|
||||
"web_fetch": true,
|
||||
}
|
||||
|
||||
// ==================== 默认注册表 ====================
|
||||
|
||||
// NewDefaultRegistry 创建默认日程工具注册表。
|
||||
@@ -310,6 +328,18 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
|
||||
},
|
||||
)
|
||||
|
||||
// --- 随口记工具 ---
|
||||
// 调用目的:将"帮我记一下明天开会"等随口任务请求直接写入数据库,无需 ScheduleState。
|
||||
// 不加入 writeTools:随口记是用户明确指令,不需要 confirm 节点二次确认。
|
||||
if deps.QuickNote.CreateTask != nil {
|
||||
quickNoteHandler := NewQuickNoteToolHandler(deps.QuickNote)
|
||||
r.Register("quick_note_create",
|
||||
"记录一条任务/提醒/待办事项到用户的任务列表。支持中文相对时间(如“明天下午3点”、“下周一”)。title 必填。记录成功后,回复时应包含一句与任务内容相关的轻松跟进话术(不超过30字),类似朋友间的友好调侃。",
|
||||
`{"name":"quick_note_create","parameters":{"title":{"type":"string","required":true,"description":"任务标题,简洁明确"},"deadline_at":{"type":"string","description":"可选截止时间,支持 yyyy-MM-dd HH:mm 或中文相对时间(明天/下周一/后天等)"},"priority_group":{"type":"int","description":"优先级(1重要且紧急,2重要不紧急,3简单不重要,4复杂不重要);信息足够时请显式填写,不确定时可不填,由工具层自动推断"}}}`,
|
||||
quickNoteHandler,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Web 搜索读工具 ---
|
||||
// 1. provider 为 nil 时 handler 返回"暂未启用"的 observation,不会阻断主流程;
|
||||
// 2. 两个工具均为读操作,走 action=continue + tool_call 模式。
|
||||
|
||||
@@ -2,6 +2,7 @@ package agentsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
@@ -214,6 +215,12 @@ func (s *AgentService) runNewAgentGraph(
|
||||
|
||||
finalState, graphErr := newagentgraph.RunAgentGraph(requestCtx, runInput)
|
||||
if graphErr != nil {
|
||||
// 1. 客户端断连导致的 context 取消,属于正常场景,不推错误通道也不跑 fallback。
|
||||
// 否则会刷 "错误通道已满" 日志噪音,且 fallback 在 ctx 已取消时也会失败。
|
||||
if errors.Is(graphErr, context.Canceled) || requestCtx.Err() != nil {
|
||||
log.Printf("[WARN] newAgent graph 因客户端断连中止 trace=%s chat=%s", traceID, chatID)
|
||||
return
|
||||
}
|
||||
log.Printf("[ERROR] newAgent graph 执行失败 trace=%s chat=%s: %v", traceID, chatID, graphErr)
|
||||
pushErrNonBlocking(errChan, fmt.Errorf("graph 执行失败: %w", graphErr))
|
||||
|
||||
|
||||
@@ -1223,12 +1223,15 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
|
||||
|
||||
if (
|
||||
typeof delta?.reasoning_content === 'string' &&
|
||||
delta.reasoning_content &&
|
||||
!assistantMessage.content.trim()
|
||||
delta.reasoning_content
|
||||
) {
|
||||
markReasoningStart(assistantMessage)
|
||||
// 正文回流后仍允许追加 reasoning(工具调用摘要、阶段状态等),
|
||||
// 但不再切换面板状态,避免 UI 闪烁。
|
||||
if (!assistantMessage.content.trim()) {
|
||||
markReasoningStart(assistantMessage)
|
||||
thinkingMessageMap[assistantMessage.id] = true
|
||||
}
|
||||
assistantMessage.reasoning = `${assistantMessage.reasoning || ''}${delta.reasoning_content}`
|
||||
thinkingMessageMap[assistantMessage.id] = true
|
||||
}
|
||||
|
||||
if (typeof delta?.content === 'string' && delta.content) {
|
||||
@@ -1377,8 +1380,10 @@ async function sendMessage(preset?: string) {
|
||||
if (planningTaskClassIdsForRequest.length > 0) {
|
||||
pendingPlanningTaskClassIds.value = []
|
||||
}
|
||||
// 流式成功后不重新加载历史:流式数据就是当前会话的权威来源,
|
||||
// 过早 reload 会因 persistVisibleMessage 尚未落库导致 merge 产生重复/丢失。
|
||||
// 历史数据在下次切换会话或刷新页面时自然加载。
|
||||
await Promise.allSettled([
|
||||
loadConversationMessages(actualConversationId, true),
|
||||
loadConversationContextStats(actualConversationId, true),
|
||||
])
|
||||
} catch (error) {
|
||||
|
||||
@@ -19,6 +19,14 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
// SSE 流必须禁用缓冲,否则 Vite proxy 会攒满 buffer 再转发,
|
||||
// 导致前端长时间收不到数据被判定为连接中断。
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyRes', (proxyRes) => {
|
||||
proxyRes.headers['x-accel-buffering'] = 'no'
|
||||
proxyRes.headers['cache-control'] = 'no-cache'
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user