Version: 0.9.47.dev.260427
后端: 1. execute 节点继续拆职责——超大 execute.go 下沉为 node/execute 子包,按决策流、动作路由、上下文锚点、工具执行、状态快照、工具展示与参数解析拆分;顶层 execute.go 收敛为桥接导出,降低单文件编排/业务/模型/工具逻辑混写 2. 节点公共能力继续沉到 shared——抽出 LLM 纠错回灌、完整上下文调试日志、thinking 开关、统一上下文压缩、可见 assistant 文本持久化等 node_* 公共件,减少 execute 独占实现并为其他节点复用铺路 3. speak 文本整理能力独立收口——新增 speak_text 辅助文件,补齐正文归一化的独立承载,继续收缩 execute 主文件体积 前端: 4. NewAgent 时间线接入 business_card 业务卡片协议——schedule_agent.ts 新增 task_query / task_record 卡片载荷类型与 business_card kind;AssistantPanel 增加业务卡片事件存储、时间线恢复、块渲染分支与 BusinessCardRenderer 接入,同时保留 interrupt / status / tool / reasoning 多块并存 5. 新增任务查询卡片与任务记录卡片组件,并补充 DesignDemo 设计预览页与路由,前端可先行验证 business_card 的视觉与交互落点 文档: 6. 新增 newagent business card 前后端对接说明,明确 timeline kind、payload 结构、卡片分类、前后端发射/渲染约束
This commit is contained in:
File diff suppressed because it is too large
Load Diff
511
backend/newAgent/node/execute/action_router.go
Normal file
511
backend/newAgent/node/execute/action_router.go
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
package newagentexecute
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
infrallm "github.com/LoveLosita/smartflow/backend/infra/llm"
|
||||||
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
|
newagentrouter "github.com/LoveLosita/smartflow/backend/newAgent/router"
|
||||||
|
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
|
||||||
|
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type executeDecisionStreamOutput struct {
|
||||||
|
decision *newagentmodel.ExecuteDecision
|
||||||
|
rawText string
|
||||||
|
parsedBeforeText string
|
||||||
|
parsedAfterText string
|
||||||
|
streamedSpeak string
|
||||||
|
speakStreamed bool
|
||||||
|
firstChunk bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectExecuteDecisionFromLLM(
|
||||||
|
ctx context.Context,
|
||||||
|
input ExecuteNodeInput,
|
||||||
|
flowState *newagentmodel.CommonState,
|
||||||
|
conversationContext *newagentmodel.ConversationContext,
|
||||||
|
emitter *newagentstream.ChunkEmitter,
|
||||||
|
messages []*schema.Message,
|
||||||
|
) (*executeDecisionStreamOutput, error) {
|
||||||
|
reader, err := input.Client.Stream(
|
||||||
|
ctx,
|
||||||
|
messages,
|
||||||
|
infrallm.GenerateOptions{
|
||||||
|
Temperature: 1.0,
|
||||||
|
MaxTokens: 131072,
|
||||||
|
Thinking: newagentshared.ResolveThinkingMode(input.ThinkingEnabled),
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"stage": executeStageName,
|
||||||
|
"step_index": flowState.CurrentStep,
|
||||||
|
"round_used": flowState.RoundUsed,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("执行阶段 Stream 请求失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parser := newagentrouter.NewStreamDecisionParser()
|
||||||
|
output := &executeDecisionStreamOutput{firstChunk: true}
|
||||||
|
var fullText strings.Builder
|
||||||
|
|
||||||
|
for {
|
||||||
|
chunk, recvErr := reader.Recv()
|
||||||
|
if recvErr == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if recvErr != nil {
|
||||||
|
log.Printf("[WARN] execute stream recv error chat=%s err=%v", flowState.ConversationID, recvErr)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunk != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
||||||
|
if emitErr := emitter.EmitReasoningText(
|
||||||
|
executeSpeakBlockID,
|
||||||
|
executeStageName,
|
||||||
|
chunk.ReasoningContent,
|
||||||
|
output.firstChunk,
|
||||||
|
); emitErr != nil {
|
||||||
|
return nil, fmt.Errorf("执行 thinking 推送失败: %w", emitErr)
|
||||||
|
}
|
||||||
|
output.firstChunk = false
|
||||||
|
}
|
||||||
|
|
||||||
|
content := ""
|
||||||
|
if chunk != nil {
|
||||||
|
content = chunk.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
visible, ready, _ := parser.Feed(content)
|
||||||
|
if !ready {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result := parser.Result()
|
||||||
|
output.rawText = result.RawBuffer
|
||||||
|
output.parsedBeforeText = result.BeforeText
|
||||||
|
output.parsedAfterText = result.AfterText
|
||||||
|
|
||||||
|
if result.Fallback || result.ParseFailed {
|
||||||
|
log.Printf(
|
||||||
|
"[DEBUG] execute LLM 决策解析失败 chat=%s round=%d raw=%s",
|
||||||
|
flowState.ConversationID,
|
||||||
|
flowState.RoundUsed,
|
||||||
|
output.rawText,
|
||||||
|
)
|
||||||
|
flowState.ConsecutiveCorrections++
|
||||||
|
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"连续 %d 次解析决策 JSON 失败,终止执行。原始输出=%s",
|
||||||
|
flowState.ConsecutiveCorrections,
|
||||||
|
output.rawText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
errorDesc := "未识别到合法的 SMARTFLOW_DECISION 标签,无法继续解析。"
|
||||||
|
optionHint := "请输出一个 <SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION>,然后再在标签外补充可见文本。"
|
||||||
|
if strings.Contains(output.rawText, `"tool_call": [`) || strings.Contains(output.rawText, `"tool_call":[`) {
|
||||||
|
errorDesc = "检测到 tool_call 字段被错误写成数组;每次只允许调用一个工具,不支持数组形式。"
|
||||||
|
optionHint = "请把多次工具调用拆开,每次只保留一个 tool_call,然后再继续下一轮。"
|
||||||
|
}
|
||||||
|
newagentshared.AppendLLMCorrectionWithHint(conversationContext, output.rawText, errorDesc, optionHint)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, parseErr := infrallm.ParseJSONObject[newagentmodel.ExecuteDecision](result.DecisionJSON)
|
||||||
|
if parseErr != nil {
|
||||||
|
log.Printf(
|
||||||
|
"[DEBUG] execute LLM JSON 解析失败 chat=%s round=%d json=%s raw=%s",
|
||||||
|
flowState.ConversationID,
|
||||||
|
flowState.RoundUsed,
|
||||||
|
result.DecisionJSON,
|
||||||
|
output.rawText,
|
||||||
|
)
|
||||||
|
flowState.ConsecutiveCorrections++
|
||||||
|
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"连续 %d 次解析决策 JSON 失败,终止执行。原始输出=%s",
|
||||||
|
flowState.ConsecutiveCorrections,
|
||||||
|
output.rawText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
newagentshared.AppendLLMCorrectionWithHint(
|
||||||
|
conversationContext,
|
||||||
|
"",
|
||||||
|
"决策标签内的 JSON 格式不合法。",
|
||||||
|
"请确保 <SMARTFLOW_DECISION> 标签内是合法 JSON;当 action=next_plan/done 时,goal_check 必须是字符串(不要输出对象)。",
|
||||||
|
)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
output.decision = decision
|
||||||
|
|
||||||
|
if visible != "" {
|
||||||
|
if emitErr := emitter.EmitAssistantText(
|
||||||
|
executeSpeakBlockID,
|
||||||
|
executeStageName,
|
||||||
|
visible,
|
||||||
|
output.firstChunk,
|
||||||
|
); emitErr != nil {
|
||||||
|
return nil, fmt.Errorf("执行回答推送失败: %w", emitErr)
|
||||||
|
}
|
||||||
|
output.speakStreamed = true
|
||||||
|
fullText.WriteString(visible)
|
||||||
|
output.firstChunk = false
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
chunk2, recvErr2 := reader.Recv()
|
||||||
|
if recvErr2 == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if recvErr2 != nil {
|
||||||
|
log.Printf("[WARN] execute speak stream error chat=%s err=%v", flowState.ConversationID, recvErr2)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if chunk2 == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(chunk2.ReasoningContent) != "" {
|
||||||
|
_ = emitter.EmitReasoningText(executeSpeakBlockID, executeStageName, chunk2.ReasoningContent, false)
|
||||||
|
}
|
||||||
|
if chunk2.Content != "" {
|
||||||
|
if emitErr := emitter.EmitAssistantText(
|
||||||
|
executeSpeakBlockID,
|
||||||
|
executeStageName,
|
||||||
|
chunk2.Content,
|
||||||
|
output.firstChunk,
|
||||||
|
); emitErr != nil {
|
||||||
|
return nil, fmt.Errorf("执行回答推送失败: %w", emitErr)
|
||||||
|
}
|
||||||
|
output.speakStreamed = true
|
||||||
|
fullText.WriteString(chunk2.Content)
|
||||||
|
output.firstChunk = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.decision == nil {
|
||||||
|
if strings.TrimSpace(output.rawText) == "" {
|
||||||
|
log.Printf(
|
||||||
|
"[WARN] execute LLM 返回空文本 chat=%s round=%d consecutive=%d/%d",
|
||||||
|
flowState.ConversationID,
|
||||||
|
flowState.RoundUsed,
|
||||||
|
flowState.ConsecutiveCorrections+1,
|
||||||
|
maxConsecutiveCorrections,
|
||||||
|
)
|
||||||
|
flowState.ConsecutiveCorrections++
|
||||||
|
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||||
|
return nil, fmt.Errorf("连续 %d 次模型返回空文本,终止执行", flowState.ConsecutiveCorrections)
|
||||||
|
}
|
||||||
|
newagentshared.AppendLLMCorrectionWithHint(
|
||||||
|
conversationContext,
|
||||||
|
"",
|
||||||
|
"模型没有返回任何内容。",
|
||||||
|
"请至少返回一个 <SMARTFLOW_DECISION>{JSON}</SMARTFLOW_DECISION> 形式的执行决策。",
|
||||||
|
)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("执行阶段模型输出中未提取到决策标签")
|
||||||
|
}
|
||||||
|
|
||||||
|
output.streamedSpeak = fullText.String()
|
||||||
|
output.decision.Speak = pickExecuteVisibleSpeak(
|
||||||
|
output.streamedSpeak,
|
||||||
|
output.parsedAfterText,
|
||||||
|
output.parsedBeforeText,
|
||||||
|
output.decision,
|
||||||
|
)
|
||||||
|
log.Printf(
|
||||||
|
"[DEBUG] execute LLM 响应 chat=%s round=%d action=%s speak_len=%d raw_len=%d raw_preview=%.200s",
|
||||||
|
flowState.ConversationID,
|
||||||
|
flowState.RoundUsed,
|
||||||
|
output.decision.Action,
|
||||||
|
len(output.decision.Speak),
|
||||||
|
len(output.rawText),
|
||||||
|
output.rawText,
|
||||||
|
)
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleExecuteDecision(
|
||||||
|
ctx context.Context,
|
||||||
|
input ExecuteNodeInput,
|
||||||
|
runtimeState *newagentmodel.AgentRuntimeState,
|
||||||
|
flowState *newagentmodel.CommonState,
|
||||||
|
conversationContext *newagentmodel.ConversationContext,
|
||||||
|
emitter *newagentstream.ChunkEmitter,
|
||||||
|
output *executeDecisionStreamOutput,
|
||||||
|
) error {
|
||||||
|
if output == nil || output.decision == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
decision := output.decision
|
||||||
|
if decision.Action == newagentmodel.ExecuteActionDone &&
|
||||||
|
decision.ToolCall != nil &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(decision.ToolCall.Name), newagenttools.ToolNameContextToolsRemove) {
|
||||||
|
decision.ToolCall = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := decision.Validate(); err != nil {
|
||||||
|
flowState.ConsecutiveCorrections++
|
||||||
|
log.Printf(
|
||||||
|
"[WARN] execute 决策不合法 chat=%s round=%d consecutive=%d/%d err=%s",
|
||||||
|
flowState.ConversationID,
|
||||||
|
flowState.RoundUsed,
|
||||||
|
flowState.ConsecutiveCorrections,
|
||||||
|
maxConsecutiveCorrections,
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
|
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"连续 %d 次决策不合法,终止执行。%s (原始输出: %s)",
|
||||||
|
flowState.ConsecutiveCorrections,
|
||||||
|
err.Error(),
|
||||||
|
output.rawText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ = emitter.EmitStatus(
|
||||||
|
executeStatusBlockID,
|
||||||
|
executeStageName,
|
||||||
|
"executing",
|
||||||
|
fmt.Sprintf("执行校验:决策不合法:%s,已请求模型重试。", err.Error()),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
newagentshared.AppendLLMCorrectionWithHint(
|
||||||
|
conversationContext,
|
||||||
|
"",
|
||||||
|
fmt.Sprintf("本次执行决策不合法:%s", err.Error()),
|
||||||
|
"合法的 action 包括:continue(继续当前步骤)、ask_user(追问用户)、confirm(写操作确认)、next_plan(推进到下一步)、done(任务完成)、abort(正式终止本轮流程)。",
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
flowState.ConsecutiveCorrections = 0
|
||||||
|
decision.Speak = pickExecuteVisibleSpeak(
|
||||||
|
decision.Speak,
|
||||||
|
output.parsedAfterText,
|
||||||
|
output.parsedBeforeText,
|
||||||
|
decision,
|
||||||
|
)
|
||||||
|
decision.Speak = normalizeSpeak(decision.Speak)
|
||||||
|
|
||||||
|
if decision.Action == newagentmodel.ExecuteActionConfirm &&
|
||||||
|
decision.ToolCall != nil &&
|
||||||
|
input.ToolRegistry != nil &&
|
||||||
|
!input.ToolRegistry.IsWriteTool(decision.ToolCall.Name) {
|
||||||
|
decision.Action = newagentmodel.ExecuteActionContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
if decision.Action == newagentmodel.ExecuteActionContinue &&
|
||||||
|
decision.ToolCall != nil &&
|
||||||
|
newagenttools.IsContextManagementTool(decision.ToolCall.Name) {
|
||||||
|
decision.Speak = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if !output.speakStreamed && strings.TrimSpace(decision.Speak) != "" {
|
||||||
|
if emitErr := emitter.EmitAssistantText(
|
||||||
|
executeSpeakBlockID,
|
||||||
|
executeStageName,
|
||||||
|
decision.Speak,
|
||||||
|
output.firstChunk,
|
||||||
|
); emitErr != nil {
|
||||||
|
return fmt.Errorf("执行回答补发失败: %w", emitErr)
|
||||||
|
}
|
||||||
|
output.speakStreamed = true
|
||||||
|
output.firstChunk = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.speakStreamed {
|
||||||
|
if tail := buildExecuteNormalizedSpeakTail(output.streamedSpeak, decision.Speak); tail != "" {
|
||||||
|
if emitErr := emitter.EmitAssistantText(
|
||||||
|
executeSpeakBlockID,
|
||||||
|
executeStageName,
|
||||||
|
tail,
|
||||||
|
output.firstChunk,
|
||||||
|
); emitErr != nil {
|
||||||
|
return fmt.Errorf("执行回答尾段补发失败: %w", emitErr)
|
||||||
|
}
|
||||||
|
output.firstChunk = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if flowState.HasPlan() &&
|
||||||
|
(decision.Action == newagentmodel.ExecuteActionNextPlan ||
|
||||||
|
decision.Action == newagentmodel.ExecuteActionDone) {
|
||||||
|
if strings.TrimSpace(decision.GoalCheck) == "" {
|
||||||
|
flowState.ConsecutiveCorrections++
|
||||||
|
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||||
|
return fmt.Errorf("连续 %d 次 goal_check 为空,终止执行", flowState.ConsecutiveCorrections)
|
||||||
|
}
|
||||||
|
_ = emitter.EmitStatus(
|
||||||
|
executeStatusBlockID,
|
||||||
|
executeStageName,
|
||||||
|
"executing",
|
||||||
|
fmt.Sprintf("执行校验:action=%s 缺少 goal_check,已请求模型重试。", decision.Action),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
newagentshared.AppendLLMCorrectionWithHint(
|
||||||
|
conversationContext,
|
||||||
|
"",
|
||||||
|
fmt.Sprintf("你输出了 action=%s,但 goal_check 为空。", decision.Action),
|
||||||
|
fmt.Sprintf("输出 %s 时,必须在 goal_check 中对照 done_when 逐条说明完成依据。", decision.Action),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
askUserHistoryAppended := false
|
||||||
|
if strings.TrimSpace(decision.Speak) != "" {
|
||||||
|
isConfirmWithCard := decision.Action == newagentmodel.ExecuteActionConfirm && !input.AlwaysExecute
|
||||||
|
isAskUser := decision.Action == newagentmodel.ExecuteActionAskUser
|
||||||
|
isAbort := decision.Action == newagentmodel.ExecuteActionAbort
|
||||||
|
|
||||||
|
if !isConfirmWithCard && !isAskUser && !isAbort {
|
||||||
|
msg := schema.AssistantMessage(decision.Speak, nil)
|
||||||
|
newagentshared.PersistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||||
|
}
|
||||||
|
if !isAbort {
|
||||||
|
conversationContext.AppendHistory(&schema.Message{
|
||||||
|
Role: schema.Assistant,
|
||||||
|
Content: decision.Speak,
|
||||||
|
})
|
||||||
|
if isAskUser {
|
||||||
|
askUserHistoryAppended = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch decision.Action {
|
||||||
|
case newagentmodel.ExecuteActionContinue:
|
||||||
|
if decision.ToolCall != nil {
|
||||||
|
if input.ToolRegistry != nil && input.ToolRegistry.IsWriteTool(decision.ToolCall.Name) {
|
||||||
|
flowState.ConsecutiveCorrections++
|
||||||
|
log.Printf(
|
||||||
|
"[WARN] execute 决策协议违背 chat=%s round=%d action=continue tool=%s consecutive=%d/%d",
|
||||||
|
flowState.ConversationID,
|
||||||
|
flowState.RoundUsed,
|
||||||
|
strings.TrimSpace(decision.ToolCall.Name),
|
||||||
|
flowState.ConsecutiveCorrections,
|
||||||
|
maxConsecutiveCorrections,
|
||||||
|
)
|
||||||
|
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||||
|
return fmt.Errorf("连续 %d 次输出 continue+写工具,终止执行", flowState.ConsecutiveCorrections)
|
||||||
|
}
|
||||||
|
_ = emitter.EmitStatus(
|
||||||
|
executeStatusBlockID,
|
||||||
|
executeStageName,
|
||||||
|
"executing",
|
||||||
|
fmt.Sprintf(
|
||||||
|
"执行校验:写工具 %q 未执行。原因:模型输出了 action=continue;所有写工具都必须使用 action=confirm。",
|
||||||
|
strings.TrimSpace(decision.ToolCall.Name),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
llmOutput := decision.Speak
|
||||||
|
if strings.TrimSpace(llmOutput) == "" {
|
||||||
|
llmOutput = decision.Reason
|
||||||
|
}
|
||||||
|
newagentshared.AppendLLMCorrectionWithHint(
|
||||||
|
conversationContext,
|
||||||
|
llmOutput,
|
||||||
|
fmt.Sprintf("你输出了 action=continue,但同时提供了 %q 这个写工具。", decision.ToolCall.Name),
|
||||||
|
"所有写工具都必须使用 action=confirm,并放在同一个 tool_call 中;continue 仅用于读工具。如果写操作尚未执行,请直接回发 confirm。",
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if shouldForceFeasibilityNegotiation(flowState, input.ToolRegistry, decision.ToolCall.Name) {
|
||||||
|
runtimeState.OpenAskUserInteraction(
|
||||||
|
uuid.NewString(),
|
||||||
|
buildInfeasibleNegotiationQuestion(flowState),
|
||||||
|
strings.TrimSpace(input.ResumeNode),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return executeToolCall(
|
||||||
|
ctx,
|
||||||
|
flowState,
|
||||||
|
conversationContext,
|
||||||
|
decision.ToolCall,
|
||||||
|
emitter,
|
||||||
|
input.ToolRegistry,
|
||||||
|
input.ScheduleState,
|
||||||
|
input.WriteSchedulePreview,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(decision.Speak) == "" && strings.TrimSpace(decision.Reason) != "" {
|
||||||
|
conversationContext.AppendHistory(&schema.Message{
|
||||||
|
Role: schema.Assistant,
|
||||||
|
Content: decision.Reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case newagentmodel.ExecuteActionAskUser:
|
||||||
|
question := resolveExecuteAskUserText(decision)
|
||||||
|
runtimeState.OpenAskUserInteraction(uuid.NewString(), question, strings.TrimSpace(input.ResumeNode))
|
||||||
|
runtimeState.SetPendingInteractionMetadata(newagentmodel.PendingMetaAskUserSpeakStreamed, output.speakStreamed)
|
||||||
|
runtimeState.SetPendingInteractionMetadata(newagentmodel.PendingMetaAskUserHistoryAppended, askUserHistoryAppended)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case newagentmodel.ExecuteActionConfirm:
|
||||||
|
if decision.ToolCall != nil && shouldForceFeasibilityNegotiation(flowState, input.ToolRegistry, decision.ToolCall.Name) {
|
||||||
|
runtimeState.OpenAskUserInteraction(
|
||||||
|
uuid.NewString(),
|
||||||
|
buildInfeasibleNegotiationQuestion(flowState),
|
||||||
|
strings.TrimSpace(input.ResumeNode),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if input.AlwaysExecute && decision.ToolCall != nil {
|
||||||
|
return executeToolCall(
|
||||||
|
ctx,
|
||||||
|
flowState,
|
||||||
|
conversationContext,
|
||||||
|
decision.ToolCall,
|
||||||
|
emitter,
|
||||||
|
input.ToolRegistry,
|
||||||
|
input.ScheduleState,
|
||||||
|
input.WriteSchedulePreview,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return handleExecuteActionConfirm(decision, runtimeState, flowState)
|
||||||
|
|
||||||
|
case newagentmodel.ExecuteActionNextPlan:
|
||||||
|
if !flowState.AdvanceStep() {
|
||||||
|
flowState.Done()
|
||||||
|
}
|
||||||
|
appendExecuteStepAdvancedMarker(conversationContext)
|
||||||
|
syncExecutePinnedContext(conversationContext, flowState)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case newagentmodel.ExecuteActionDone:
|
||||||
|
flowState.Done()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case newagentmodel.ExecuteActionAbort:
|
||||||
|
return handleExecuteActionAbort(decision, flowState)
|
||||||
|
|
||||||
|
default:
|
||||||
|
llmOutput := decision.Speak
|
||||||
|
if strings.TrimSpace(llmOutput) == "" {
|
||||||
|
llmOutput = decision.Reason
|
||||||
|
}
|
||||||
|
newagentshared.AppendLLMCorrectionWithHint(
|
||||||
|
conversationContext,
|
||||||
|
llmOutput,
|
||||||
|
fmt.Sprintf("你输出的 action %q 不是合法的执行动作。", decision.Action),
|
||||||
|
"合法的 action 包括:continue(继续当前步骤)、ask_user(追问用户)、confirm(写操作确认)、next_plan(推进到下一步)、done(任务完成)、abort(正式终止本轮流程)。",
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
119
backend/newAgent/node/execute/action_text.go
Normal file
119
backend/newAgent/node/execute/action_text.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package newagentexecute
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resolveExecuteAskUserText(decision *newagentmodel.ExecuteDecision) string {
|
||||||
|
if decision == nil {
|
||||||
|
return "执行过程中遇到不确定的情况,需要向你确认。"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(decision.Speak) != "" {
|
||||||
|
return strings.TrimSpace(decision.Speak)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(decision.Reason) != "" {
|
||||||
|
return strings.TrimSpace(decision.Reason)
|
||||||
|
}
|
||||||
|
return "执行过程中遇到不确定的情况,需要向你确认。"
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickExecuteVisibleSpeak(
|
||||||
|
streamed string,
|
||||||
|
afterText string,
|
||||||
|
beforeText string,
|
||||||
|
decision *newagentmodel.ExecuteDecision,
|
||||||
|
) string {
|
||||||
|
if text := strings.TrimSpace(streamed); text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
if text := strings.TrimSpace(afterText); text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
if text := strings.TrimSpace(beforeText); text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return buildExecuteSpeakWithFallback(decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExecuteSpeakWithFallback(decision *newagentmodel.ExecuteDecision) string {
|
||||||
|
if decision == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
speak := strings.TrimSpace(decision.Speak)
|
||||||
|
if speak != "" {
|
||||||
|
return speak
|
||||||
|
}
|
||||||
|
|
||||||
|
switch decision.Action {
|
||||||
|
case newagentmodel.ExecuteActionContinue,
|
||||||
|
newagentmodel.ExecuteActionAskUser,
|
||||||
|
newagentmodel.ExecuteActionConfirm:
|
||||||
|
if reason := strings.TrimSpace(decision.Reason); reason != "" {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
switch decision.Action {
|
||||||
|
case newagentmodel.ExecuteActionAskUser:
|
||||||
|
return "我还缺少一条关键信息,想先向你确认。"
|
||||||
|
case newagentmodel.ExecuteActionConfirm:
|
||||||
|
return "我先整理好这一步操作,等待你的确认。"
|
||||||
|
default:
|
||||||
|
return "我先继续这一步处理,马上给你结果。"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return speak
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleExecuteActionConfirm(
|
||||||
|
decision *newagentmodel.ExecuteDecision,
|
||||||
|
runtimeState *newagentmodel.AgentRuntimeState,
|
||||||
|
flowState *newagentmodel.CommonState,
|
||||||
|
) error {
|
||||||
|
toolCall := decision.ToolCall
|
||||||
|
|
||||||
|
argsJSON := ""
|
||||||
|
if toolCall.Arguments != nil {
|
||||||
|
if raw, err := json.Marshal(toolCall.Arguments); err == nil {
|
||||||
|
argsJSON = string(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeState.PendingConfirmTool = &newagentmodel.PendingToolCallSnapshot{
|
||||||
|
ToolName: toolCall.Name,
|
||||||
|
ArgsJSON: argsJSON,
|
||||||
|
Summary: strings.TrimSpace(decision.Speak),
|
||||||
|
}
|
||||||
|
|
||||||
|
flowState.Phase = newagentmodel.PhaseWaitingConfirm
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleExecuteActionAbort(
|
||||||
|
decision *newagentmodel.ExecuteDecision,
|
||||||
|
flowState *newagentmodel.CommonState,
|
||||||
|
) error {
|
||||||
|
if decision == nil || decision.Abort == nil {
|
||||||
|
return fmt.Errorf("abort 动作缺少终止信息")
|
||||||
|
}
|
||||||
|
if flowState == nil {
|
||||||
|
return fmt.Errorf("abort 动作缺少流程状态")
|
||||||
|
}
|
||||||
|
|
||||||
|
internalReason := strings.TrimSpace(decision.Abort.InternalReason)
|
||||||
|
if internalReason == "" {
|
||||||
|
internalReason = strings.TrimSpace(decision.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
flowState.Abort(
|
||||||
|
executeStageName,
|
||||||
|
decision.Abort.Code,
|
||||||
|
decision.Abort.UserMessage,
|
||||||
|
internalReason,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
162
backend/newAgent/node/execute/args.go
Normal file
162
backend/newAgent/node/execute/args.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package newagentexecute
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func intSliceToSet(values []int) map[int]struct{} {
|
||||||
|
result := make(map[int]struct{}, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
result[value] = struct{}{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func readIntAnyFromMap(args map[string]any, keys ...string) (int, bool) {
|
||||||
|
for _, key := range keys {
|
||||||
|
if args == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, exists := args[key]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if value, ok := parseAnyToInt(raw); ok {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func readIntSliceAnyFromMap(args map[string]any, keys ...string) []int {
|
||||||
|
for _, key := range keys {
|
||||||
|
if args == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, exists := args[key]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values := parseAnyToIntSlice(raw)
|
||||||
|
if len(values) > 0 {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readStringAnyFromMap(args map[string]any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if args == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, exists := args[key]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if text, ok := raw.(string); ok {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAnyToInt(value any) (int, bool) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case int:
|
||||||
|
return v, true
|
||||||
|
case int8:
|
||||||
|
return int(v), true
|
||||||
|
case int16:
|
||||||
|
return int(v), true
|
||||||
|
case int32:
|
||||||
|
return int(v), true
|
||||||
|
case int64:
|
||||||
|
return int(v), true
|
||||||
|
case float32:
|
||||||
|
return int(v), true
|
||||||
|
case float64:
|
||||||
|
return int(v), true
|
||||||
|
case json.Number:
|
||||||
|
if iv, err := v.Int64(); err == nil {
|
||||||
|
return int(iv), true
|
||||||
|
}
|
||||||
|
if fv, err := v.Float64(); err == nil {
|
||||||
|
return int(fv), true
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
text := strings.TrimSpace(v)
|
||||||
|
if text == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
iv, err := strconv.Atoi(text)
|
||||||
|
if err == nil {
|
||||||
|
return iv, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAnyToIntSlice(value any) []int {
|
||||||
|
switch values := value.(type) {
|
||||||
|
case []int:
|
||||||
|
result := make([]int, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case []any:
|
||||||
|
result := make([]int, 0, len(values))
|
||||||
|
for _, item := range values {
|
||||||
|
iv, ok := parseAnyToInt(item)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, iv)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAnyToStringSlice(value any) []string {
|
||||||
|
switch values := value.(type) {
|
||||||
|
case []string:
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, item := range values {
|
||||||
|
text := strings.TrimSpace(item)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, text)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case []any:
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, item := range values {
|
||||||
|
text := strings.TrimSpace(fmt.Sprintf("%v", item))
|
||||||
|
if text == "" || text == "<nil>" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, text)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateText(text string, maxLen int) string {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if len(text) <= maxLen {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
if maxLen <= 3 {
|
||||||
|
return text[:maxLen]
|
||||||
|
}
|
||||||
|
return text[:maxLen-3] + "..."
|
||||||
|
}
|
||||||
157
backend/newAgent/node/execute/context.go
Normal file
157
backend/newAgent/node/execute/context.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package newagentexecute
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
|
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
planCurrentStepKey = "current_step"
|
||||||
|
planCurrentStepTitle = "当前步骤"
|
||||||
|
)
|
||||||
|
|
||||||
|
func prepareExecuteNodeInput(input ExecuteNodeInput) (*newagentmodel.AgentRuntimeState, *newagentmodel.ConversationContext, *newagentstream.ChunkEmitter, error) {
|
||||||
|
if input.RuntimeState == nil {
|
||||||
|
return nil, nil, nil, fmt.Errorf("execute node: runtime state 不能为空")
|
||||||
|
}
|
||||||
|
if input.Client == nil {
|
||||||
|
return nil, nil, nil, fmt.Errorf("execute node: execute client 未注入")
|
||||||
|
}
|
||||||
|
|
||||||
|
input.RuntimeState.EnsureCommonState()
|
||||||
|
if input.ConversationContext == nil {
|
||||||
|
input.ConversationContext = newagentmodel.NewConversationContext("")
|
||||||
|
}
|
||||||
|
if input.ChunkEmitter == nil {
|
||||||
|
input.ChunkEmitter = newagentstream.NewChunkEmitter(newagentstream.NoopPayloadEmitter(), "", "", time.Now().Unix())
|
||||||
|
}
|
||||||
|
return input.RuntimeState, input.ConversationContext, input.ChunkEmitter, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncExecutePinnedContext(
|
||||||
|
conversationContext *newagentmodel.ConversationContext,
|
||||||
|
flowState *newagentmodel.CommonState,
|
||||||
|
) {
|
||||||
|
if conversationContext == nil || flowState == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
execContent := buildExecuteContextPinnedMarkdown(flowState)
|
||||||
|
if strings.TrimSpace(execContent) != "" {
|
||||||
|
conversationContext.UpsertPinnedBlock(newagentmodel.ContextBlock{
|
||||||
|
Key: executePinnedKey,
|
||||||
|
Title: "执行上下文",
|
||||||
|
Content: execContent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !flowState.HasPlan() {
|
||||||
|
conversationContext.RemovePinnedBlock(planCurrentStepKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
step, ok := flowState.CurrentPlanStep()
|
||||||
|
if !ok {
|
||||||
|
conversationContext.RemovePinnedBlock(planCurrentStepKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current, total := flowState.PlanProgress()
|
||||||
|
title := strings.TrimSpace(planCurrentStepTitle)
|
||||||
|
if title == "" {
|
||||||
|
title = "当前步骤"
|
||||||
|
}
|
||||||
|
conversationContext.UpsertPinnedBlock(newagentmodel.ContextBlock{
|
||||||
|
Key: planCurrentStepKey,
|
||||||
|
Title: title,
|
||||||
|
Content: buildCurrentPlanStepPinnedMarkdown(step, current, total),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendExecuteStepAdvancedMarker(conversationContext *newagentmodel.ConversationContext) {
|
||||||
|
if conversationContext == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
history := conversationContext.HistorySnapshot()
|
||||||
|
if len(history) > 0 {
|
||||||
|
last := history[len(history)-1]
|
||||||
|
if last != nil && last.Extra != nil {
|
||||||
|
if kind, ok := last.Extra[executeHistoryKindKey].(string); ok && strings.TrimSpace(kind) == executeHistoryKindStepAdvanced {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conversationContext.AppendHistory(&schema.Message{
|
||||||
|
Role: schema.Assistant,
|
||||||
|
Content: "",
|
||||||
|
Extra: map[string]any{
|
||||||
|
executeHistoryKindKey: executeHistoryKindStepAdvanced,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExecuteContextPinnedMarkdown(flowState *newagentmodel.CommonState) string {
|
||||||
|
if flowState == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make([]string, 0, 8)
|
||||||
|
if flowState.HasPlan() {
|
||||||
|
lines = append(lines, "执行模式:计划执行(按步骤推进)")
|
||||||
|
current, total := flowState.PlanProgress()
|
||||||
|
lines = append(lines, fmt.Sprintf("计划进度:第 %d/%d 步", current, total))
|
||||||
|
|
||||||
|
if step, ok := flowState.CurrentPlanStep(); ok {
|
||||||
|
lines = append(lines, "当前步骤:"+compactExecutePinnedText(step.Content))
|
||||||
|
doneWhen := compactExecutePinnedText(step.DoneWhen)
|
||||||
|
if doneWhen != "" {
|
||||||
|
lines = append(lines, "完成判定(done_when):"+doneWhen)
|
||||||
|
}
|
||||||
|
lines = append(lines, "动作纪律:未满足 done_when 禁止 next_plan;满足后优先 next_plan。")
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "当前步骤:不可读(可能已执行完成)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "执行模式:自由执行(无预定义步骤)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if flowState.MaxRounds > 0 {
|
||||||
|
lines = append(lines, fmt.Sprintf("轮次预算:%d/%d", flowState.RoundUsed, flowState.MaxRounds))
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCurrentPlanStepPinnedMarkdown(step newagentmodel.PlanStep, current, total int) string {
|
||||||
|
lines := make([]string, 0, 4)
|
||||||
|
lines = append(lines, fmt.Sprintf("步骤进度:第 %d/%d 步", current, total))
|
||||||
|
|
||||||
|
content := compactExecutePinnedText(step.Content)
|
||||||
|
if content == "" {
|
||||||
|
content = "(空)"
|
||||||
|
}
|
||||||
|
lines = append(lines, "步骤内容:"+content)
|
||||||
|
|
||||||
|
doneWhen := compactExecutePinnedText(step.DoneWhen)
|
||||||
|
if doneWhen != "" {
|
||||||
|
lines = append(lines, "完成判定:"+doneWhen)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactExecutePinnedText(text string) string {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||||
|
text = strings.ReplaceAll(text, "\n", ";")
|
||||||
|
return strings.TrimSpace(text)
|
||||||
|
}
|
||||||
150
backend/newAgent/node/execute/run.go
Normal file
150
backend/newAgent/node/execute/run.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package newagentexecute
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared"
|
||||||
|
|
||||||
|
infrallm "github.com/LoveLosita/smartflow/backend/infra/llm"
|
||||||
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
|
newagentprompt "github.com/LoveLosita/smartflow/backend/newAgent/prompt"
|
||||||
|
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
|
||||||
|
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
executeStageName = "execute"
|
||||||
|
executeStatusBlockID = "execute.status"
|
||||||
|
executeSpeakBlockID = "execute.speak"
|
||||||
|
executePinnedKey = "execution_context"
|
||||||
|
toolAnalyzeHealth = "analyze_health"
|
||||||
|
executeHistoryKindKey = "newagent_history_kind"
|
||||||
|
executeHistoryKindStepAdvanced = "execute_step_advanced"
|
||||||
|
|
||||||
|
maxConsecutiveCorrections = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExecuteNodeInput struct {
|
||||||
|
RuntimeState *newagentmodel.AgentRuntimeState
|
||||||
|
ConversationContext *newagentmodel.ConversationContext
|
||||||
|
UserInput string
|
||||||
|
Client *infrallm.Client
|
||||||
|
ChunkEmitter *newagentstream.ChunkEmitter
|
||||||
|
ResumeNode string
|
||||||
|
ToolRegistry *newagenttools.ToolRegistry
|
||||||
|
ScheduleState *schedule.ScheduleState
|
||||||
|
CompactionStore newagentmodel.CompactionStore
|
||||||
|
WriteSchedulePreview newagentmodel.WriteSchedulePreviewFunc
|
||||||
|
OriginalScheduleState *schedule.ScheduleState
|
||||||
|
AlwaysExecute bool
|
||||||
|
ThinkingEnabled bool
|
||||||
|
PersistVisibleMessage newagentmodel.PersistVisibleMessageFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecuteRoundObservation struct {
|
||||||
|
Round int `json:"round"`
|
||||||
|
StepIndex int `json:"step_index"`
|
||||||
|
GoalCheck string `json:"goal_check,omitempty"`
|
||||||
|
Decision string `json:"decision,omitempty"`
|
||||||
|
ToolName string `json:"tool_name,omitempty"`
|
||||||
|
ToolParams string `json:"tool_params,omitempty"`
|
||||||
|
ToolSuccess bool `json:"tool_success"`
|
||||||
|
ToolResult string `json:"tool_result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
||||||
|
runtimeState, conversationContext, emitter, err := prepareExecuteNodeInput(input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
flowState := runtimeState.EnsureCommonState()
|
||||||
|
applyPendingContextHook(flowState)
|
||||||
|
|
||||||
|
if runtimeState.PendingConfirmTool != nil {
|
||||||
|
return executePendingTool(
|
||||||
|
ctx,
|
||||||
|
runtimeState,
|
||||||
|
conversationContext,
|
||||||
|
input.ToolRegistry,
|
||||||
|
input.ScheduleState,
|
||||||
|
input.OriginalScheduleState,
|
||||||
|
input.WriteSchedulePreview,
|
||||||
|
emitter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.ScheduleState != nil && flowState.RoundUsed == 0 {
|
||||||
|
schedule.ResetTaskProcessingQueue(input.ScheduleState)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncExecutePinnedContext(conversationContext, flowState)
|
||||||
|
|
||||||
|
if flowState.HasCurrentPlanStep() {
|
||||||
|
current, total := flowState.PlanProgress()
|
||||||
|
currentStep, _ := flowState.CurrentPlanStep()
|
||||||
|
if err := emitter.EmitStatus(
|
||||||
|
executeStatusBlockID,
|
||||||
|
executeStageName,
|
||||||
|
"executing",
|
||||||
|
fmt.Sprintf("正在执行第 %d/%d 步:%s", current, total, truncateText(currentStep.Content, 60)),
|
||||||
|
false,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("执行阶段状态推送失败: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := emitter.EmitStatus(
|
||||||
|
executeStatusBlockID,
|
||||||
|
executeStageName,
|
||||||
|
"executing",
|
||||||
|
"正在处理你的请求...",
|
||||||
|
false,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("执行阶段状态推送失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !flowState.NextRound() {
|
||||||
|
flowState.Exhaust(
|
||||||
|
executeStageName,
|
||||||
|
"本轮执行已达到安全轮次上限,当前先停止继续操作。如需继续,我可以在你确认后接着处理剩余步骤。",
|
||||||
|
"execute rounds exhausted before task completion",
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := newagentprompt.BuildExecuteMessages(flowState, conversationContext)
|
||||||
|
messages = newagentshared.CompactUnifiedMessagesIfNeeded(ctx, messages, newagentshared.UnifiedCompactInput{
|
||||||
|
Client: input.Client,
|
||||||
|
CompactionStore: input.CompactionStore,
|
||||||
|
FlowState: flowState,
|
||||||
|
Emitter: emitter,
|
||||||
|
StageName: executeStageName,
|
||||||
|
StatusBlockID: executeStatusBlockID,
|
||||||
|
})
|
||||||
|
|
||||||
|
newagentshared.LogNodeLLMContext(executeStageName, "decision", flowState, messages)
|
||||||
|
|
||||||
|
decisionOutput, err := collectExecuteDecisionFromLLM(
|
||||||
|
ctx,
|
||||||
|
input,
|
||||||
|
flowState,
|
||||||
|
conversationContext,
|
||||||
|
emitter,
|
||||||
|
messages,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleExecuteDecision(
|
||||||
|
ctx,
|
||||||
|
input,
|
||||||
|
runtimeState,
|
||||||
|
flowState,
|
||||||
|
conversationContext,
|
||||||
|
emitter,
|
||||||
|
decisionOutput,
|
||||||
|
)
|
||||||
|
}
|
||||||
332
backend/newAgent/node/execute/state_snapshot.go
Normal file
332
backend/newAgent/node/execute/state_snapshot.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
package newagentexecute
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
|
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
func shouldForceFeasibilityNegotiation(
|
||||||
|
flowState *newagentmodel.CommonState,
|
||||||
|
registry *newagenttools.ToolRegistry,
|
||||||
|
toolName string,
|
||||||
|
) bool {
|
||||||
|
if flowState == nil || registry == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !flowState.HealthCheckDone || flowState.HealthIsFeasible {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !registry.IsWriteTool(toolName) || !registry.RequiresScheduleState(toolName) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildInfeasibleNegotiationQuestion(flowState *newagentmodel.CommonState) string {
|
||||||
|
capacityGap := 0
|
||||||
|
reasonCode := "capacity_insufficient"
|
||||||
|
if flowState != nil {
|
||||||
|
capacityGap = flowState.HealthCapacityGap
|
||||||
|
if strings.TrimSpace(flowState.HealthReasonCode) != "" {
|
||||||
|
reasonCode = strings.TrimSpace(flowState.HealthReasonCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"当前计划不可行:analyze_health 判断当前约束不可行(capacity_gap=%d,reason=%s)。在继续写操作前,请先与用户协商:扩展时间窗、放宽约束、缩减范围或预算,或接受风险收口。",
|
||||||
|
capacityGap,
|
||||||
|
reasonCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildInfeasibleBlockedResult(flowState *newagentmodel.CommonState) string {
|
||||||
|
capacityGap := 0
|
||||||
|
reasonCode := "capacity_insufficient"
|
||||||
|
if flowState != nil {
|
||||||
|
capacityGap = flowState.HealthCapacityGap
|
||||||
|
if strings.TrimSpace(flowState.HealthReasonCode) != "" {
|
||||||
|
reasonCode = strings.TrimSpace(flowState.HealthReasonCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"已阻断本次写操作:analyze_health 判定当前约束不可行(capacity_gap=%d,reason=%s)。请先与用户协商:扩展时间窗 / 放宽约束 / 缩减范围或预算 / 接受风险收口。",
|
||||||
|
capacityGap,
|
||||||
|
reasonCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type contextToolsResultEnvelope struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Domain string `json:"domain,omitempty"`
|
||||||
|
Packs []string `json:"packs,omitempty"`
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
All bool `json:"all,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type analyzeHealthResultEnvelope struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Feasibility *analyzeHealthFeasibilityBrief `json:"feasibility,omitempty"`
|
||||||
|
Decision *analyzeHealthDecisionBrief `json:"decision,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type analyzeHealthFeasibilityBrief struct {
|
||||||
|
IsFeasible bool `json:"is_feasible"`
|
||||||
|
CapacityGap int `json:"capacity_gap"`
|
||||||
|
ReasonCode string `json:"reason_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type analyzeHealthDecisionBrief struct {
|
||||||
|
ShouldContinueOptimize bool `json:"should_continue_optimize"`
|
||||||
|
PrimaryProblem string `json:"primary_problem,omitempty"`
|
||||||
|
RecommendedOperation string `json:"recommended_operation,omitempty"`
|
||||||
|
IsForcedImperfection bool `json:"is_forced_imperfection"`
|
||||||
|
ImprovementSignal string `json:"improvement_signal,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type upsertTaskClassResultEnvelope struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Validation *upsertTaskClassValidationPart `json:"validation,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
ErrorCode string `json:"error_code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type upsertTaskClassValidationPart struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Issues []string `json:"issues"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateActiveToolDomainSnapshot(flowState *newagentmodel.CommonState, toolName string, result string) {
|
||||||
|
if flowState == nil || !newagenttools.IsContextManagementTool(toolName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelope contextToolsResultEnvelope
|
||||||
|
if err := json.Unmarshal([]byte(result), &envelope); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !envelope.Success {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.TrimSpace(toolName) {
|
||||||
|
case newagenttools.ToolNameContextToolsAdd:
|
||||||
|
domain := newagenttools.NormalizeToolDomain(envelope.Domain)
|
||||||
|
if domain == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextPacks := newagenttools.ResolveEffectiveToolPacks(domain, envelope.Packs)
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(envelope.Mode))
|
||||||
|
if mode == "merge" && newagenttools.NormalizeToolDomain(flowState.ActiveToolDomain) == domain {
|
||||||
|
merged := make([]string, 0, len(flowState.ActiveToolPacks)+len(nextPacks))
|
||||||
|
seen := make(map[string]struct{}, len(flowState.ActiveToolPacks)+len(nextPacks))
|
||||||
|
current := newagenttools.ResolveEffectiveToolPacks(domain, flowState.ActiveToolPacks)
|
||||||
|
for _, pack := range current {
|
||||||
|
if _, exists := seen[pack]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[pack] = struct{}{}
|
||||||
|
merged = append(merged, pack)
|
||||||
|
}
|
||||||
|
for _, pack := range nextPacks {
|
||||||
|
if _, exists := seen[pack]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[pack] = struct{}{}
|
||||||
|
merged = append(merged, pack)
|
||||||
|
}
|
||||||
|
nextPacks = merged
|
||||||
|
}
|
||||||
|
flowState.ActiveToolDomain = domain
|
||||||
|
flowState.ActiveToolPacks = nextPacks
|
||||||
|
case newagenttools.ToolNameContextToolsRemove:
|
||||||
|
if envelope.All {
|
||||||
|
flowState.ActiveToolDomain = ""
|
||||||
|
flowState.ActiveToolPacks = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domain := newagenttools.NormalizeToolDomain(envelope.Domain)
|
||||||
|
if domain == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentDomain := newagenttools.NormalizeToolDomain(flowState.ActiveToolDomain)
|
||||||
|
if currentDomain != domain {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
removedPacks := newagenttools.NormalizeToolPacks(domain, envelope.Packs)
|
||||||
|
if len(removedPacks) == 0 {
|
||||||
|
flowState.ActiveToolDomain = ""
|
||||||
|
flowState.ActiveToolPacks = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEffective := newagenttools.ResolveEffectiveToolPacks(domain, flowState.ActiveToolPacks)
|
||||||
|
if len(currentEffective) == 0 {
|
||||||
|
flowState.ActiveToolDomain = ""
|
||||||
|
flowState.ActiveToolPacks = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
removedSet := make(map[string]struct{}, len(removedPacks))
|
||||||
|
for _, pack := range removedPacks {
|
||||||
|
removedSet[pack] = struct{}{}
|
||||||
|
}
|
||||||
|
remaining := make([]string, 0, len(currentEffective))
|
||||||
|
for _, pack := range currentEffective {
|
||||||
|
if _, shouldRemove := removedSet[pack]; shouldRemove {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remaining = append(remaining, pack)
|
||||||
|
}
|
||||||
|
if len(remaining) == 0 {
|
||||||
|
flowState.ActiveToolDomain = ""
|
||||||
|
flowState.ActiveToolPacks = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flowState.ActiveToolPacks = remaining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateHealthFeasibilitySnapshot(flowState *newagentmodel.CommonState, toolName string, result string) {
|
||||||
|
if flowState == nil || !strings.EqualFold(strings.TrimSpace(toolName), toolAnalyzeHealth) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flowState.HealthCheckDone = false
|
||||||
|
flowState.HealthIsFeasible = true
|
||||||
|
flowState.HealthCapacityGap = 0
|
||||||
|
flowState.HealthReasonCode = ""
|
||||||
|
|
||||||
|
var envelope analyzeHealthResultEnvelope
|
||||||
|
if err := json.Unmarshal([]byte(result), &envelope); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !envelope.Success || envelope.Feasibility == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flowState.HealthCheckDone = true
|
||||||
|
flowState.HealthIsFeasible = envelope.Feasibility.IsFeasible
|
||||||
|
flowState.HealthCapacityGap = envelope.Feasibility.CapacityGap
|
||||||
|
flowState.HealthReasonCode = strings.TrimSpace(envelope.Feasibility.ReasonCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTaskClassUpsertSnapshot(flowState *newagentmodel.CommonState, toolName string, result string) {
|
||||||
|
if flowState == nil || !strings.EqualFold(strings.TrimSpace(toolName), "upsert_task_class") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flowState.TaskClassUpsertLastTried = true
|
||||||
|
flowState.TaskClassUpsertLastSuccess = false
|
||||||
|
flowState.TaskClassUpsertLastIssues = nil
|
||||||
|
|
||||||
|
var envelope upsertTaskClassResultEnvelope
|
||||||
|
if err := json.Unmarshal([]byte(result), &envelope); err != nil {
|
||||||
|
flowState.TaskClassUpsertConsecutiveFailures++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
success := envelope.Success
|
||||||
|
issues := make([]string, 0)
|
||||||
|
if envelope.Validation != nil {
|
||||||
|
issues = append(issues, parseAnyToStringSlice(any(envelope.Validation.Issues))...)
|
||||||
|
if !envelope.Validation.OK {
|
||||||
|
success = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !success && strings.TrimSpace(envelope.Error) != "" && len(issues) == 0 {
|
||||||
|
issues = append(issues, strings.TrimSpace(envelope.Error))
|
||||||
|
}
|
||||||
|
issues = uniqueNonEmptyStrings(issues)
|
||||||
|
|
||||||
|
flowState.TaskClassUpsertLastSuccess = success
|
||||||
|
flowState.TaskClassUpsertLastIssues = issues
|
||||||
|
if success {
|
||||||
|
flowState.TaskClassUpsertConsecutiveFailures = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flowState.TaskClassUpsertConsecutiveFailures++
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueNonEmptyStrings(values []string) []string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
text := strings.TrimSpace(value)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[text]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[text] = struct{}{}
|
||||||
|
result = append(result, text)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateHealthSnapshotV2(flowState *newagentmodel.CommonState, toolName string, result string) {
|
||||||
|
if flowState == nil || !strings.EqualFold(strings.TrimSpace(toolName), toolAnalyzeHealth) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prevSignal := strings.TrimSpace(flowState.HealthImprovementSignal)
|
||||||
|
flowState.HealthCheckDone = false
|
||||||
|
flowState.HealthIsFeasible = true
|
||||||
|
flowState.HealthCapacityGap = 0
|
||||||
|
flowState.HealthReasonCode = ""
|
||||||
|
flowState.HealthShouldContinueOptimize = false
|
||||||
|
flowState.HealthTightnessLevel = ""
|
||||||
|
flowState.HealthPrimaryProblem = ""
|
||||||
|
flowState.HealthRecommendedOperation = ""
|
||||||
|
flowState.HealthIsForcedImperfection = false
|
||||||
|
flowState.HealthImprovementSignal = ""
|
||||||
|
|
||||||
|
var envelope struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Feasibility *analyzeHealthFeasibilityBrief `json:"feasibility,omitempty"`
|
||||||
|
Metrics struct {
|
||||||
|
Tightness *struct {
|
||||||
|
TightnessLevel string `json:"tightness_level"`
|
||||||
|
} `json:"tightness,omitempty"`
|
||||||
|
} `json:"metrics"`
|
||||||
|
Decision *analyzeHealthDecisionBrief `json:"decision,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(result), &envelope); err != nil {
|
||||||
|
flowState.HealthStagnationCount = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !envelope.Success || envelope.Feasibility == nil {
|
||||||
|
flowState.HealthStagnationCount = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flowState.HealthCheckDone = true
|
||||||
|
flowState.HealthIsFeasible = envelope.Feasibility.IsFeasible
|
||||||
|
flowState.HealthCapacityGap = envelope.Feasibility.CapacityGap
|
||||||
|
flowState.HealthReasonCode = strings.TrimSpace(envelope.Feasibility.ReasonCode)
|
||||||
|
if envelope.Metrics.Tightness != nil {
|
||||||
|
flowState.HealthTightnessLevel = strings.TrimSpace(envelope.Metrics.Tightness.TightnessLevel)
|
||||||
|
}
|
||||||
|
if envelope.Decision != nil {
|
||||||
|
flowState.HealthShouldContinueOptimize = envelope.Decision.ShouldContinueOptimize
|
||||||
|
flowState.HealthPrimaryProblem = strings.TrimSpace(envelope.Decision.PrimaryProblem)
|
||||||
|
flowState.HealthRecommendedOperation = strings.TrimSpace(envelope.Decision.RecommendedOperation)
|
||||||
|
flowState.HealthIsForcedImperfection = envelope.Decision.IsForcedImperfection
|
||||||
|
flowState.HealthImprovementSignal = strings.TrimSpace(envelope.Decision.ImprovementSignal)
|
||||||
|
}
|
||||||
|
if signal := strings.TrimSpace(flowState.HealthImprovementSignal); signal != "" && prevSignal != "" && signal == prevSignal {
|
||||||
|
flowState.HealthStagnationCount++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flowState.HealthStagnationCount = 0
|
||||||
|
}
|
||||||
436
backend/newAgent/node/execute/tool_runtime.go
Normal file
436
backend/newAgent/node/execute/tool_runtime.go
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
package newagentexecute
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
newagentshared "github.com/LoveLosita/smartflow/backend/newAgent/shared"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
|
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
|
||||||
|
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func appendToolCallResultHistory(
|
||||||
|
conversationContext *newagentmodel.ConversationContext,
|
||||||
|
toolName string,
|
||||||
|
args map[string]any,
|
||||||
|
result string,
|
||||||
|
) {
|
||||||
|
if conversationContext == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
argsJSON := "{}"
|
||||||
|
if args != nil {
|
||||||
|
if raw, err := json.Marshal(args); err == nil {
|
||||||
|
argsJSON = string(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toolCallID := uuid.NewString()
|
||||||
|
conversationContext.AppendHistory(&schema.Message{
|
||||||
|
Role: schema.Assistant,
|
||||||
|
Content: "",
|
||||||
|
ToolCalls: []schema.ToolCall{
|
||||||
|
{
|
||||||
|
ID: toolCallID,
|
||||||
|
Type: "function",
|
||||||
|
Function: schema.FunctionCall{
|
||||||
|
Name: toolName,
|
||||||
|
Arguments: argsJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
conversationContext.AppendHistory(&schema.Message{
|
||||||
|
Role: schema.Tool,
|
||||||
|
Content: result,
|
||||||
|
ToolCallID: toolCallID,
|
||||||
|
ToolName: toolName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeToolCall(
|
||||||
|
ctx context.Context,
|
||||||
|
flowState *newagentmodel.CommonState,
|
||||||
|
conversationContext *newagentmodel.ConversationContext,
|
||||||
|
toolCall *newagentmodel.ToolCallIntent,
|
||||||
|
emitter *newagentstream.ChunkEmitter,
|
||||||
|
registry *newagenttools.ToolRegistry,
|
||||||
|
scheduleState *schedule.ScheduleState,
|
||||||
|
writePreview newagentmodel.WriteSchedulePreviewFunc,
|
||||||
|
) error {
|
||||||
|
if toolCall == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
toolName := strings.TrimSpace(toolCall.Name)
|
||||||
|
if toolName == "" {
|
||||||
|
return fmt.Errorf("工具调用缺少工具名称")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := emitter.EmitToolCallStart(
|
||||||
|
executeStatusBlockID,
|
||||||
|
executeStageName,
|
||||||
|
toolName,
|
||||||
|
buildToolCallStartSummary(toolName, toolCall.Arguments),
|
||||||
|
buildToolArgumentsPreviewCN(toolCall.Arguments),
|
||||||
|
false,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("工具调用开始事件发送失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if registry == nil {
|
||||||
|
return fmt.Errorf("工具注册表未注入")
|
||||||
|
}
|
||||||
|
if scheduleState == nil && registry.RequiresScheduleState(toolName) {
|
||||||
|
return fmt.Errorf("日程状态未加载,无法执行工具 %q", toolName)
|
||||||
|
}
|
||||||
|
if registry.IsToolTemporarilyDisabled(toolName) {
|
||||||
|
flowState.ConsecutiveCorrections++
|
||||||
|
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||||
|
return fmt.Errorf("连续 %d 次调用临时禁用工具,终止执行: %s",
|
||||||
|
flowState.ConsecutiveCorrections, toolName)
|
||||||
|
}
|
||||||
|
blockedResult := buildTemporarilyDisabledToolResult(toolName)
|
||||||
|
_ = emitter.EmitToolCallResult(
|
||||||
|
executeStatusBlockID,
|
||||||
|
executeStageName,
|
||||||
|
toolName,
|
||||||
|
"blocked",
|
||||||
|
blockedResult,
|
||||||
|
buildToolArgumentsPreviewCN(toolCall.Arguments),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult)
|
||||||
|
newagentshared.AppendLLMCorrectionWithHint(
|
||||||
|
conversationContext,
|
||||||
|
"",
|
||||||
|
fmt.Sprintf("工具 %q 当前暂时禁用。", toolName),
|
||||||
|
"请改用 move/swap/batch_move/unplace 等排程微调工具继续推进。",
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !registry.HasTool(toolName) {
|
||||||
|
flowState.ConsecutiveCorrections++
|
||||||
|
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||||
|
return fmt.Errorf("连续 %d 次调用未知工具,终止执行: %s;可用工具:%s。",
|
||||||
|
flowState.ConsecutiveCorrections, toolName, strings.Join(registry.ToolNames(), "、"))
|
||||||
|
}
|
||||||
|
log.Printf("[WARN] execute 工具名不合法 chat=%s round=%d tool=%s consecutive=%d/%d available=%v",
|
||||||
|
flowState.ConversationID, flowState.RoundUsed, toolName,
|
||||||
|
flowState.ConsecutiveCorrections, maxConsecutiveCorrections, registry.ToolNames())
|
||||||
|
newagentshared.AppendLLMCorrectionWithHint(
|
||||||
|
conversationContext,
|
||||||
|
"",
|
||||||
|
fmt.Sprintf("你调用的工具 %q 不存在。", toolName),
|
||||||
|
fmt.Sprintf("可用工具:%s。请检查拼写后重试。", strings.Join(registry.ToolNames(), "、")),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isToolVisibleForCurrentExecuteMode(flowState, registry, toolName) {
|
||||||
|
flowState.ConsecutiveCorrections++
|
||||||
|
if flowState.ConsecutiveCorrections >= maxConsecutiveCorrections {
|
||||||
|
return fmt.Errorf("连续 %d 次调用未激活工具,终止执行: %s(active_domain=%q active_packs=%v)",
|
||||||
|
flowState.ConsecutiveCorrections,
|
||||||
|
toolName,
|
||||||
|
flowState.ActiveToolDomain,
|
||||||
|
newagenttools.ResolveEffectiveToolPacks(flowState.ActiveToolDomain, flowState.ActiveToolPacks))
|
||||||
|
}
|
||||||
|
|
||||||
|
addHint := `请先调用 context_tools_add 激活目标工具域后再继续。`
|
||||||
|
if flowState != nil && flowState.ActiveOptimizeOnly {
|
||||||
|
addHint = `当前处于“粗排后主动优化专用模式”,只允许使用 analyze_health、move、swap;不要再尝试 query_target_tasks / query_available_slots 等全窗搜索工具。`
|
||||||
|
} else if domain, pack, ok := newagenttools.ResolveToolDomainPack(toolName); ok {
|
||||||
|
if newagenttools.IsFixedToolPack(domain, pack) {
|
||||||
|
addHint = fmt.Sprintf(`请先调用 context_tools_add,参数 domain="%s"。`, domain)
|
||||||
|
} else {
|
||||||
|
addHint = fmt.Sprintf(`请先调用 context_tools_add,参数 domain="%s", packs=["%s"]。`, domain, pack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newagentshared.AppendLLMCorrectionWithHint(
|
||||||
|
conversationContext,
|
||||||
|
"",
|
||||||
|
fmt.Sprintf("你调用的工具 %q 当前不在已激活工具域内。", toolName),
|
||||||
|
addHint,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldForceFeasibilityNegotiation(flowState, registry, toolName) {
|
||||||
|
blockedResult := buildInfeasibleBlockedResult(flowState)
|
||||||
|
_ = emitter.EmitToolCallResult(
|
||||||
|
executeStatusBlockID,
|
||||||
|
executeStageName,
|
||||||
|
toolName,
|
||||||
|
"blocked",
|
||||||
|
blockedResult,
|
||||||
|
buildToolArgumentsPreviewCN(toolCall.Arguments),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, blockedResult)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||||
|
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)
|
||||||
|
updateHealthSnapshotV2(flowState, toolName, result)
|
||||||
|
updateTaskClassUpsertSnapshot(flowState, toolName, result)
|
||||||
|
updateActiveToolDomainSnapshot(flowState, toolName, result)
|
||||||
|
afterDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||||
|
log.Printf(
|
||||||
|
"[DEBUG] execute tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s",
|
||||||
|
flowState.ConversationID,
|
||||||
|
flowState.RoundUsed,
|
||||||
|
toolName,
|
||||||
|
marshalArgsForDebug(toolCall.Arguments),
|
||||||
|
beforeDigest,
|
||||||
|
afterDigest,
|
||||||
|
flattenForLog(result),
|
||||||
|
)
|
||||||
|
_ = emitter.EmitToolCallResult(
|
||||||
|
executeStatusBlockID,
|
||||||
|
executeStageName,
|
||||||
|
toolName,
|
||||||
|
resolveToolEventResultStatus(result),
|
||||||
|
buildToolEventResultSummary(result),
|
||||||
|
buildToolArgumentsPreviewCN(toolCall.Arguments),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, result)
|
||||||
|
|
||||||
|
if registry.IsScheduleMutationTool(toolName) {
|
||||||
|
flowState.HasScheduleWriteOps = true
|
||||||
|
flowState.HasScheduleChanges = true
|
||||||
|
}
|
||||||
|
|
||||||
|
tryWritePreviewAfterWriteTool(ctx, flowState, scheduleState, registry, toolName, writePreview)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPendingContextHook(flowState *newagentmodel.CommonState) {
|
||||||
|
if flowState == nil || flowState.PendingContextHook == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hook := flowState.PendingContextHook
|
||||||
|
domain := newagenttools.NormalizeToolDomain(hook.Domain)
|
||||||
|
if domain == "" {
|
||||||
|
flowState.PendingContextHook = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flowState.ActiveToolDomain = domain
|
||||||
|
flowState.ActiveToolPacks = newagenttools.ResolveEffectiveToolPacks(domain, hook.Packs)
|
||||||
|
flowState.PendingContextHook = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isToolVisibleForCurrentExecuteMode(
|
||||||
|
flowState *newagentmodel.CommonState,
|
||||||
|
registry *newagenttools.ToolRegistry,
|
||||||
|
toolName string,
|
||||||
|
) bool {
|
||||||
|
if registry == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
activeDomain := ""
|
||||||
|
var activePacks []string
|
||||||
|
if flowState != nil {
|
||||||
|
activeDomain = flowState.ActiveToolDomain
|
||||||
|
activePacks = flowState.ActiveToolPacks
|
||||||
|
}
|
||||||
|
if !registry.IsToolVisibleInDomain(activeDomain, activePacks, toolName) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if flowState != nil && flowState.ActiveOptimizeOnly && !newagenttools.IsToolAllowedInActiveOptimize(toolName) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTemporarilyDisabledToolResult(toolName string) string {
|
||||||
|
return fmt.Sprintf("工具 %q 当前暂时禁用。请改用 move/swap/batch_move/unplace 等排程微调工具。", strings.TrimSpace(toolName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func executePendingTool(
|
||||||
|
ctx context.Context,
|
||||||
|
runtimeState *newagentmodel.AgentRuntimeState,
|
||||||
|
conversationContext *newagentmodel.ConversationContext,
|
||||||
|
registry *newagenttools.ToolRegistry,
|
||||||
|
scheduleState *schedule.ScheduleState,
|
||||||
|
originalState *schedule.ScheduleState,
|
||||||
|
writePreview newagentmodel.WriteSchedulePreviewFunc,
|
||||||
|
emitter *newagentstream.ChunkEmitter,
|
||||||
|
) error {
|
||||||
|
pending := runtimeState.PendingConfirmTool
|
||||||
|
if pending == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var args map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(pending.ArgsJSON), &args); err != nil {
|
||||||
|
return fmt.Errorf("解析待确认工具参数失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := emitter.EmitToolCallStart(
|
||||||
|
executeStatusBlockID,
|
||||||
|
executeStageName,
|
||||||
|
pending.ToolName,
|
||||||
|
buildToolCallStartSummary(pending.ToolName, args),
|
||||||
|
buildToolArgumentsPreviewCN(args),
|
||||||
|
false,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("工具调用开始事件发送失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if scheduleState == nil {
|
||||||
|
return fmt.Errorf("日程状态未加载,无法执行已确认的写工具 %s", pending.ToolName)
|
||||||
|
}
|
||||||
|
flowState := runtimeState.EnsureCommonState()
|
||||||
|
if registry.IsToolTemporarilyDisabled(pending.ToolName) {
|
||||||
|
blockedResult := buildTemporarilyDisabledToolResult(pending.ToolName)
|
||||||
|
_ = emitter.EmitToolCallResult(
|
||||||
|
executeStatusBlockID,
|
||||||
|
executeStageName,
|
||||||
|
pending.ToolName,
|
||||||
|
"blocked",
|
||||||
|
blockedResult,
|
||||||
|
buildToolArgumentsPreviewCN(args),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult)
|
||||||
|
runtimeState.PendingConfirmTool = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldForceFeasibilityNegotiation(flowState, registry, pending.ToolName) {
|
||||||
|
blockedResult := buildInfeasibleBlockedResult(flowState)
|
||||||
|
_ = emitter.EmitToolCallResult(
|
||||||
|
executeStatusBlockID,
|
||||||
|
executeStageName,
|
||||||
|
pending.ToolName,
|
||||||
|
"blocked",
|
||||||
|
blockedResult,
|
||||||
|
buildToolArgumentsPreviewCN(args),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
appendToolCallResultHistory(conversationContext, pending.ToolName, args, blockedResult)
|
||||||
|
runtimeState.PendingConfirmTool = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||||
|
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)
|
||||||
|
updateHealthSnapshotV2(flowState, pending.ToolName, result)
|
||||||
|
updateTaskClassUpsertSnapshot(flowState, pending.ToolName, result)
|
||||||
|
updateActiveToolDomainSnapshot(flowState, pending.ToolName, result)
|
||||||
|
afterDigest := summarizeScheduleStateForDebug(scheduleState)
|
||||||
|
log.Printf(
|
||||||
|
"[DEBUG] execute pending tool chat=%s round=%d tool=%s args=%s before=%s after=%s result_preview=%.200s",
|
||||||
|
flowState.ConversationID,
|
||||||
|
flowState.RoundUsed,
|
||||||
|
pending.ToolName,
|
||||||
|
marshalArgsForDebug(args),
|
||||||
|
beforeDigest,
|
||||||
|
afterDigest,
|
||||||
|
flattenForLog(result),
|
||||||
|
)
|
||||||
|
_ = emitter.EmitToolCallResult(
|
||||||
|
executeStatusBlockID,
|
||||||
|
executeStageName,
|
||||||
|
pending.ToolName,
|
||||||
|
resolveToolEventResultStatus(result),
|
||||||
|
buildToolEventResultSummary(result),
|
||||||
|
buildToolArgumentsPreviewCN(args),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
appendToolCallResultHistory(conversationContext, pending.ToolName, args, result)
|
||||||
|
|
||||||
|
if registry.IsScheduleMutationTool(pending.ToolName) {
|
||||||
|
flowState.HasScheduleWriteOps = true
|
||||||
|
flowState.HasScheduleChanges = true
|
||||||
|
}
|
||||||
|
|
||||||
|
tryWritePreviewAfterWriteTool(ctx, flowState, scheduleState, registry, pending.ToolName, writePreview)
|
||||||
|
|
||||||
|
runtimeState.PendingConfirmTool = nil
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryWritePreviewAfterWriteTool(
|
||||||
|
ctx context.Context,
|
||||||
|
flowState *newagentmodel.CommonState,
|
||||||
|
scheduleState *schedule.ScheduleState,
|
||||||
|
registry *newagenttools.ToolRegistry,
|
||||||
|
toolName string,
|
||||||
|
writePreview newagentmodel.WriteSchedulePreviewFunc,
|
||||||
|
) {
|
||||||
|
if flowState == nil || scheduleState == nil || registry == nil || writePreview == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !registry.IsScheduleMutationTool(toolName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writePreview(ctx, scheduleState, flowState.UserID, flowState.ConversationID, flowState.TaskClassIDs); err != nil {
|
||||||
|
log.Printf(
|
||||||
|
"[WARN] execute realtime preview write failed chat=%s tool=%s err=%v",
|
||||||
|
flowState.ConversationID,
|
||||||
|
toolName,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(
|
||||||
|
"[DEBUG] execute realtime preview write success chat=%s tool=%s",
|
||||||
|
flowState.ConversationID,
|
||||||
|
toolName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var listItemRe = regexp.MustCompile(`([^\n])([2-9][\.、]\s)`)
|
||||||
|
|
||||||
|
func normalizeSpeak(speak string) string {
|
||||||
|
speak = strings.TrimSpace(speak)
|
||||||
|
if speak == "" {
|
||||||
|
return speak
|
||||||
|
}
|
||||||
|
if !strings.Contains(speak, "\n") {
|
||||||
|
speak = listItemRe.ReplaceAllString(speak, "$1\n$2")
|
||||||
|
}
|
||||||
|
return speak + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExecuteNormalizedSpeakTail(streamed, normalized string) string {
|
||||||
|
streamed = strings.ReplaceAll(streamed, "\r\n", "\n")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "\r\n", "\n")
|
||||||
|
if streamed == "" || normalized == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(normalized, streamed) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return normalized[len(streamed):]
|
||||||
|
}
|
||||||
420
backend/newAgent/node/execute/tool_view.go
Normal file
420
backend/newAgent/node/execute/tool_view.go
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
package newagentexecute
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||||||
|
)
|
||||||
|
|
||||||
|
func summarizeScheduleStateForDebug(state *schedule.ScheduleState) string {
|
||||||
|
if state == nil {
|
||||||
|
return "state=nil"
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(state.Tasks)
|
||||||
|
pendingNoSlot := 0
|
||||||
|
suggestedTotal := 0
|
||||||
|
existingTotal := 0
|
||||||
|
taskItemWithSlot := 0
|
||||||
|
eventWithSlot := 0
|
||||||
|
|
||||||
|
for i := range state.Tasks {
|
||||||
|
t := &state.Tasks[i]
|
||||||
|
hasSlot := len(t.Slots) > 0
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case schedule.IsPendingTask(*t):
|
||||||
|
pendingNoSlot++
|
||||||
|
case schedule.IsSuggestedTask(*t):
|
||||||
|
suggestedTotal++
|
||||||
|
case schedule.IsExistingTask(*t):
|
||||||
|
existingTotal++
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasSlot {
|
||||||
|
if t.Source == "task_item" {
|
||||||
|
taskItemWithSlot++
|
||||||
|
}
|
||||||
|
if t.Source == "event" {
|
||||||
|
eventWithSlot++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"tasks=%d pending=%d suggested=%d existing=%d task_item_with_slot=%d event_with_slot=%d",
|
||||||
|
total,
|
||||||
|
pendingNoSlot,
|
||||||
|
suggestedTotal,
|
||||||
|
existingTotal,
|
||||||
|
taskItemWithSlot,
|
||||||
|
eventWithSlot,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalArgsForDebug(args map[string]any) string {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return "<marshal_error>"
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenForLog(text string) string {
|
||||||
|
text = strings.ReplaceAll(text, "\n", " ")
|
||||||
|
text = strings.ReplaceAll(text, "\r", " ")
|
||||||
|
return strings.TrimSpace(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveToolEventResultStatus(result string) string {
|
||||||
|
normalized := strings.TrimSpace(result)
|
||||||
|
if normalized == "" {
|
||||||
|
return "done"
|
||||||
|
}
|
||||||
|
if strings.Contains(normalized, "失败") {
|
||||||
|
return "failed"
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(normalized)
|
||||||
|
if strings.Contains(lower, "error") || strings.Contains(lower, "failed") {
|
||||||
|
return "failed"
|
||||||
|
}
|
||||||
|
return "done"
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildToolEventResultSummary(result string) string {
|
||||||
|
flat := flattenForLog(result)
|
||||||
|
if flat == "" {
|
||||||
|
return "工具已执行完成。"
|
||||||
|
}
|
||||||
|
|
||||||
|
if summary, ok := tryExtractToolResultSummaryCN(flat); ok {
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
runes := []rune(flat)
|
||||||
|
if len(runes) <= 48 {
|
||||||
|
return flat
|
||||||
|
}
|
||||||
|
return string(runes[:48]) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryExtractToolResultSummaryCN(raw string) (string, bool) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
toolRaw := strings.TrimSpace(readStringAnyFromMap(payload, "tool"))
|
||||||
|
toolName := resolveToolDisplayNameCN(toolRaw)
|
||||||
|
|
||||||
|
if strings.EqualFold(toolRaw, "upsert_task_class") {
|
||||||
|
if summary, ok := buildUpsertTaskClassSummaryCN(payload); ok {
|
||||||
|
return truncateToolSummaryCN(summary), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errText := strings.TrimSpace(readStringAnyFromMap(payload, "error", "err")); errText != "" {
|
||||||
|
return truncateToolSummaryCN(fmt.Sprintf("%s失败:%s", toolName, errText)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
if success, exists := payload["success"]; exists {
|
||||||
|
if ok, isBool := success.(bool); isBool && !ok {
|
||||||
|
reason := strings.TrimSpace(readStringAnyFromMap(payload, "reason", "message"))
|
||||||
|
if reason != "" {
|
||||||
|
return truncateToolSummaryCN(fmt.Sprintf("%s失败:%s", toolName, reason)), true
|
||||||
|
}
|
||||||
|
return truncateToolSummaryCN(fmt.Sprintf("%s执行失败。", toolName)), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if message := strings.TrimSpace(readStringAnyFromMap(payload, "result", "message", "reason")); message != "" {
|
||||||
|
return truncateToolSummaryCN(message), true
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, hasPending := readIntAnyFromMap(payload, "pending_count")
|
||||||
|
completed, hasCompleted := readIntAnyFromMap(payload, "completed_count")
|
||||||
|
if hasPending || hasCompleted {
|
||||||
|
skipped, _ := readIntAnyFromMap(payload, "skipped_count")
|
||||||
|
return fmt.Sprintf("队列状态:待处理 %d,已完成 %d,已跳过 %d。", pending, completed, skipped), true
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasHead, exists := payload["has_head"]; exists {
|
||||||
|
if b, isBool := hasHead.(bool); isBool {
|
||||||
|
if b {
|
||||||
|
return "已获取当前队首任务。", true
|
||||||
|
}
|
||||||
|
return "当前队列没有可处理任务。", true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := payload["slot_candidates"]; ok {
|
||||||
|
if total, exists := readIntAnyFromMap(payload, "total"); exists {
|
||||||
|
return fmt.Sprintf("共找到 %d 个可用时段。", total), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if toolRaw != "" {
|
||||||
|
return fmt.Sprintf("已完成“%s”操作。", toolName), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUpsertTaskClassSummaryCN(payload map[string]any) (string, bool) {
|
||||||
|
validationRaw, hasValidation := payload["validation"]
|
||||||
|
if !hasValidation {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
validation, ok := validationRaw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
validationOK, hasValidationOK := validation["ok"].(bool)
|
||||||
|
issues := parseAnyToStringSlice(validation["issues"])
|
||||||
|
|
||||||
|
if hasValidationOK && !validationOK {
|
||||||
|
if len(issues) > 0 {
|
||||||
|
return fmt.Sprintf("任务类写入未通过校验:%s。", strings.Join(issues, ";")), true
|
||||||
|
}
|
||||||
|
return "任务类写入未通过校验,请先补齐缺失字段。", true
|
||||||
|
}
|
||||||
|
|
||||||
|
success, hasSuccess := payload["success"].(bool)
|
||||||
|
if hasSuccess && success {
|
||||||
|
if taskClassID, ok := readIntAnyFromMap(payload, "task_class_id"); ok && taskClassID > 0 {
|
||||||
|
return fmt.Sprintf("任务类写入成功,task_class_id=%d。", taskClassID), true
|
||||||
|
}
|
||||||
|
return "任务类写入成功。", true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateToolSummaryCN(text string) string {
|
||||||
|
runes := []rune(strings.TrimSpace(text))
|
||||||
|
if len(runes) <= 48 {
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
return string(runes[:48]) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildToolCallStartSummary(toolName string, args map[string]any) string {
|
||||||
|
displayName := resolveToolDisplayNameCN(toolName)
|
||||||
|
argSummary := buildToolArgumentsPreviewCN(args)
|
||||||
|
if argSummary == "" {
|
||||||
|
return fmt.Sprintf("已调用工具:%s。", displayName)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("已调用工具:%s(%s)。", displayName, argSummary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildToolArgumentsPreviewCN(args map[string]any) string {
|
||||||
|
if len(args) <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type argPair struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
orderedPairs := []argPair{
|
||||||
|
{Key: "title", Label: "任务标题"},
|
||||||
|
{Key: "task_name", Label: "任务名称"},
|
||||||
|
{Key: "deadline_at", Label: "截止时间"},
|
||||||
|
{Key: "new_day", Label: "目标日期"},
|
||||||
|
{Key: "new_slot_start", Label: "目标开始时段"},
|
||||||
|
{Key: "day", Label: "日期"},
|
||||||
|
{Key: "day_start", Label: "开始日"},
|
||||||
|
{Key: "day_end", Label: "结束日"},
|
||||||
|
{Key: "day_scope", Label: "日期范围"},
|
||||||
|
{Key: "day_of_week", Label: "星期"},
|
||||||
|
{Key: "week", Label: "周"},
|
||||||
|
{Key: "week_from", Label: "起始周"},
|
||||||
|
{Key: "week_to", Label: "结束周"},
|
||||||
|
{Key: "week_filter", Label: "周筛选"},
|
||||||
|
{Key: "slot_start", Label: "开始时段"},
|
||||||
|
{Key: "slot_end", Label: "结束时段"},
|
||||||
|
{Key: "slot_type", Label: "时段类型"},
|
||||||
|
{Key: "slot_types", Label: "时段类型"},
|
||||||
|
{Key: "task_id", Label: "任务 ID"},
|
||||||
|
{Key: "task_ids", Label: "任务 ID 列表"},
|
||||||
|
{Key: "task_item_id", Label: "任务项 ID"},
|
||||||
|
{Key: "task_item_ids", Label: "任务项 ID 列表"},
|
||||||
|
{Key: "query", Label: "查询词"},
|
||||||
|
{Key: "keyword", Label: "关键词"},
|
||||||
|
{Key: "domain", Label: "工具域"},
|
||||||
|
{Key: "mode", Label: "激活模式"},
|
||||||
|
{Key: "all", Label: "移除全部"},
|
||||||
|
{Key: "top_k", Label: "返回数量"},
|
||||||
|
{Key: "url", Label: "链接"},
|
||||||
|
{Key: "reason", Label: "原因"},
|
||||||
|
{Key: "limit", Label: "数量"},
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]string, 0, 2)
|
||||||
|
for _, pair := range orderedPairs {
|
||||||
|
rawValue, exists := args[pair.Key]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valueText := formatToolArgValueByKeyCN(pair.Key, rawValue)
|
||||||
|
if valueText == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, fmt.Sprintf("%s:%s", pair.Label, valueText))
|
||||||
|
if len(items) >= 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(items, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveToolDisplayNameCN(toolName string) string {
|
||||||
|
name := strings.TrimSpace(toolName)
|
||||||
|
if name == "" {
|
||||||
|
return "未知工具"
|
||||||
|
}
|
||||||
|
|
||||||
|
displayNameMap := map[string]string{
|
||||||
|
"get_overview": "查看总览",
|
||||||
|
"query_range": "查询时间范围",
|
||||||
|
"queue_status": "查看任务队列",
|
||||||
|
"queue_pop_head": "获取队首任务",
|
||||||
|
"queue_apply_head_move": "应用队首任务时段",
|
||||||
|
"queue_skip_head": "跳过队首任务",
|
||||||
|
"query_target_tasks": "查询目标任务",
|
||||||
|
"query_available_slots": "查询可用时段",
|
||||||
|
"get_task_info": "查看任务信息",
|
||||||
|
"analyze_health": "综合体检",
|
||||||
|
"analyze_rhythm": "分析学习节律",
|
||||||
|
"web_search": "网页搜索",
|
||||||
|
"web_fetch": "网页抓取",
|
||||||
|
"move": "移动任务",
|
||||||
|
"place": "放置任务",
|
||||||
|
"swap": "交换任务",
|
||||||
|
"batch_move": "批量移动任务",
|
||||||
|
"unplace": "移出任务安排",
|
||||||
|
"upsert_task_class": "写入任务类",
|
||||||
|
"context_tools_add": "激活工具域",
|
||||||
|
"context_tools_remove": "移除工具域",
|
||||||
|
}
|
||||||
|
|
||||||
|
if label, ok := displayNameMap[name]; ok {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatToolArgValueByKeyCN(key string, value any) string {
|
||||||
|
switch key {
|
||||||
|
case "day_scope":
|
||||||
|
scope := strings.ToLower(strings.TrimSpace(formatToolArgValueCN(value)))
|
||||||
|
switch scope {
|
||||||
|
case "workday":
|
||||||
|
return "工作日"
|
||||||
|
case "weekend":
|
||||||
|
return "周末"
|
||||||
|
case "all":
|
||||||
|
return "全部日期"
|
||||||
|
default:
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
case "day_of_week":
|
||||||
|
weekdays := parseAnyToIntSlice(value)
|
||||||
|
if len(weekdays) <= 0 {
|
||||||
|
return formatToolArgValueCN(value)
|
||||||
|
}
|
||||||
|
labels := make([]string, 0, len(weekdays))
|
||||||
|
for _, day := range weekdays {
|
||||||
|
labels = append(labels, fmt.Sprintf("周%d", day))
|
||||||
|
if len(labels) >= 4 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(labels, "、")
|
||||||
|
case "task_ids", "task_item_ids", "week_filter":
|
||||||
|
values := parseAnyToIntSlice(value)
|
||||||
|
if len(values) <= 0 {
|
||||||
|
return formatToolArgValueCN(value)
|
||||||
|
}
|
||||||
|
items := make([]string, 0, len(values))
|
||||||
|
for _, current := range values {
|
||||||
|
items = append(items, strconv.Itoa(current))
|
||||||
|
if len(items) >= 4 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(items, "、")
|
||||||
|
case "url":
|
||||||
|
return truncateToolSummaryCN(formatToolArgValueCN(value))
|
||||||
|
case "reason", "title", "task_name", "query", "keyword":
|
||||||
|
return truncateToolSummaryCN(formatToolArgValueCN(value))
|
||||||
|
default:
|
||||||
|
return formatToolArgValueCN(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatToolArgValueCN(value any) string {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
text := strings.TrimSpace(v)
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
case int:
|
||||||
|
return strconv.Itoa(v)
|
||||||
|
case int8:
|
||||||
|
return strconv.Itoa(int(v))
|
||||||
|
case int16:
|
||||||
|
return strconv.Itoa(int(v))
|
||||||
|
case int32:
|
||||||
|
return strconv.Itoa(int(v))
|
||||||
|
case int64:
|
||||||
|
return strconv.Itoa(int(v))
|
||||||
|
case float32:
|
||||||
|
return strings.TrimSpace(strconv.FormatFloat(float64(v), 'f', -1, 32))
|
||||||
|
case float64:
|
||||||
|
return strings.TrimSpace(strconv.FormatFloat(v, 'f', -1, 64))
|
||||||
|
case bool:
|
||||||
|
if v {
|
||||||
|
return "是"
|
||||||
|
}
|
||||||
|
return "否"
|
||||||
|
case []any:
|
||||||
|
values := make([]string, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
text := formatToolArgValueCN(item)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values = append(values, text)
|
||||||
|
if len(values) >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(values, "、")
|
||||||
|
default:
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||||
|
if text == "" || text == "<nil>" || text == "map[]" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
21
backend/newAgent/node/speak_text.go
Normal file
21
backend/newAgent/node/speak_text.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package newagentnode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listItemRe 匹配被粘连在一起的列表序号,用于正文归一化时自动补换行。
|
||||||
|
var listItemRe = regexp.MustCompile(`([^\n])([2-9][\.、]\s)`)
|
||||||
|
|
||||||
|
// normalizeSpeak 统一整理要展示给用户的正文。
|
||||||
|
func normalizeSpeak(speak string) string {
|
||||||
|
speak = strings.TrimSpace(speak)
|
||||||
|
if speak == "" {
|
||||||
|
return speak
|
||||||
|
}
|
||||||
|
if !strings.Contains(speak, "\n") {
|
||||||
|
speak = listItemRe.ReplaceAllString(speak, "$1\n$2")
|
||||||
|
}
|
||||||
|
return speak + "\n"
|
||||||
|
}
|
||||||
117
backend/newAgent/shared/node_correction.go
Normal file
117
backend/newAgent/shared/node_correction.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package newagentshared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
correctionHistoryKindKey = "newagent_history_kind"
|
||||||
|
correctionHistoryKindCorrectionUser = "llm_correction_prompt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppendLLMCorrection 追加 LLM 修正提示到对话历史。
|
||||||
|
//
|
||||||
|
// 设计目的:
|
||||||
|
// 1. 当 LLM 输出不符合预期(如不支持的 action、格式错误等),不应直接报错终止;
|
||||||
|
// 2. 应该给 LLM 一个自我修正的机会,把错误反馈写回历史,让它重新生成;
|
||||||
|
// 3. 该函数封装了“追加 assistant 消息 + 追加纠正提示”的通用流程。
|
||||||
|
//
|
||||||
|
// 参数说明:
|
||||||
|
// - conversationContext: 对话上下文,用于追加历史消息;
|
||||||
|
// - llmOutput: LLM 的原始输出内容,会作为 assistant 消息追加;
|
||||||
|
// - validOptionsDesc: 合法选项的描述,用于构造纠正提示。
|
||||||
|
func AppendLLMCorrection(
|
||||||
|
conversationContext *newagentmodel.ConversationContext,
|
||||||
|
llmOutput string,
|
||||||
|
validOptionsDesc string,
|
||||||
|
) {
|
||||||
|
if conversationContext == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assistantContent := strings.TrimSpace(llmOutput)
|
||||||
|
appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
|
||||||
|
|
||||||
|
correctionContent := fmt.Sprintf(
|
||||||
|
"你的输出不符合预期。%s 请重新分析当前状态,输出正确的内容。",
|
||||||
|
validOptionsDesc,
|
||||||
|
)
|
||||||
|
conversationContext.AppendHistory(&schema.Message{
|
||||||
|
Role: schema.User,
|
||||||
|
Content: correctionContent,
|
||||||
|
Extra: map[string]any{
|
||||||
|
correctionHistoryKindKey: correctionHistoryKindCorrectionUser,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendLLMCorrectionWithHint 追加 LLM 修正提示(带自定义错误描述)。
|
||||||
|
//
|
||||||
|
// 相比 AppendLLMCorrection,该函数允许调用方提供更详细的错误描述,
|
||||||
|
// 适用于需要明确告知 LLM 具体哪里出错的场景。
|
||||||
|
func AppendLLMCorrectionWithHint(
|
||||||
|
conversationContext *newagentmodel.ConversationContext,
|
||||||
|
llmOutput string,
|
||||||
|
errorDesc string,
|
||||||
|
validOptionsDesc string,
|
||||||
|
) {
|
||||||
|
if conversationContext == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assistantContent := strings.TrimSpace(llmOutput)
|
||||||
|
appendCorrectionAssistantIfNeeded(conversationContext, assistantContent)
|
||||||
|
|
||||||
|
correctionContent := fmt.Sprintf(
|
||||||
|
"%s %s 请重新分析当前状态,输出正确的内容。",
|
||||||
|
errorDesc,
|
||||||
|
validOptionsDesc,
|
||||||
|
)
|
||||||
|
conversationContext.AppendHistory(&schema.Message{
|
||||||
|
Role: schema.User,
|
||||||
|
Content: correctionContent,
|
||||||
|
Extra: map[string]any{
|
||||||
|
correctionHistoryKindKey: correctionHistoryKindCorrectionUser,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendCorrectionAssistantIfNeeded 在纠错回灌前做最小降噪。
|
||||||
|
//
|
||||||
|
// 1. 空文本直接跳过,避免写入“占位噪音”;
|
||||||
|
// 2. 若与“最近一条 assistant 文本”完全一致则跳过,避免同句反复回灌;
|
||||||
|
// 3. 仅负责“是否回灌”判定,不负责生成纠错 user 提示。
|
||||||
|
func appendCorrectionAssistantIfNeeded(
|
||||||
|
conversationContext *newagentmodel.ConversationContext,
|
||||||
|
assistantContent string,
|
||||||
|
) {
|
||||||
|
if conversationContext == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assistantContent = strings.TrimSpace(assistantContent)
|
||||||
|
if assistantContent == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
history := conversationContext.HistorySnapshot()
|
||||||
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
|
msg := history[i]
|
||||||
|
if msg == nil || msg.Role != schema.Assistant {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(msg.Content) == assistantContent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 只看最近一条 assistant,避免误去重很久以前的正常重复表达。
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
conversationContext.AppendHistory(&schema.Message{
|
||||||
|
Role: schema.Assistant,
|
||||||
|
Content: assistantContent,
|
||||||
|
})
|
||||||
|
}
|
||||||
121
backend/newAgent/shared/node_llm_debug.go
Normal file
121
backend/newAgent/shared/node_llm_debug.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package newagentshared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogNodeLLMContext 将某个节点即将送入 LLM 的完整消息上下文按统一格式打印到日志。
|
||||||
|
//
|
||||||
|
// 步骤化说明:
|
||||||
|
// 1. 统一输出 stage / phase / chat / round,方便按一次请求内的多次 LLM 调用串联排查;
|
||||||
|
// 2. 完整展开 messages,不做截断,保证问题复现时能直接对照 prompt 组装结果;
|
||||||
|
// 3. 该函数只负责调试日志,不参与任何业务判断,也不修改上下文内容。
|
||||||
|
func LogNodeLLMContext(
|
||||||
|
stage string,
|
||||||
|
phase string,
|
||||||
|
flowState *newagentmodel.CommonState,
|
||||||
|
messages []*schema.Message,
|
||||||
|
) {
|
||||||
|
chatID := ""
|
||||||
|
roundUsed := 0
|
||||||
|
if flowState != nil {
|
||||||
|
chatID = flowState.ConversationID
|
||||||
|
roundUsed = flowState.RoundUsed
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(
|
||||||
|
"[DEBUG] %s LLM context begin phase=%s chat=%s round=%d message_count=%d\n%s\n[DEBUG] %s LLM context end phase=%s chat=%s round=%d",
|
||||||
|
stage,
|
||||||
|
strings.TrimSpace(phase),
|
||||||
|
chatID,
|
||||||
|
roundUsed,
|
||||||
|
len(messages),
|
||||||
|
formatLLMMessagesForDebug(messages),
|
||||||
|
stage,
|
||||||
|
strings.TrimSpace(phase),
|
||||||
|
chatID,
|
||||||
|
roundUsed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatLLMMessagesForDebug 将本轮送入 LLM 的完整消息上下文展开成可读多行日志。
|
||||||
|
//
|
||||||
|
// 说明:
|
||||||
|
// 1. 按消息索引逐条输出,便于和上游上下文构造步骤逐项对齐;
|
||||||
|
// 2. 完整输出 content / reasoning_content / tool_calls / extra,不做截断;
|
||||||
|
// 3. 仅用于调试打点,不参与业务决策。
|
||||||
|
func formatLLMMessagesForDebug(messages []*schema.Message) string {
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return "(empty messages)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for i, msg := range messages {
|
||||||
|
sb.WriteString(fmt.Sprintf("----- message[%d] -----\n", i))
|
||||||
|
if msg == nil {
|
||||||
|
sb.WriteString("role: <nil>\n\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("role: %s\n", msg.Role))
|
||||||
|
|
||||||
|
if strings.TrimSpace(msg.ToolCallID) != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("tool_call_id: %s\n", msg.ToolCallID))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(msg.ToolName) != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("tool_name: %s\n", msg.ToolName))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.ToolCalls) > 0 {
|
||||||
|
sb.WriteString("tool_calls:\n")
|
||||||
|
for j, call := range msg.ToolCalls {
|
||||||
|
sb.WriteString(fmt.Sprintf(" - [%d] id=%s type=%s function=%s\n", j, call.ID, call.Type, call.Function.Name))
|
||||||
|
sb.WriteString(" arguments:\n")
|
||||||
|
sb.WriteString(indentMultilineForDebug(call.Function.Arguments, " "))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(msg.ReasoningContent) != "" {
|
||||||
|
sb.WriteString("reasoning_content:\n")
|
||||||
|
sb.WriteString(indentMultilineForDebug(msg.ReasoningContent, " "))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("content:\n")
|
||||||
|
sb.WriteString(indentMultilineForDebug(msg.Content, " "))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
if len(msg.Extra) > 0 {
|
||||||
|
sb.WriteString("extra:\n")
|
||||||
|
raw, err := json.MarshalIndent(msg.Extra, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
sb.WriteString(indentMultilineForDebug("<marshal_error>", " "))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(indentMultilineForDebug(string(raw), " "))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// indentMultilineForDebug 为多行文本统一添加前缀缩进,避免日志折行后难以阅读。
|
||||||
|
func indentMultilineForDebug(text, prefix string) string {
|
||||||
|
if text == "" {
|
||||||
|
return prefix + "<empty>"
|
||||||
|
}
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
for i := range lines {
|
||||||
|
lines[i] = prefix + lines[i]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
10
backend/newAgent/shared/node_thinking.go
Normal file
10
backend/newAgent/shared/node_thinking.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package newagentshared
|
||||||
|
|
||||||
|
import infrallm "github.com/LoveLosita/smartflow/backend/infra/llm"
|
||||||
|
|
||||||
|
func ResolveThinkingMode(enabled bool) infrallm.ThinkingMode {
|
||||||
|
if enabled {
|
||||||
|
return infrallm.ThinkingModeEnabled
|
||||||
|
}
|
||||||
|
return infrallm.ThinkingModeDisabled
|
||||||
|
}
|
||||||
290
backend/newAgent/shared/node_unified_compact.go
Normal file
290
backend/newAgent/shared/node_unified_compact.go
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
package newagentshared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
infrallm "github.com/LoveLosita/smartflow/backend/infra/llm"
|
||||||
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
|
newagentprompt "github.com/LoveLosita/smartflow/backend/newAgent/prompt"
|
||||||
|
newagentstream "github.com/LoveLosita/smartflow/backend/newAgent/stream"
|
||||||
|
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnifiedCompactInput 是统一压缩入口的参数。
|
||||||
|
//
|
||||||
|
// 设计说明:
|
||||||
|
// 1. 从各节点输入中提取压缩所需的公共字段,消除对具体节点实现的直接依赖;
|
||||||
|
// 2. 各节点(Plan/Chat/Deliver/Execute)构造此参数时,只需填充自己已有的运行时能力;
|
||||||
|
// 3. StageName 和 StatusBlockID 用于区分日志来源与 SSE 状态推送目标。
|
||||||
|
type UnifiedCompactInput struct {
|
||||||
|
// Client 用于调用 LLM 压缩 msg1/msg2。
|
||||||
|
Client *infrallm.Client
|
||||||
|
// CompactionStore 用于持久化压缩摘要和 token 统计,为 nil 时跳过持久化。
|
||||||
|
CompactionStore newagentmodel.CompactionStore
|
||||||
|
// FlowState 提供 userID / conversationID / roundUsed 等定位信息。
|
||||||
|
FlowState *newagentmodel.CommonState
|
||||||
|
// Emitter 用于推送压缩进度 SSE 事件。
|
||||||
|
Emitter *newagentstream.ChunkEmitter
|
||||||
|
// StageName 标识当前阶段,如 execute / plan / chat / deliver。
|
||||||
|
StageName string
|
||||||
|
// StatusBlockID 是 SSE 状态推送的 block ID,各节点使用自己的 block ID。
|
||||||
|
StatusBlockID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompactUnifiedMessagesIfNeeded 检查统一消息结构的 token 预算,
|
||||||
|
// 超限时对 msg1(历史对话)和 msg2(阶段工作区)执行 LLM 压缩。
|
||||||
|
//
|
||||||
|
// 消息布局约定(由统一消息构造器返回):
|
||||||
|
// [0] system - msg0: 系统规则 + 工具简表
|
||||||
|
// [1] assistant - msg1: 历史对话上下文
|
||||||
|
// [2] assistant - msg2: 阶段工作区(Execute=ReAct Loop,其余通常为“暂无”)
|
||||||
|
// [3] system - msg3: 阶段状态 + 记忆 + 指令
|
||||||
|
//
|
||||||
|
// 压缩策略:
|
||||||
|
// 1. msg1 超过可用预算一半时触发 LLM 压缩(合并已有摘要 + 新内容);
|
||||||
|
// 2. msg1 压缩后仍超限,则对 msg2 也做 LLM 压缩;
|
||||||
|
// 3. 压缩结果持久化到 CompactionStore,下一轮可复用摘要避免重复计算。
|
||||||
|
func CompactUnifiedMessagesIfNeeded(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []*schema.Message,
|
||||||
|
input UnifiedCompactInput,
|
||||||
|
) []*schema.Message {
|
||||||
|
if input.FlowState == nil {
|
||||||
|
log.Printf("[COMPACT:%s] FlowState is nil, skip token stats refresh", input.StageName)
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 非严格 4 段式时,退化成按角色汇总的统计,确保 context_token_stats 仍能刷新。
|
||||||
|
if len(messages) != 4 {
|
||||||
|
breakdown := estimateFallbackStageTokenBreakdown(messages)
|
||||||
|
log.Printf(
|
||||||
|
"[COMPACT:%s] fallback token stats refresh: total=%d budget=%d count=%d (msg0=%d msg1=%d msg2=%d msg3=%d)",
|
||||||
|
input.StageName, breakdown.Total, breakdown.Budget, len(messages),
|
||||||
|
breakdown.Msg0, breakdown.Msg1, breakdown.Msg2, breakdown.Msg3,
|
||||||
|
)
|
||||||
|
saveUnifiedTokenStats(ctx, input, breakdown)
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 提取四条消息的文本内容,供预算检查与后续压缩使用。
|
||||||
|
msg0 := messages[0].Content
|
||||||
|
msg1 := messages[1].Content
|
||||||
|
msg2 := messages[2].Content
|
||||||
|
msg3 := messages[3].Content
|
||||||
|
|
||||||
|
// 3. 执行 token 预算检查,判断是否需要压缩历史对话或阶段工作区。
|
||||||
|
breakdown, overBudget, needCompactMsg1, needCompactMsg2 := pkg.CheckStageTokenBudget(msg0, msg1, msg2, msg3)
|
||||||
|
|
||||||
|
log.Printf(
|
||||||
|
"[COMPACT:%s] token budget check: total=%d budget=%d over=%v compactMsg1=%v compactMsg2=%v (msg0=%d msg1=%d msg2=%d msg3=%d)",
|
||||||
|
input.StageName, breakdown.Total, breakdown.Budget, overBudget, needCompactMsg1, needCompactMsg2,
|
||||||
|
breakdown.Msg0, breakdown.Msg1, breakdown.Msg2, breakdown.Msg3,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !overBudget {
|
||||||
|
// 4. 未超限时仅记录 token 分布,不做压缩。
|
||||||
|
saveUnifiedTokenStats(ctx, input, breakdown)
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 先压缩 msg1(历史对话),它通常是最主要的 token 消耗来源。
|
||||||
|
if needCompactMsg1 {
|
||||||
|
msg1 = compactUnifiedMsg1(ctx, input, msg1)
|
||||||
|
messages[1].Content = msg1
|
||||||
|
breakdown = pkg.EstimateStageMessagesTokens(msg0, msg1, msg2, msg3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 若 msg1 压缩后仍超限,再压缩 msg2(阶段工作区 / ReAct 记录)。
|
||||||
|
if needCompactMsg2 || breakdown.Total > pkg.StageTokenBudget {
|
||||||
|
msg2 = compactUnifiedMsg2(ctx, input, msg2)
|
||||||
|
messages[2].Content = msg2
|
||||||
|
breakdown = pkg.EstimateStageMessagesTokens(msg0, msg1, msg2, msg3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 记录最终 token 分布,供后续调试与监控使用。
|
||||||
|
saveUnifiedTokenStats(ctx, input, breakdown)
|
||||||
|
|
||||||
|
log.Printf(
|
||||||
|
"[COMPACT:%s] after compaction: total=%d budget=%d (msg0=%d msg1=%d msg2=%d msg3=%d)",
|
||||||
|
input.StageName, breakdown.Total, breakdown.Budget,
|
||||||
|
breakdown.Msg0, breakdown.Msg1, breakdown.Msg2, breakdown.Msg3,
|
||||||
|
)
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// estimateFallbackStageTokenBreakdown 在非统一 4 段式场景下按消息角色做近似统计。
|
||||||
|
//
|
||||||
|
// 步骤说明:
|
||||||
|
// 1. 先按消息类型汇总 token,保证总量准确;
|
||||||
|
// 2. 再把最后一个 user 消息尽量视作 msg3,保留阶段指令语义;
|
||||||
|
// 3. 其他历史内容归入 msg1 / msg2,确保上下文统计不会因为结构不标准而断更。
|
||||||
|
func estimateFallbackStageTokenBreakdown(messages []*schema.Message) pkg.StageTokenBreakdown {
|
||||||
|
breakdown := pkg.StageTokenBreakdown{Budget: pkg.StageTokenBudget}
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return breakdown
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUserIndex := -1
|
||||||
|
for i := len(messages) - 1; i >= 0; i-- {
|
||||||
|
msg := messages[i]
|
||||||
|
if msg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if msg.Role == schema.User {
|
||||||
|
lastUserIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, msg := range messages {
|
||||||
|
if msg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tokens := pkg.EstimateMessageTokens(msg)
|
||||||
|
breakdown.Total += tokens
|
||||||
|
|
||||||
|
switch msg.Role {
|
||||||
|
case schema.System:
|
||||||
|
breakdown.Msg0 += tokens
|
||||||
|
case schema.User:
|
||||||
|
if i == lastUserIndex {
|
||||||
|
breakdown.Msg3 += tokens
|
||||||
|
} else {
|
||||||
|
breakdown.Msg1 += tokens
|
||||||
|
}
|
||||||
|
case schema.Tool:
|
||||||
|
breakdown.Msg2 += tokens
|
||||||
|
case schema.Assistant:
|
||||||
|
if len(msg.ToolCalls) > 0 {
|
||||||
|
breakdown.Msg2 += tokens
|
||||||
|
} else {
|
||||||
|
breakdown.Msg1 += tokens
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
breakdown.Msg1 += tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return breakdown
|
||||||
|
}
|
||||||
|
|
||||||
|
// compactUnifiedMsg1 对 msg1(历史对话)执行 LLM 压缩。
|
||||||
|
//
|
||||||
|
// 步骤化说明:
|
||||||
|
// 1. CompactionStore 为 nil 时跳过(测试环境 / 骨架期);
|
||||||
|
// 2. 先加载该阶段已有的压缩摘要,与当前 msg1 合并后调 LLM 压缩;
|
||||||
|
// 3. 压缩失败时降级为原始文本,不中断主流程;
|
||||||
|
// 4. 压缩成功后持久化新摘要,供下一轮复用。
|
||||||
|
func compactUnifiedMsg1(
|
||||||
|
ctx context.Context,
|
||||||
|
input UnifiedCompactInput,
|
||||||
|
msg1 string,
|
||||||
|
) string {
|
||||||
|
if input.CompactionStore == nil {
|
||||||
|
log.Printf("[COMPACT:%s] CompactionStore is nil, skip msg1 compaction", input.StageName)
|
||||||
|
return msg1
|
||||||
|
}
|
||||||
|
|
||||||
|
existingSummary, _, err := input.CompactionStore.LoadStageCompaction(ctx, input.FlowState.UserID, input.FlowState.ConversationID, input.StageName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[COMPACT:%s] load existing compaction failed: %v, proceed without cache", input.StageName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenBefore := pkg.EstimateTextTokens(msg1)
|
||||||
|
_ = input.Emitter.EmitStatus(
|
||||||
|
input.StatusBlockID, input.StageName, "context_compact_start",
|
||||||
|
fmt.Sprintf("正在压缩对话历史(%d tokens)...", tokenBefore),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
newSummary, err := newagentprompt.CompactMsg1(ctx, input.Client, msg1, existingSummary)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[COMPACT:%s] compact msg1 failed: %v", input.StageName, err)
|
||||||
|
_ = input.Emitter.EmitStatus(
|
||||||
|
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||||
|
"对话历史压缩失败,使用原始文本",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
return msg1
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenAfter := pkg.EstimateTextTokens(newSummary)
|
||||||
|
_ = input.Emitter.EmitStatus(
|
||||||
|
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||||
|
fmt.Sprintf("对话历史已压缩:%d → %d tokens", tokenBefore, tokenAfter),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := input.CompactionStore.SaveStageCompaction(ctx, input.FlowState.UserID, input.FlowState.ConversationID, input.StageName, newSummary, input.FlowState.RoundUsed); err != nil {
|
||||||
|
log.Printf("[COMPACT:%s] save compaction failed: %v", input.StageName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
// compactUnifiedMsg2 对 msg2(阶段工作区)执行 LLM 压缩。
|
||||||
|
//
|
||||||
|
// 步骤化说明:
|
||||||
|
// 1. 非 Execute 阶段的 msg2 通常内容较少,压缩即使收益有限也不应出错;
|
||||||
|
// 2. Execute 阶段的 msg2 包含 ReAct loop 记录,压缩可显著节省 token;
|
||||||
|
// 3. 压缩失败时降级为原始文本,不中断主流程。
|
||||||
|
func compactUnifiedMsg2(
|
||||||
|
ctx context.Context,
|
||||||
|
input UnifiedCompactInput,
|
||||||
|
msg2 string,
|
||||||
|
) string {
|
||||||
|
tokenBefore := pkg.EstimateTextTokens(msg2)
|
||||||
|
_ = input.Emitter.EmitStatus(
|
||||||
|
input.StatusBlockID, input.StageName, "context_compact_start",
|
||||||
|
fmt.Sprintf("正在压缩执行记录(%d tokens)...", tokenBefore),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
compressed, err := newagentprompt.CompactMsg2(ctx, input.Client, msg2)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[COMPACT:%s] compact msg2 failed: %v", input.StageName, err)
|
||||||
|
_ = input.Emitter.EmitStatus(
|
||||||
|
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||||
|
"执行记录压缩失败,使用原始文本",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
return msg2
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenAfter := pkg.EstimateTextTokens(compressed)
|
||||||
|
_ = input.Emitter.EmitStatus(
|
||||||
|
input.StatusBlockID, input.StageName, "context_compact_done",
|
||||||
|
fmt.Sprintf("执行记录已压缩:%d → %d tokens", tokenBefore, tokenAfter),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
return compressed
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveUnifiedTokenStats 持久化当前 token 分布到存储层。
|
||||||
|
//
|
||||||
|
// 步骤化说明:
|
||||||
|
// 1. CompactionStore 为 nil 时跳过(测试环境 / 骨架期);
|
||||||
|
// 2. 序列化失败只记日志,不中断主流程;
|
||||||
|
// 3. 写入失败只记日志,不中断主流程。
|
||||||
|
func saveUnifiedTokenStats(
|
||||||
|
ctx context.Context,
|
||||||
|
input UnifiedCompactInput,
|
||||||
|
breakdown pkg.StageTokenBreakdown,
|
||||||
|
) {
|
||||||
|
if input.CompactionStore == nil || input.FlowState == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statsJSON, err := json.Marshal(breakdown)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[COMPACT:%s] marshal token stats failed: %v", input.StageName, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := input.CompactionStore.SaveContextTokenStats(ctx, input.FlowState.UserID, input.FlowState.ConversationID, string(statsJSON)); err != nil {
|
||||||
|
log.Printf("[COMPACT:%s] save token stats failed: %v", input.StageName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/newAgent/shared/node_visible_message.go
Normal file
37
backend/newAgent/shared/node_visible_message.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package newagentshared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PersistVisibleAssistantMessage 负责把“真正要展示给用户”的 assistant 文本交给 service 层持久化。
|
||||||
|
//
|
||||||
|
// 职责边界:
|
||||||
|
// 1. 只处理可见的 assistant 消息,不处理内部纠错提示、工具调用结果和纯状态文案;
|
||||||
|
// 2. 持久化失败只记日志,不反向中断节点主流程,避免“已经对外输出但后端补写失败”时把用户请求打断;
|
||||||
|
// 3. 具体的 Redis / MySQL / 乐观缓存写入由 service 回调统一完成。
|
||||||
|
func PersistVisibleAssistantMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
persist newagentmodel.PersistVisibleMessageFunc,
|
||||||
|
state *newagentmodel.CommonState,
|
||||||
|
msg *schema.Message,
|
||||||
|
) {
|
||||||
|
if persist == nil || state == nil || msg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role := strings.TrimSpace(string(msg.Role))
|
||||||
|
content := strings.TrimSpace(msg.Content)
|
||||||
|
if role != string(schema.Assistant) || content == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := persist(ctx, state, msg); err != nil {
|
||||||
|
log.Printf("[WARN] persist visible assistant message failed chat=%s phase=%s err=%v", state.ConversationID, state.Phase, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
720
docs/frontend/newagent_business_card_对接说明.md
Normal file
720
docs/frontend/newagent_business_card_对接说明.md
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
# NewAgent 业务卡片前后端对接说明
|
||||||
|
|
||||||
|
## 1. 文档目标
|
||||||
|
|
||||||
|
本文用于约定 NewAgent 聊天时间线中的“业务结果卡片”协议,供前端先行实现卡片容器与渲染逻辑,后端后续按同一标准补齐事件发射。
|
||||||
|
|
||||||
|
本次只覆盖两类卡片:
|
||||||
|
|
||||||
|
1. 查询任务卡片
|
||||||
|
2. 任务记录卡片
|
||||||
|
|
||||||
|
其中“任务记录卡片”统一承载以下两个入口语义:
|
||||||
|
|
||||||
|
- 随口记
|
||||||
|
- 创建任务
|
||||||
|
|
||||||
|
这样做的原因是:两者最终落到前端展示时,表达的都是“系统中新增了一条任务/提醒”,如果硬拆成两套完全独立协议,会造成字段重复、渲染重复和样式分叉。
|
||||||
|
|
||||||
|
## 2. 适用范围
|
||||||
|
|
||||||
|
本说明只约定聊天时间线中的结构化卡片事件,不重做整套聊天 UI,也不影响现有:
|
||||||
|
|
||||||
|
- `assistant_text`
|
||||||
|
- `tool_call`
|
||||||
|
- `tool_result`
|
||||||
|
- `confirm_request`
|
||||||
|
- `schedule_completed`
|
||||||
|
|
||||||
|
现阶段建议复用“像 schedule_completed 一样通过 extra 事件驱动前端卡片”的模式,但不复用 `schedule_completed` 这个具体 kind。
|
||||||
|
|
||||||
|
## 3. 总体设计原则
|
||||||
|
|
||||||
|
### 3.1 业务卡片走独立事件
|
||||||
|
|
||||||
|
业务卡片不应伪装成:
|
||||||
|
|
||||||
|
- `status`
|
||||||
|
- `tool_result`
|
||||||
|
- `schedule_completed`
|
||||||
|
|
||||||
|
原因如下:
|
||||||
|
|
||||||
|
1. `status` 更适合阶段提示,不适合承载稳定业务结果。
|
||||||
|
2. `tool_result` 更适合工具过程回执,不适合承载用户真正关心的结果实体。
|
||||||
|
3. `schedule_completed` 是“排程完成信号卡”,语义过窄,不适合继续复用到任务域。
|
||||||
|
|
||||||
|
因此,本次建议新增统一事件类型:
|
||||||
|
|
||||||
|
- `business_card`
|
||||||
|
|
||||||
|
### 3.2 统一入口,卡片内再分类型
|
||||||
|
|
||||||
|
后端统一发:
|
||||||
|
|
||||||
|
- `kind = business_card`
|
||||||
|
- `display_mode = card`
|
||||||
|
|
||||||
|
再在 payload 中细分:
|
||||||
|
|
||||||
|
- `card_type = task_query`
|
||||||
|
- `card_type = task_record`
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- `task_query` 表示“查到了什么”
|
||||||
|
- `task_record` 表示“刚刚记下/创建了什么”
|
||||||
|
|
||||||
|
### 3.3 尽量直接携带结果快照
|
||||||
|
|
||||||
|
本批业务卡片不建议完全照搬 schedule 的“只发信号、前端二次补拉”模式,而应优先直接携带卡片渲染所需的最小结果快照。
|
||||||
|
|
||||||
|
原因如下:
|
||||||
|
|
||||||
|
1. 查询任务结果是“本轮对话当时查到的内容”,若前端二次补拉,结果可能已变化。
|
||||||
|
2. 随口记 / 创建任务通常只需要 1 条新增结果,直接随事件下发最稳。
|
||||||
|
3. 业务卡字段本身不重,没有必要为一张小卡额外走一轮查询接口。
|
||||||
|
|
||||||
|
因此,本次推荐:
|
||||||
|
|
||||||
|
1. 查询任务卡:直接下发查询条件摘要 + 命中列表快照
|
||||||
|
2. 任务记录卡:直接下发新建任务摘要
|
||||||
|
|
||||||
|
## 4. 事件协议
|
||||||
|
|
||||||
|
## 4.1 Timeline kind 扩展
|
||||||
|
|
||||||
|
前端 `TimelineEvent.kind` 建议新增:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type TimelineEventKind =
|
||||||
|
| 'user_text'
|
||||||
|
| 'assistant_text'
|
||||||
|
| 'tool_call'
|
||||||
|
| 'tool_result'
|
||||||
|
| 'confirm_request'
|
||||||
|
| 'schedule_completed'
|
||||||
|
| 'interrupt'
|
||||||
|
| 'status'
|
||||||
|
| 'business_card'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.2 payload 扩展建议
|
||||||
|
|
||||||
|
建议在现有 `payload` 中显式新增 `business_card` 字段,不建议长期把正式协议塞进 `meta`。
|
||||||
|
|
||||||
|
推荐结构:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type BusinessCardType = 'task_query' | 'task_record'
|
||||||
|
|
||||||
|
type TaskRecordSource = 'quick_note' | 'create_task'
|
||||||
|
|
||||||
|
interface TimelineBusinessCardPayload {
|
||||||
|
card_type: BusinessCardType
|
||||||
|
title?: string
|
||||||
|
summary?: string
|
||||||
|
source?: TaskRecordSource
|
||||||
|
data: TaskQueryCardData | TaskRecordCardData
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
对应地,`TimelineEvent.payload` 可扩为:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TimelineEventPayload {
|
||||||
|
reasoning_content?: string
|
||||||
|
stage?: string
|
||||||
|
block_id?: string
|
||||||
|
display_mode?: 'card'
|
||||||
|
tool?: TimelineToolPayload
|
||||||
|
confirm?: TimelineConfirmPayload
|
||||||
|
business_card?: TimelineBusinessCardPayload
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.3 SSE extra 扩展建议
|
||||||
|
|
||||||
|
后端流式 extra 建议新增:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "business_card",
|
||||||
|
"block_id": "quick_task.result",
|
||||||
|
"stage": "quick_task",
|
||||||
|
"display_mode": "card",
|
||||||
|
"business_card": {
|
||||||
|
"card_type": "task_query",
|
||||||
|
"title": "找到 4 条未完成任务",
|
||||||
|
"summary": "按截止时间升序",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
建议后端在 `OpenAIChunkExtra` 中显式新增:
|
||||||
|
|
||||||
|
- `BusinessCard *StreamBusinessCardExtra`
|
||||||
|
|
||||||
|
而不是长期挂入 `Meta`。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
1. 业务卡已经被确认会长期存在,不再是灰度字段。
|
||||||
|
2. 前端渲染会高频依赖这些字段,显式类型更稳。
|
||||||
|
3. 时间线持久化也更容易做结构校验。
|
||||||
|
|
||||||
|
## 5. 两类卡片的数据结构
|
||||||
|
|
||||||
|
## 5.1 查询任务卡片
|
||||||
|
|
||||||
|
### 5.1.1 目标
|
||||||
|
|
||||||
|
用于展示“本轮查询任务时实际查到了哪些任务”,重点是结果快照,而不是工具过程。
|
||||||
|
|
||||||
|
### 5.1.2 推荐数据结构
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TaskQueryCardTaskItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
priority_group?: number
|
||||||
|
priority_label?: string
|
||||||
|
deadline_at?: string
|
||||||
|
is_completed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskQueryCardData {
|
||||||
|
query_summary?: string
|
||||||
|
result_count: number
|
||||||
|
shown_count: number
|
||||||
|
has_more?: boolean
|
||||||
|
tasks: TaskQueryCardTaskItem[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.1.3 最小字段集
|
||||||
|
|
||||||
|
最少需要:
|
||||||
|
|
||||||
|
- `card_type = task_query`
|
||||||
|
- `title`
|
||||||
|
- `data.result_count`
|
||||||
|
- `data.tasks`
|
||||||
|
|
||||||
|
其中 `tasks` 每项最少建议包含:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `title`
|
||||||
|
|
||||||
|
### 5.1.4 增强字段
|
||||||
|
|
||||||
|
有条件时建议补充:
|
||||||
|
|
||||||
|
- `query_summary`
|
||||||
|
- `priority_label`
|
||||||
|
- `deadline_at`
|
||||||
|
- `is_completed`
|
||||||
|
- `shown_count`
|
||||||
|
- `has_more`
|
||||||
|
|
||||||
|
### 5.1.5 降级规则
|
||||||
|
|
||||||
|
1. 若只有 `result_count` 无任务列表:
|
||||||
|
- 前端仍可渲染简版统计卡,不展示列表区。
|
||||||
|
2. 若任务项缺少 `priority_label` / `deadline_at`:
|
||||||
|
- 隐藏对应字段行,不展示占位符。
|
||||||
|
3. 若 `result_count = 0`:
|
||||||
|
- 渲染空结果态卡片,而不是回退纯文本。
|
||||||
|
|
||||||
|
### 5.1.6 示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "business_card",
|
||||||
|
"payload": {
|
||||||
|
"stage": "quick_task",
|
||||||
|
"block_id": "quick_task.result",
|
||||||
|
"display_mode": "card",
|
||||||
|
"business_card": {
|
||||||
|
"card_type": "task_query",
|
||||||
|
"title": "找到 4 条未完成任务",
|
||||||
|
"summary": "按截止时间升序",
|
||||||
|
"data": {
|
||||||
|
"query_summary": "关键词:离散数学;仅未完成;截止时间升序",
|
||||||
|
"result_count": 4,
|
||||||
|
"shown_count": 3,
|
||||||
|
"has_more": true,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"title": "离散数学作业 3",
|
||||||
|
"priority_group": 2,
|
||||||
|
"priority_label": "重要不紧急",
|
||||||
|
"deadline_at": "2026-04-29 21:00",
|
||||||
|
"is_completed": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 105,
|
||||||
|
"title": "离散数学命题证明复习",
|
||||||
|
"priority_group": 2,
|
||||||
|
"priority_label": "重要不紧急",
|
||||||
|
"deadline_at": "2026-05-01 18:00",
|
||||||
|
"is_completed": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 108,
|
||||||
|
"title": "离散数学错题整理",
|
||||||
|
"priority_group": 3,
|
||||||
|
"priority_label": "普通任务",
|
||||||
|
"deadline_at": "",
|
||||||
|
"is_completed": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.2 任务记录卡片
|
||||||
|
|
||||||
|
### 5.2.1 目标
|
||||||
|
|
||||||
|
用于展示“刚刚记下/创建出的那条任务结果”,统一承载:
|
||||||
|
|
||||||
|
- 随口记
|
||||||
|
- 创建任务
|
||||||
|
|
||||||
|
### 5.2.2 推荐数据结构
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TaskRecordCardData {
|
||||||
|
id?: number
|
||||||
|
title: string
|
||||||
|
priority_group?: number
|
||||||
|
priority_label?: string
|
||||||
|
deadline_at?: string
|
||||||
|
urgency_threshold_at?: string
|
||||||
|
status?: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
卡片额外语义通过外层字段区分:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TimelineBusinessCardPayload {
|
||||||
|
card_type: 'task_record'
|
||||||
|
title?: string
|
||||||
|
summary?: string
|
||||||
|
source?: 'quick_note' | 'create_task'
|
||||||
|
data: TaskRecordCardData
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2.3 最小字段集
|
||||||
|
|
||||||
|
最少需要:
|
||||||
|
|
||||||
|
- `card_type = task_record`
|
||||||
|
- `source`
|
||||||
|
- `data.title`
|
||||||
|
|
||||||
|
### 5.2.4 增强字段
|
||||||
|
|
||||||
|
有条件时建议补充:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `priority_label`
|
||||||
|
- `deadline_at`
|
||||||
|
- `urgency_threshold_at`
|
||||||
|
- `status`
|
||||||
|
- `created_at`
|
||||||
|
- `summary`
|
||||||
|
|
||||||
|
### 5.2.5 source 语义约定
|
||||||
|
|
||||||
|
#### `source = quick_note`
|
||||||
|
|
||||||
|
表示这条记录来自“随口记”入口。
|
||||||
|
|
||||||
|
前端展示建议:
|
||||||
|
|
||||||
|
1. 头部弱化“正式创建”措辞。
|
||||||
|
2. 更强调“已帮你记下”。
|
||||||
|
3. 若字段较少,只展示标题和轻量标签即可成立。
|
||||||
|
|
||||||
|
#### `source = create_task`
|
||||||
|
|
||||||
|
表示这条记录来自“明确创建任务”入口。
|
||||||
|
|
||||||
|
前端展示建议:
|
||||||
|
|
||||||
|
1. 头部可用更正式的“任务已创建”表达。
|
||||||
|
2. 更适合展示 deadline / priority / status 等结构化信息。
|
||||||
|
|
||||||
|
### 5.2.6 降级规则
|
||||||
|
|
||||||
|
1. 若只有 `title`:
|
||||||
|
- 仍渲染最简任务记录卡。
|
||||||
|
2. 若没有 `deadline_at`:
|
||||||
|
- 不展示“无截止时间”字样,直接隐藏该字段区。
|
||||||
|
3. 若没有 `priority_label`:
|
||||||
|
- 不展示优先级标签,避免为了填满 UI 硬造信息。
|
||||||
|
|
||||||
|
### 5.2.7 示例:随口记
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "business_card",
|
||||||
|
"payload": {
|
||||||
|
"stage": "quick_task",
|
||||||
|
"block_id": "quick_task.result",
|
||||||
|
"display_mode": "card",
|
||||||
|
"business_card": {
|
||||||
|
"card_type": "task_record",
|
||||||
|
"title": "已帮你记下",
|
||||||
|
"summary": "一条轻量提醒已写入任务系统",
|
||||||
|
"source": "quick_note",
|
||||||
|
"data": {
|
||||||
|
"id": 301,
|
||||||
|
"title": "周三晚上给导师发周报",
|
||||||
|
"priority_group": 2,
|
||||||
|
"priority_label": "重要不紧急",
|
||||||
|
"deadline_at": "2026-04-29 20:00",
|
||||||
|
"created_at": "2026-04-27 16:10:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2.8 示例:创建任务
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "business_card",
|
||||||
|
"payload": {
|
||||||
|
"stage": "execute",
|
||||||
|
"block_id": "execute.result",
|
||||||
|
"display_mode": "card",
|
||||||
|
"business_card": {
|
||||||
|
"card_type": "task_record",
|
||||||
|
"title": "任务已创建",
|
||||||
|
"summary": "已写入任务系统",
|
||||||
|
"source": "create_task",
|
||||||
|
"data": {
|
||||||
|
"id": 405,
|
||||||
|
"title": "完成离散数学第 1 节复习",
|
||||||
|
"priority_group": 1,
|
||||||
|
"priority_label": "重要紧急",
|
||||||
|
"deadline_at": "2026-04-28 22:00",
|
||||||
|
"status": "todo",
|
||||||
|
"created_at": "2026-04-27 16:12:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 前端对接要求
|
||||||
|
|
||||||
|
## 6.1 时间线模型扩展
|
||||||
|
|
||||||
|
前端建议扩展:
|
||||||
|
|
||||||
|
1. `TimelineEvent.kind` 增加 `business_card`
|
||||||
|
2. `payload.business_card` 增加强类型
|
||||||
|
3. `DisplayAssistantBlock.type` 增加 `business_card`
|
||||||
|
|
||||||
|
建议结构:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type DisplayAssistantBlockType =
|
||||||
|
| 'tool'
|
||||||
|
| 'status'
|
||||||
|
| 'reasoning'
|
||||||
|
| 'content'
|
||||||
|
| 'content_indicator'
|
||||||
|
| 'schedule_card'
|
||||||
|
| 'business_card'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6.2 组件拆分建议
|
||||||
|
|
||||||
|
建议前端按“两层组件”实现:
|
||||||
|
|
||||||
|
### 第一层:统一容器
|
||||||
|
|
||||||
|
- `BusinessCardRenderer.vue`
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
1. 根据 `card_type` 分发子组件
|
||||||
|
2. 兜底空态 / 未知类型
|
||||||
|
3. 统一外边距、动画、时间线嵌入样式
|
||||||
|
|
||||||
|
### 第二层:两张业务卡
|
||||||
|
|
||||||
|
- `TaskQueryResultCard.vue`
|
||||||
|
- `TaskRecordCard.vue`
|
||||||
|
|
||||||
|
这样可以保持:
|
||||||
|
|
||||||
|
1. 外层时间线接入统一
|
||||||
|
2. 卡片内部样式独立演进
|
||||||
|
3. 后续若新增其他业务卡,只需继续扩展 renderer
|
||||||
|
|
||||||
|
## 6.3 渲染策略
|
||||||
|
|
||||||
|
### 查询任务卡
|
||||||
|
|
||||||
|
建议展示:
|
||||||
|
|
||||||
|
1. 头部标题
|
||||||
|
2. 查询摘要
|
||||||
|
3. 命中数量
|
||||||
|
4. 任务列表(建议最多展示 3~5 条)
|
||||||
|
5. 若有更多结果,展示“还有 N 条”
|
||||||
|
|
||||||
|
### 任务记录卡
|
||||||
|
|
||||||
|
建议展示:
|
||||||
|
|
||||||
|
1. 头部标题
|
||||||
|
2. 来源标签:`随口记生成` / `已创建任务`
|
||||||
|
3. 任务标题
|
||||||
|
4. 优先级 / 截止时间等辅助信息
|
||||||
|
|
||||||
|
## 6.4 未知字段兼容
|
||||||
|
|
||||||
|
前端渲染必须允许以下情况存在:
|
||||||
|
|
||||||
|
1. 后端只返回最小字段集
|
||||||
|
2. 某些增强字段为空
|
||||||
|
3. 不同入口产生的字段丰富度不同
|
||||||
|
|
||||||
|
因此前端实现原则是:
|
||||||
|
|
||||||
|
- 只消费拿得到的字段
|
||||||
|
- 不对缺失字段报错
|
||||||
|
- 不渲染“空标签”“空时间”“--”
|
||||||
|
|
||||||
|
## 7. 后端对接要求
|
||||||
|
|
||||||
|
## 7.1 发射位置建议
|
||||||
|
|
||||||
|
考虑到当前 `node` 目录正在整理,本轮先定协议,不要求立刻改动节点实现。后端后续落地时,建议在以下业务完成点发射:
|
||||||
|
|
||||||
|
### 查询任务卡
|
||||||
|
|
||||||
|
建议在“查询任务成功且拿到最终结果快照”后发射。
|
||||||
|
|
||||||
|
候选位置:
|
||||||
|
|
||||||
|
- `quick_task` 查询成功路径
|
||||||
|
- 后续若有独立查询任务工具域,也应在最终结果汇总后发射
|
||||||
|
|
||||||
|
### 任务记录卡
|
||||||
|
|
||||||
|
建议在“写入任务系统成功并拿到任务结果”后发射。
|
||||||
|
|
||||||
|
候选位置:
|
||||||
|
|
||||||
|
- `quick_task` create 成功路径,对应 `source = quick_note`
|
||||||
|
- 正式任务创建成功路径,对应 `source = create_task`
|
||||||
|
|
||||||
|
## 7.2 发射时机约束
|
||||||
|
|
||||||
|
业务卡片必须满足以下约束:
|
||||||
|
|
||||||
|
1. 只在业务真实成功后发射
|
||||||
|
2. 不能在参数未齐、等待确认、仅计划阶段时提前发射
|
||||||
|
3. 不能把“工具调用开始”误当成“业务结果卡”
|
||||||
|
|
||||||
|
换句话说:
|
||||||
|
|
||||||
|
- `tool_call/tool_result` 负责过程
|
||||||
|
- `business_card` 负责结果
|
||||||
|
|
||||||
|
## 7.3 与纯文本回复的关系
|
||||||
|
|
||||||
|
业务卡片不是纯文本回复的替代物,而是补充物。
|
||||||
|
|
||||||
|
建议后端保持:
|
||||||
|
|
||||||
|
1. 正常 assistant speak 继续输出
|
||||||
|
2. 业务卡片作为同轮时间线中的独立 block 插入
|
||||||
|
|
||||||
|
这样用户既能看到自然语言结果,也能看到结构化回执。
|
||||||
|
|
||||||
|
### 7.3.1 默认范式:短正文 + 结果卡
|
||||||
|
|
||||||
|
本次明确约定:业务卡片默认采用“短正文 + 结果卡”的组合范式,不采用“用卡片替换 LLM 正文”的方案。
|
||||||
|
|
||||||
|
推荐理解如下:
|
||||||
|
|
||||||
|
1. LLM 正文负责自然语言衔接、解释和收口。
|
||||||
|
2. 业务卡片负责结构化结果展示。
|
||||||
|
3. 两者是互补关系,不是替代关系。
|
||||||
|
|
||||||
|
推荐表现形态:
|
||||||
|
|
||||||
|
- 查询任务:
|
||||||
|
- 正文示例:`我找到 4 条相关任务,先给你列重点。`
|
||||||
|
- 后接:查询任务卡片
|
||||||
|
- 随口记:
|
||||||
|
- 正文示例:`我帮你记下来了。`
|
||||||
|
- 后接:任务记录卡片
|
||||||
|
- 创建任务:
|
||||||
|
- 正文示例:`这条任务已经创建好了。`
|
||||||
|
- 后接:任务记录卡片
|
||||||
|
|
||||||
|
### 7.3.2 为什么不采用“卡片替换正文”
|
||||||
|
|
||||||
|
不建议让节点直接吞掉 LLM 原本准备输出的正文,只保留卡片,原因如下:
|
||||||
|
|
||||||
|
1. 自然语言回复承担上下文衔接作用,直接去掉后,聊天感会突然中断。
|
||||||
|
2. 卡片负责结果快照,正文负责语气和解释,两者职责不同。
|
||||||
|
3. “替换正文”会引入额外分支判断,容易再次出现“该显示的被吞掉 / 不该显示的被露出”的问题。
|
||||||
|
|
||||||
|
因此,本次协议层明确规定:
|
||||||
|
|
||||||
|
- `business_card` 是正文补充,不是正文替代。
|
||||||
|
- 前端收到 `business_card` 时,不应主动隐藏同轮 `assistant_text`。
|
||||||
|
- 后端发出 `business_card` 时,也不应把它当作“正文已无需输出”的信号。
|
||||||
|
|
||||||
|
### 7.3.3 正文长度约束
|
||||||
|
|
||||||
|
虽然保留正文,但在存在业务卡片的场景下,正文应尽量短,不要把卡片里已经结构化展示的内容再用长段文字完整复述一遍。
|
||||||
|
|
||||||
|
建议约束:
|
||||||
|
|
||||||
|
1. 正文以一句或两句为宜。
|
||||||
|
2. 正文只表达结论、态度或过渡,不重复列出完整字段。
|
||||||
|
3. 任务标题、时间、优先级、命中列表等细节尽量交给卡片承载。
|
||||||
|
|
||||||
|
换句话说,本次推荐的最终交互形态是:
|
||||||
|
|
||||||
|
- 先给一句自然语言反馈
|
||||||
|
- 再给结构化业务卡片
|
||||||
|
|
||||||
|
而不是:
|
||||||
|
|
||||||
|
- 大段正文完整复述一遍
|
||||||
|
- 再来一张内容几乎重复的卡片
|
||||||
|
|
||||||
|
### 7.3.4 顺序建议
|
||||||
|
|
||||||
|
如果同一轮既有正文又有业务卡片,推荐前端按以下顺序展示:
|
||||||
|
|
||||||
|
1. `assistant_text`
|
||||||
|
2. `business_card`
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
1. 更符合自然阅读流:先“听结果”,再“看详情”。
|
||||||
|
2. 能保持聊天节奏,不会让卡片突兀抢到正文前面。
|
||||||
|
3. 与当前 `assistant_text + 结构化卡片` 的时间线模型一致。
|
||||||
|
|
||||||
|
### 7.3.5 卡片位置约束
|
||||||
|
|
||||||
|
本次进一步明确:业务卡片应当紧跟在“与之对应的那段 assistant 正文”后面,而不是拖到整轮消息流的绝对结尾再统一补发。
|
||||||
|
|
||||||
|
推荐顺序:
|
||||||
|
|
||||||
|
1. 业务成功
|
||||||
|
2. 输出一句简短 `assistant_text`
|
||||||
|
3. 立即输出对应的 `business_card`
|
||||||
|
4. 本轮结束,或仅保留极短的必要收尾
|
||||||
|
|
||||||
|
不推荐顺序:
|
||||||
|
|
||||||
|
1. 先输出结果正文
|
||||||
|
2. 中间再插入其他阶段提示、补充说明或收尾文案
|
||||||
|
3. 最后才在整轮末尾补一张业务卡片
|
||||||
|
|
||||||
|
这样做的问题是:
|
||||||
|
|
||||||
|
1. 卡片和对应正文的语义绑定会变弱。
|
||||||
|
2. 用户会误以为卡片是在回应更后面的内容,而不是前面的那句结果。
|
||||||
|
3. 卡片会更像“附录”或“补充材料”,而不是本轮结果的结构化主回执。
|
||||||
|
|
||||||
|
因此,前后端统一按以下口径理解:
|
||||||
|
|
||||||
|
- 业务卡片不是“整轮结束彩蛋”
|
||||||
|
- 业务卡片是“对应正文的紧随结果块”
|
||||||
|
|
||||||
|
也就是说,位置上应理解为:
|
||||||
|
|
||||||
|
- 紧跟对应消息后面
|
||||||
|
- 而不是放在整轮会话的绝对结尾
|
||||||
|
|
||||||
|
### 7.3.6 对后端发射时机的直接要求
|
||||||
|
|
||||||
|
后端后续补发 `business_card` 时,应尽量保证:
|
||||||
|
|
||||||
|
1. 卡片事件在对应 `assistant_text` 之后立即进入时间线。
|
||||||
|
2. 卡片事件之后不要再接大段重复解释。
|
||||||
|
3. 若确实需要补一句收尾,也应控制在极短长度内,避免把卡片重新推离它所服务的正文。
|
||||||
|
|
||||||
|
前端在渲染时,也不应为了“统一收口”而把业务卡片重新移动到该轮消息的最末尾。
|
||||||
|
|
||||||
|
## 8. 时间线持久化要求
|
||||||
|
|
||||||
|
若当前时间线已经会持久化 `tool_call`、`tool_result`、`confirm_request`、`schedule_completed`,则 `business_card` 也应进入同一条时间线持久化链路。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
1. 刷新页面后能恢复卡片
|
||||||
|
2. 渲染顺序仍以 `seq` 为准
|
||||||
|
3. 不能只在 SSE 在线期间可见
|
||||||
|
|
||||||
|
## 9. 推荐落地顺序
|
||||||
|
|
||||||
|
考虑到当前 execute/node 还在精简,推荐顺序如下:
|
||||||
|
|
||||||
|
### 第一步:前端先落承载层
|
||||||
|
|
||||||
|
1. 扩展 timeline 类型
|
||||||
|
2. 扩展 `business_card` payload 类型
|
||||||
|
3. 新增 `BusinessCardRenderer.vue`
|
||||||
|
4. 新增 `TaskQueryResultCard.vue`
|
||||||
|
5. 新增 `TaskRecordCard.vue`
|
||||||
|
6. 在 `AssistantPanel.vue` 接入渲染分支
|
||||||
|
|
||||||
|
### 第二步:后端补协议结构
|
||||||
|
|
||||||
|
1. 在 stream extra 中新增 `business_card`
|
||||||
|
2. 在 timeline 持久化 DTO 中补 `business_card`
|
||||||
|
3. 保证刷新后可恢复
|
||||||
|
|
||||||
|
### 第三步:后端补业务发射点
|
||||||
|
|
||||||
|
1. quick task 查询成功 -> 发 `task_query`
|
||||||
|
2. quick note 创建成功 -> 发 `task_record(source=quick_note)`
|
||||||
|
3. 正式创建任务成功 -> 发 `task_record(source=create_task)`
|
||||||
|
|
||||||
|
## 10. 本次明确不做的事
|
||||||
|
|
||||||
|
本说明暂不覆盖:
|
||||||
|
|
||||||
|
1. 修改工具调用卡片协议
|
||||||
|
2. 修改确认卡片协议
|
||||||
|
3. 把业务卡片统一改为前端二次补拉
|
||||||
|
4. 把 `随口记` 和 `创建任务` 再拆成两套完全独立事件类型
|
||||||
|
|
||||||
|
## 11. 最终结论
|
||||||
|
|
||||||
|
本次业务卡片推荐采用以下标准:
|
||||||
|
|
||||||
|
1. 新增统一时间线事件:`business_card`
|
||||||
|
2. 卡片类型只保留两类:
|
||||||
|
- `task_query`
|
||||||
|
- `task_record`
|
||||||
|
3. `task_record` 用 `source` 区分:
|
||||||
|
- `quick_note`
|
||||||
|
- `create_task`
|
||||||
|
4. 卡片优先直接携带结果快照,不走“仅发信号、前端再查一次”的默认模式
|
||||||
|
5. 前端可先按本文把渲染层做完,后端后续按同一协议补发事件
|
||||||
@@ -16,6 +16,45 @@ export interface TimelineConfirmPayload {
|
|||||||
summary: string
|
summary: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskQueryCardTaskItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
priority_group?: number
|
||||||
|
priority_label?: string
|
||||||
|
deadline_at?: string
|
||||||
|
is_completed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskQueryCardData {
|
||||||
|
query_summary?: string
|
||||||
|
result_count: number
|
||||||
|
shown_count: number
|
||||||
|
has_more?: boolean
|
||||||
|
tasks: TaskQueryCardTaskItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskRecordCardData {
|
||||||
|
id?: number
|
||||||
|
title: string
|
||||||
|
priority_group?: number
|
||||||
|
priority_label?: string
|
||||||
|
deadline_at?: string
|
||||||
|
urgency_threshold_at?: string
|
||||||
|
status?: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BusinessCardType = 'task_query' | 'task_record'
|
||||||
|
export type TaskRecordSource = 'quick_note' | 'create_task'
|
||||||
|
|
||||||
|
export interface TimelineBusinessCardPayload {
|
||||||
|
card_type: BusinessCardType
|
||||||
|
title?: string
|
||||||
|
summary?: string
|
||||||
|
source?: TaskRecordSource
|
||||||
|
data: TaskQueryCardData | TaskRecordCardData
|
||||||
|
}
|
||||||
|
|
||||||
export interface TimelineEvent {
|
export interface TimelineEvent {
|
||||||
id: number
|
id: number
|
||||||
seq: number
|
seq: number
|
||||||
@@ -28,6 +67,7 @@ export interface TimelineEvent {
|
|||||||
| 'schedule_completed'
|
| 'schedule_completed'
|
||||||
| 'interrupt'
|
| 'interrupt'
|
||||||
| 'status'
|
| 'status'
|
||||||
|
| 'business_card'
|
||||||
role?: 'user' | 'assistant'
|
role?: 'user' | 'assistant'
|
||||||
content?: string
|
content?: string
|
||||||
payload?: {
|
payload?: {
|
||||||
@@ -37,6 +77,7 @@ export interface TimelineEvent {
|
|||||||
display_mode?: 'card'
|
display_mode?: 'card'
|
||||||
tool?: TimelineToolPayload
|
tool?: TimelineToolPayload
|
||||||
confirm?: TimelineConfirmPayload
|
confirm?: TimelineConfirmPayload
|
||||||
|
business_card?: TimelineBusinessCardPayload
|
||||||
}
|
}
|
||||||
tokens_consumed?: number
|
tokens_consumed?: number
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { TimelineBusinessCardPayload, TaskQueryCardData, TaskRecordCardData } from '@/api/schedule_agent'
|
||||||
|
import TaskQueryResultCard from './TaskQueryResultCard.vue'
|
||||||
|
import TaskRecordCard from './TaskRecordCard.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
payload: TimelineBusinessCardPayload
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isTaskQuery = computed(() => props.payload.card_type === 'task_query')
|
||||||
|
const isTaskRecord = computed(() => props.payload.card_type === 'task_record')
|
||||||
|
|
||||||
|
const queryData = computed(() => props.payload.data as TaskQueryCardData)
|
||||||
|
const recordData = computed(() => props.payload.data as TaskRecordCardData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="business-card-renderer">
|
||||||
|
<TaskQueryResultCard
|
||||||
|
v-if="isTaskQuery"
|
||||||
|
:data="queryData"
|
||||||
|
:title="payload.title"
|
||||||
|
:summary="payload.summary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TaskRecordCard
|
||||||
|
v-else-if="isTaskRecord"
|
||||||
|
:data="recordData"
|
||||||
|
:source="payload.source"
|
||||||
|
:title="payload.title"
|
||||||
|
:summary="payload.summary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else class="unknown-card">
|
||||||
|
<p>未知业务卡片类型: {{ payload.card_type }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.business-card-renderer {
|
||||||
|
margin: 12px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: card-appear 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes card-appear {
|
||||||
|
0% { opacity: 0; transform: scale(0.95) translateY(10px); }
|
||||||
|
100% { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.unknown-card {
|
||||||
|
padding: 16px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
230
frontend/src/components/assistant/cards/TaskQueryResultCard.vue
Normal file
230
frontend/src/components/assistant/cards/TaskQueryResultCard.vue
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskQueryCardData } from '@/api/schedule_agent'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: TaskQueryCardData
|
||||||
|
title?: string
|
||||||
|
summary?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 对齐首页象限体系
|
||||||
|
const quadMeta: any = {
|
||||||
|
1: { title: '重要且紧急', tone: 'danger', color: '#ef4444' },
|
||||||
|
2: { title: '重要不紧急', tone: 'primary', color: '#3b82f6' },
|
||||||
|
3: { title: '简单不重要', tone: 'warning', color: '#f59e0b' },
|
||||||
|
4: { title: '不简单不重要', tone: 'slate', color: '#64748b' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBgStyle = (group: number = 2) => {
|
||||||
|
const bgMap: any = {
|
||||||
|
1: 'linear-gradient(180deg, #fff1f2 0%, #fff7f7 100%)',
|
||||||
|
2: 'linear-gradient(180deg, #eef7ff 0%, #f7fbff 100%)',
|
||||||
|
3: 'linear-gradient(180deg, #fff8df 0%, #fffdf1 100%)',
|
||||||
|
4: 'linear-gradient(180deg, #f2f5fb 0%, #f8fafc 100%)'
|
||||||
|
}
|
||||||
|
return bgMap[group] || bgMap[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTextColor = (group: number = 2) => {
|
||||||
|
return quadMeta[group]?.color || '#3b82f6'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="business-card query-results" :style="{ background: getBgStyle(props.data.tasks[0]?.priority_group) }">
|
||||||
|
<header class="card-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<p class="eyebrow">{{ summary || '查询结果' }}</p>
|
||||||
|
<h3>{{ title || '为您找到以下任务' }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="count-badge" v-if="data.result_count > 0">
|
||||||
|
{{ data.result_count }} 项
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<div v-if="data.tasks && data.tasks.length > 0" class="task-items">
|
||||||
|
<div v-for="task in data.tasks" :key="task.id" class="task-item">
|
||||||
|
<div class="item-check">
|
||||||
|
<div class="check-circle" :style="{ borderColor: getTextColor(task.priority_group) }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="item-body">
|
||||||
|
<div class="item-title">{{ task.title }}</div>
|
||||||
|
<div class="item-meta">
|
||||||
|
<span
|
||||||
|
class="q-pill"
|
||||||
|
v-if="task.priority_group"
|
||||||
|
:style="{ color: getTextColor(task.priority_group), background: getTextColor(task.priority_group) + '10' }"
|
||||||
|
>
|
||||||
|
Q{{ task.priority_group }} {{ quadMeta[task.priority_group]?.title }}
|
||||||
|
</span>
|
||||||
|
<span v-if="task.deadline_at" class="time-pill">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
{{ task.deadline_at }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M10 10l4 4m0-4l-4 4"/></svg>
|
||||||
|
</div>
|
||||||
|
<p>暂无符合条目</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="data.has_more" class="btn-more">查看完整列表</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.business-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
border-radius: 28px;
|
||||||
|
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.02);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 24px 24px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: rgba(30, 41, 59, 0.5);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 850;
|
||||||
|
color: #1e293b;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #475569;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-items {
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: 1px solid rgba(0,0,0,0.04);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-circle {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #122033;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-pill {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-pill {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #94a3b8;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-more {
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
margin: 16px 16px 20px;
|
||||||
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-more:hover {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 32px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
180
frontend/src/components/assistant/cards/TaskRecordCard.vue
Normal file
180
frontend/src/components/assistant/cards/TaskRecordCard.vue
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskRecordCardData, TaskRecordSource } from '@/api/schedule_agent'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: TaskRecordCardData
|
||||||
|
source?: TaskRecordSource
|
||||||
|
title?: string
|
||||||
|
summary?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 对齐首页象限体系
|
||||||
|
const quadMeta: any = {
|
||||||
|
1: { title: '重要且紧急', tone: 'danger', color: '#ef4444' },
|
||||||
|
2: { title: '重要不紧急', tone: 'primary', color: '#3b82f6' },
|
||||||
|
3: { title: '简单不重要', tone: 'warning', color: '#f59e0b' },
|
||||||
|
4: { title: '不简单不重要', tone: 'slate', color: '#64748b' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBgStyle = (group: number = 2) => {
|
||||||
|
const bgMap: any = {
|
||||||
|
1: 'linear-gradient(180deg, #fff1f2 0%, #fff7f7 100%)',
|
||||||
|
2: 'linear-gradient(180deg, #eef7ff 0%, #f7fbff 100%)',
|
||||||
|
3: 'linear-gradient(180deg, #fff8df 0%, #fffdf1 100%)',
|
||||||
|
4: 'linear-gradient(180deg, #f2f5fb 0%, #f8fafc 100%)'
|
||||||
|
}
|
||||||
|
return bgMap[group] || bgMap[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTextColor = (group: number = 2) => {
|
||||||
|
return quadMeta[group]?.color || '#3b82f6'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="business-card creation-receipt" :style="{ background: getBgStyle(props.data.priority_group) }">
|
||||||
|
<div class="receipt-inner">
|
||||||
|
<div class="receipt-header">
|
||||||
|
<div class="success-ring" :style="{ background: getTextColor(props.data.priority_group) + '20', color: getTextColor(props.data.priority_group) }">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="success-msg">
|
||||||
|
<strong>{{ title || (source === 'quick_note' ? '已帮您记下' : '任务已创建') }}</strong>
|
||||||
|
<span v-if="data.priority_group">归类至:{{ quadMeta[data.priority_group].title }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-info-card">
|
||||||
|
<div class="task-title">{{ data.title }}</div>
|
||||||
|
<div class="task-footer">
|
||||||
|
<span class="task-id" v-if="data.id">ID: {{ data.id }}</span>
|
||||||
|
<span class="task-time" v-if="data.created_at || data.deadline_at">
|
||||||
|
{{ data.deadline_at ? '截止:' + data.deadline_at : '刚刚创建' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="receipt-actions">
|
||||||
|
<button class="btn-outline">修改详情</button>
|
||||||
|
<button class="btn-fill" :style="{ background: getTextColor(props.data.priority_group) }">打开查看</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.business-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
border-radius: 28px;
|
||||||
|
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.02);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-inner {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-ring {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-msg {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-msg strong {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 850;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-msg span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-info-card {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: 1px solid rgba(0,0,0,0.03);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1e293b;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-fill {
|
||||||
|
height: 42px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-fill:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -32,6 +32,8 @@ import ScheduleResultCard from '@/components/assistant/ScheduleResultCard.vue'
|
|||||||
import ScheduleFineTuneModal from '@/components/assistant/ScheduleFineTuneModal.vue'
|
import ScheduleFineTuneModal from '@/components/assistant/ScheduleFineTuneModal.vue'
|
||||||
import { formatConversationTime, formatMessageTime } from '@/utils/date'
|
import { formatConversationTime, formatMessageTime } from '@/utils/date'
|
||||||
import { renderMarkdown } from '@/utils/markdown'
|
import { renderMarkdown } from '@/utils/markdown'
|
||||||
|
import BusinessCardRenderer from '@/components/assistant/cards/BusinessCardRenderer.vue'
|
||||||
|
import type { TimelineBusinessCardPayload } from '@/api/schedule_agent'
|
||||||
|
|
||||||
interface StreamDeltaPayload {
|
interface StreamDeltaPayload {
|
||||||
content?: string
|
content?: string
|
||||||
@@ -147,12 +149,13 @@ interface DisplayMessage {
|
|||||||
|
|
||||||
interface DisplayAssistantBlock {
|
interface DisplayAssistantBlock {
|
||||||
id: string
|
id: string
|
||||||
type: 'tool' | 'status' | 'reasoning' | 'content' | 'content_indicator' | 'schedule_card'
|
type: 'tool' | 'status' | 'reasoning' | 'content' | 'content_indicator' | 'schedule_card' | 'business_card'
|
||||||
seq: number
|
seq: number
|
||||||
text?: string
|
text?: string
|
||||||
event?: ToolTraceEvent
|
event?: ToolTraceEvent
|
||||||
statusEvent?: StatusTraceEvent
|
statusEvent?: StatusTraceEvent
|
||||||
schedulePreview?: SchedulePreviewData
|
schedulePreview?: SchedulePreviewData
|
||||||
|
businessCard?: TimelineBusinessCardPayload
|
||||||
/** 所属的源消息 ID,用于状态查询 */
|
/** 所属的源消息 ID,用于状态查询 */
|
||||||
sourceId?: string
|
sourceId?: string
|
||||||
/** 所属的源消息引用,用于渲染辅助信息 */
|
/** 所属的源消息引用,用于渲染辅助信息 */
|
||||||
@@ -228,12 +231,13 @@ const toolTraceExpandedMap = reactive<Record<string, boolean>>({})
|
|||||||
const assistantReasoningSeqMap = reactive<Record<string, number>>({})
|
const assistantReasoningSeqMap = reactive<Record<string, number>>({})
|
||||||
const assistantContentBlocksMap = reactive<Record<string, AssistantContentBlock[]>>({})
|
const assistantContentBlocksMap = reactive<Record<string, AssistantContentBlock[]>>({})
|
||||||
const assistantReasoningBlocksMap = reactive<Record<string, AssistantContentBlock[]>>({})
|
const assistantReasoningBlocksMap = reactive<Record<string, AssistantContentBlock[]>>({})
|
||||||
const assistantTimelineLastKindMap = reactive<Record<string, 'content' | 'tool' | 'status' | 'reasoning' | 'other'>>({})
|
const assistantTimelineLastKindMap = reactive<Record<string, 'content' | 'tool' | 'status' | 'reasoning' | 'business_card' | 'other'>>({})
|
||||||
const conversationContextStatsMap = reactive<Record<string, ConversationContextStats | null>>({})
|
const conversationContextStatsMap = reactive<Record<string, ConversationContextStats | null>>({})
|
||||||
const conversationContextStatsLoadingMap = reactive<Record<string, boolean>>({})
|
const conversationContextStatsLoadingMap = reactive<Record<string, boolean>>({})
|
||||||
const conversationContextStatsReadyMap = reactive<Record<string, boolean>>({})
|
const conversationContextStatsReadyMap = reactive<Record<string, boolean>>({})
|
||||||
const conversationListItemRevealMap = reactive<Record<string, boolean>>({})
|
const conversationListItemRevealMap = reactive<Record<string, boolean>>({})
|
||||||
const scheduleResultMap = reactive<Record<string, SchedulePreviewData>>({})
|
const scheduleResultMap = reactive<Record<string, SchedulePreviewData>>({})
|
||||||
|
const businessCardEventsMap = reactive<Record<string, TimelineBusinessCardPayload[]>>({})
|
||||||
const isFineTuneModalVisible = ref(false)
|
const isFineTuneModalVisible = ref(false)
|
||||||
const fineTuneLoading = ref(false)
|
const fineTuneLoading = ref(false)
|
||||||
const activeFineTuneData = ref<SchedulePreviewData | null>(null)
|
const activeFineTuneData = ref<SchedulePreviewData | null>(null)
|
||||||
@@ -484,6 +488,7 @@ function clearToolTraceState(messageId: string) {
|
|||||||
delete assistantContentBlocksMap[messageId]
|
delete assistantContentBlocksMap[messageId]
|
||||||
delete assistantTimelineLastKindMap[messageId]
|
delete assistantTimelineLastKindMap[messageId]
|
||||||
delete scheduleResultMap[messageId]
|
delete scheduleResultMap[messageId]
|
||||||
|
delete businessCardEventsMap[messageId]
|
||||||
for (const key of Object.keys(toolTraceExpandedMap)) {
|
for (const key of Object.keys(toolTraceExpandedMap)) {
|
||||||
if (key.startsWith(`${messageId}:tool:`)) {
|
if (key.startsWith(`${messageId}:tool:`)) {
|
||||||
delete toolTraceExpandedMap[key]
|
delete toolTraceExpandedMap[key]
|
||||||
@@ -711,6 +716,29 @@ function appendAssistantReasoningChunk(messageId: string, chunk: string) {
|
|||||||
assistantTimelineLastKindMap[messageId] = 'reasoning'
|
assistantTimelineLastKindMap[messageId] = 'reasoning'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加业务卡片事件
|
||||||
|
*/
|
||||||
|
function appendBusinessCardEvent(messageId: string, payload: TimelineBusinessCardPayload, seq?: number) {
|
||||||
|
if (!businessCardEventsMap[messageId]) {
|
||||||
|
businessCardEventsMap[messageId] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果上一个阶段是推理,则结束并折叠它
|
||||||
|
if (assistantTimelineLastKindMap[messageId] === 'reasoning') {
|
||||||
|
finishCurrentReasoningBlock(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventSeq = seq || nextAssistantTimelineSeq()
|
||||||
|
businessCardEventsMap[messageId].push({
|
||||||
|
...payload,
|
||||||
|
// 借用 payload 存储 seq,便于 getDisplayAssistantBlocks 排序
|
||||||
|
_seq: eventSeq
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
assistantTimelineLastKindMap[messageId] = 'business_card'
|
||||||
|
}
|
||||||
|
|
||||||
function mapToolEventState(rawStatus?: string): ToolTraceState {
|
function mapToolEventState(rawStatus?: string): ToolTraceState {
|
||||||
const normalized = `${rawStatus || ''}`.trim().toLowerCase()
|
const normalized = `${rawStatus || ''}`.trim().toLowerCase()
|
||||||
if (normalized === 'start' || normalized === 'calling' || normalized === 'called') {
|
if (normalized === 'start' || normalized === 'calling' || normalized === 'called') {
|
||||||
@@ -1284,7 +1312,6 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
|
|||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusEvents = (statusTraceEventsMap[source.id] || []).slice().sort((left, right) => left.seq - right.seq)
|
const statusEvents = (statusTraceEventsMap[source.id] || []).slice().sort((left, right) => left.seq - right.seq)
|
||||||
for (const statusEvent of statusEvents) {
|
for (const statusEvent of statusEvents) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
@@ -1321,6 +1348,18 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const businessCards = businessCardEventsMap[source.id] || []
|
||||||
|
for (const card of businessCards) {
|
||||||
|
blocks.push({
|
||||||
|
id: `${source.id}:card:${(card as any)._seq}`,
|
||||||
|
type: 'business_card',
|
||||||
|
seq: (card as any)._seq,
|
||||||
|
businessCard: card,
|
||||||
|
sourceId: source.id,
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const contentBlocks = assistantContentBlocksMap[source.id] || []
|
const contentBlocks = assistantContentBlocksMap[source.id] || []
|
||||||
if (contentBlocks.length > 0) {
|
if (contentBlocks.length > 0) {
|
||||||
hasContentBlock = true
|
hasContentBlock = true
|
||||||
@@ -1803,18 +1842,16 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
|
|||||||
// 在刷新恢复场景下,我们只需设置状态即可。
|
// 在刷新恢复场景下,我们只需设置状态即可。
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
case 'business_card':
|
||||||
|
if (event.payload?.business_card) {
|
||||||
|
appendBusinessCardEvent(mid, event.payload.business_card)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
case 'schedule_completed':
|
case 'business_card':
|
||||||
// 1. 标记该消息需要排程卡片。
|
if (event.payload?.business_card) {
|
||||||
// 2. 改造点:不在此处立即进行 getSchedulePreview 的异步拉取,
|
appendBusinessCardEvent(mid, event.payload.business_card, event.seq)
|
||||||
// 避免后端还未完成落库、或者并发过高导致的 'schedule plan preview not found' 404 捕获。
|
}
|
||||||
// 3. 这里先存入占位标志,真正的拉取推迟到用户“点击卡片”时。
|
|
||||||
scheduleResultMap[mid] = {
|
|
||||||
summary: '智能编排方案已就绪',
|
|
||||||
conversation_id: conversationId,
|
|
||||||
hybrid_entries: [],
|
|
||||||
is_placeholder: true, // 内部临时标记
|
|
||||||
} as any
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2255,20 +2292,11 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
|
|||||||
`${extra.stage || ''}`,
|
`${extra.stage || ''}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
scheduleScrollMessagesToBottom(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (extra.kind === 'schedule_completed') {
|
if (extra.kind === 'business_card' && extra.business_card) {
|
||||||
// 1. 每当“排程卡片”这种重量级里程碑出现时,刷新统计信息,让用户感知到上下文变动。
|
appendBusinessCardEvent(assistantMessage.id, extra.business_card)
|
||||||
void loadConversationContextStats(selectedConversationId.value, true)
|
|
||||||
|
|
||||||
// 2. 收到编排完成事件,仅在前端打上占位标记,展示展示卡片。
|
|
||||||
// 不再并发执行异步 fetch,防止后端落库延迟导致的 NotFound。
|
|
||||||
scheduleResultMap[assistantMessage.id] = {
|
|
||||||
summary: '智能编排方案已就绪',
|
|
||||||
conversation_id: selectedConversationId.value,
|
|
||||||
hybrid_entries: [],
|
|
||||||
is_placeholder: true,
|
|
||||||
} as any
|
|
||||||
scheduleScrollMessagesToBottom(true)
|
scheduleScrollMessagesToBottom(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2798,16 +2826,7 @@ onBeforeUnmount(() => {
|
|||||||
<div v-else class="chat-message__assistant-flow">
|
<div v-else class="chat-message__assistant-flow">
|
||||||
<TransitionGroup name="inner-fade">
|
<TransitionGroup name="inner-fade">
|
||||||
<div v-for="block in getDisplayAssistantBlocks(dm)" :key="block.id">
|
<div v-for="block in getDisplayAssistantBlocks(dm)" :key="block.id">
|
||||||
<div v-if="block.type === 'tool'" class="chat-message__tool-list">
|
<article v-if="block.type === 'tool'" class="chat-message__tool">
|
||||||
<article
|
|
||||||
class="chat-message__tool-item"
|
|
||||||
:class="{
|
|
||||||
'chat-message__tool-item--called': block.event?.state === 'called',
|
|
||||||
'chat-message__tool-item--completed': block.event?.state === 'completed',
|
|
||||||
'chat-message__tool-item--create': block.event?.state === 'create',
|
|
||||||
'chat-message__tool-item--blocked': block.event?.state === 'blocked',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="chat-message__tool-head"
|
class="chat-message__tool-head"
|
||||||
@@ -2831,7 +2850,6 @@ onBeforeUnmount(() => {
|
|||||||
{{ block.event.detail }}
|
{{ block.event.detail }}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="block.type === 'status'" class="chat-message__status-line">
|
<div v-else-if="block.type === 'status'" class="chat-message__status-line">
|
||||||
<span class="chat-message__status-icon" aria-hidden="true">
|
<span class="chat-message__status-icon" aria-hidden="true">
|
||||||
@@ -2914,6 +2932,10 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="block.type === 'business_card'" class="chat-message__business-card">
|
||||||
|
<BusinessCardRenderer :payload="block.businessCard" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="block.type === 'content'" class="chat-message__assistant-content">
|
<div v-else-if="block.type === 'content'" class="chat-message__assistant-content">
|
||||||
<div class="chat-message__markdown chat-message__markdown--assistant" v-html="renderMessageMarkdown(block.text || '')" />
|
<div class="chat-message__markdown chat-message__markdown--assistant" v-html="renderMessageMarkdown(block.text || '')" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import DashboardView from '@/views/DashboardView.vue'
|
|||||||
import ScheduleView from '@/views/ScheduleView.vue'
|
import ScheduleView from '@/views/ScheduleView.vue'
|
||||||
import ToolTracePrototypeView from '@/views/ToolTracePrototypeView.vue'
|
import ToolTracePrototypeView from '@/views/ToolTracePrototypeView.vue'
|
||||||
import TaskInteractiveDemo from '@/views/TaskInteractiveDemo.vue'
|
import TaskInteractiveDemo from '@/views/TaskInteractiveDemo.vue'
|
||||||
|
import DesignDemo from '@/views/DesignDemo.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -57,6 +58,11 @@ const router = createRouter({
|
|||||||
name: 'tool-trace-prototype',
|
name: 'tool-trace-prototype',
|
||||||
component: ToolTracePrototypeView,
|
component: ToolTracePrototypeView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/design-demo',
|
||||||
|
name: 'design-demo',
|
||||||
|
component: DesignDemo,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
253
frontend/src/views/DesignDemo.vue
Normal file
253
frontend/src/views/DesignDemo.vue
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// --- 数据结构定义 ---
|
||||||
|
interface Task {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
priority_group: 1 | 2 | 3 | 4
|
||||||
|
deadline_at?: string
|
||||||
|
is_completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 四象限元数据 (深度对齐首页提示词 & 视觉) ---
|
||||||
|
const quadMeta: any = {
|
||||||
|
1: { title: '重要且紧急', caption: '优先处理', tone: 'danger', bg: 'linear-gradient(180deg, #fff1f2 0%, #fff7f7 100%)', text: '#ef4444' },
|
||||||
|
2: { title: '重要不紧急', caption: '持续推进', tone: 'primary', bg: 'linear-gradient(180deg, #eef7ff 0%, #f7fbff 100%)', text: '#3b82f6' },
|
||||||
|
3: { title: '简单不重要', caption: '顺手完成', tone: 'warning', bg: 'linear-gradient(180deg, #fff8df 0%, #fffdf1 100%)', text: '#f59e0b' },
|
||||||
|
4: { title: '不简单不重要', caption: '谨慎投入', tone: 'slate', bg: 'linear-gradient(180deg, #f2f5fb 0%, #f8fafc 100%)', text: '#64748b' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 卡片模拟数据 ---
|
||||||
|
const cardData = {
|
||||||
|
query: {
|
||||||
|
query: '我第一象限里还有哪些事情?',
|
||||||
|
group: 1 as const,
|
||||||
|
tasks: [
|
||||||
|
{ id: '1', title: '修复生产环境登录异常', priority_group: 1, deadline_at: '2024-05-20 09:00', is_completed: false },
|
||||||
|
{ id: '2', title: '提交年度安全审计报告', priority_group: 1, deadline_at: '今天 18:00', is_completed: false },
|
||||||
|
{ id: '3', title: '确认猎选系统的集成计划', priority_group: 1, deadline_at: '明天', is_completed: false }
|
||||||
|
] as Task[]
|
||||||
|
},
|
||||||
|
receipt: {
|
||||||
|
title: '联系供应商确认物料进度',
|
||||||
|
group: 2 as const,
|
||||||
|
id: 'TASK-520',
|
||||||
|
created_at: '刚才'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 交互控制 ---
|
||||||
|
const activeView = ref<'query' | 'receipt'>('query')
|
||||||
|
const currentTone = ref<'danger' | 'primary' | 'warning' | 'slate'>('danger')
|
||||||
|
|
||||||
|
const switchTone = (tone: any) => {
|
||||||
|
currentTone.value = tone
|
||||||
|
// 模拟不同象限的查询结果
|
||||||
|
const toneToGroup: any = { danger: 1, primary: 2, warning: 3, slate: 4 }
|
||||||
|
cardData.query.group = toneToGroup[tone]
|
||||||
|
cardData.receipt.group = toneToGroup[tone]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="design-demo-page">
|
||||||
|
<div class="page-background">
|
||||||
|
<div class="shape shape-1"></div>
|
||||||
|
<div class="shape shape-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="chip">UI Refined V3.0</div>
|
||||||
|
<h1>业务卡片收敛方案</h1>
|
||||||
|
<p>首页风格同步 · 软渐变不晃眼 · 语义对齐</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-wrapper">
|
||||||
|
<!-- 预览控制台 -->
|
||||||
|
<aside class="demo-sidebar">
|
||||||
|
<div class="sidebar-block">
|
||||||
|
<h3>切换卡片类型</h3>
|
||||||
|
<div class="view-btns">
|
||||||
|
<button @click="activeView = 'query'" :class="{ active: activeView === 'query' }">查询记录</button>
|
||||||
|
<button @click="activeView = 'receipt'" :class="{ active: activeView === 'receipt' }">创建回执</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-block">
|
||||||
|
<h3>模拟目标象限</h3>
|
||||||
|
<div class="tone-btns">
|
||||||
|
<button v-for="(v, k) in quadMeta" :key="k" @click="switchTone(v.tone)" :class="[v.tone, { active: currentTone === v.tone }]">
|
||||||
|
{{ v.tone === 'danger' ? 'Q1' : v.tone === 'primary' ? 'Q2' : v.tone === 'warning' ? 'Q3' : 'Q4' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 画布区域 -->
|
||||||
|
<main class="demo-canvas">
|
||||||
|
<!-- 场景 A:任务查询结果 -->
|
||||||
|
<div v-if="activeView === 'query'" class="card-stage" :key="'query-' + currentTone">
|
||||||
|
<div class="card-label">预览:跨象限/单象限查询结果列表</div>
|
||||||
|
<div class="chat-inline-mockup">
|
||||||
|
<div class="business-card-final query-results" :style="{ background: quadMeta[cardData.query.group].bg }">
|
||||||
|
<header class="card-header-final">
|
||||||
|
<div class="header-left">
|
||||||
|
<p class="eyebrow">{{ quadMeta[cardData.query.group].caption }}</p>
|
||||||
|
<h3>{{ cardData.query.query }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="count-badge">找到 {{ cardData.query.tasks.length }} 项</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card-content-final">
|
||||||
|
<div class="task-items-final">
|
||||||
|
<div v-for="task in cardData.query.tasks" :key="task.id" class="task-item-final">
|
||||||
|
<div class="item-check">
|
||||||
|
<div class="check-circle" :style="{ borderColor: quadMeta[cardData.query.group].text }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="item-body">
|
||||||
|
<div class="item-title">{{ task.title }}</div>
|
||||||
|
<div class="item-meta">
|
||||||
|
<span class="q-pill" :style="{ color: quadMeta[cardData.query.group].text, background: quadMeta[cardData.query.group].text + '10' }">
|
||||||
|
Q{{ task.priority_group }} {{ quadMeta[task.priority_group].title }}
|
||||||
|
</span>
|
||||||
|
<span v-if="task.deadline_at" class="time-pill">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
{{ task.deadline_at }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-more-final">查看完整任务列表</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 场景 B:任务创建回执 -->
|
||||||
|
<div v-else class="card-stage" :key="'receipt-' + currentTone">
|
||||||
|
<div class="card-label">预览:任务创建成功的轻量回执</div>
|
||||||
|
<div class="chat-inline-mockup">
|
||||||
|
<div class="business-card-final creation-receipt" :style="{ background: quadMeta[cardData.receipt.group].bg }">
|
||||||
|
<div class="receipt-inner">
|
||||||
|
<div class="receipt-header-final">
|
||||||
|
<div class="success-ring-v3" :style="{ background: quadMeta[cardData.receipt.group].text + '20', color: quadMeta[cardData.receipt.group].text }">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="success-msg">
|
||||||
|
<strong>任务已由助手成功创建</strong>
|
||||||
|
<span>归类至:{{ quadMeta[cardData.receipt.group].title }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="receipt-task-card">
|
||||||
|
<div class="task-card-title">{{ cardData.receipt.title }}</div>
|
||||||
|
<div class="task-card-footer">
|
||||||
|
<span class="task-id-final">ID: {{ cardData.receipt.id }}</span>
|
||||||
|
<span class="task-time-final">创建于今日 {{ cardData.receipt.created_at }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="receipt-actions-final">
|
||||||
|
<button class="btn-action-outline">调整象限</button>
|
||||||
|
<button class="btn-action-fill" :style="{ background: quadMeta[cardData.receipt.group].text }">打开详情</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.design-demo-page {
|
||||||
|
padding: 80px 24px;
|
||||||
|
background: #fdfdfe;
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'Inter', -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-background { position: fixed; inset: 0; z-index: -1; }
|
||||||
|
.shape { position: absolute; border-radius: 50%; filter: blur(80px); opacity: 0.1; }
|
||||||
|
.shape-1 { width: 500px; height: 500px; background: #3b82f6; top: -10%; left: -10%; }
|
||||||
|
.shape-2 { width: 400px; height: 400px; background: #f43f5e; bottom: -5%; right: -5%; }
|
||||||
|
|
||||||
|
.page-header { text-align: center; margin-bottom: 60px; }
|
||||||
|
.chip { display: inline-block; padding: 4px 12px; background: #f1f5f9; color: #475569; border-radius: 100px; font-size: 11px; font-weight: 800; margin-bottom: 12px; }
|
||||||
|
.page-header h1 { font-size: 32px; font-weight: 900; letter-spacing: -0.04em; color: #0f172a; margin-bottom: 8px; }
|
||||||
|
.page-header p { font-size: 16px; color: #64748b; font-weight: 500; }
|
||||||
|
|
||||||
|
.demo-wrapper { display: flex; gap: 48px; max-width: 1000px; margin: 0 auto; align-items: flex-start; }
|
||||||
|
|
||||||
|
.demo-sidebar { width: 200px; display: flex; flex-direction: column; gap: 32px; position: sticky; top: 80px; }
|
||||||
|
.sidebar-block h3 { font-size: 13px; font-weight: 800; color: #94a3b8; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.view-btns, .tone-btns { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
|
.view-btns button, .tone-btns button { padding: 10px 14px; border: 1px solid #f1f5f9; background: white; border-radius: 12px; font-size: 13px; font-weight: 700; color: #475569; cursor: pointer; transition: all 0.2s; text-align: left; }
|
||||||
|
.view-btns button.active { background: #0f172a; color: white; border-color: #0f172a; }
|
||||||
|
|
||||||
|
.tone-btns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.tone-btns button { text-align: center; }
|
||||||
|
.tone-btns button.danger.active { background: #fee2e2; color: #ef4444; border-color: #ef4444; }
|
||||||
|
.tone-btns button.primary.active { background: #dbeafe; color: #3b82f6; border-color: #3b82f6; }
|
||||||
|
.tone-btns button.warning.active { background: #fef3c7; color: #d97706; border-color: #d97706; }
|
||||||
|
.tone-btns button.slate.active { background: #f1f5f9; color: #475569; border-color: #475569; }
|
||||||
|
|
||||||
|
.demo-canvas { flex: 1; min-width: 0; }
|
||||||
|
.card-stage { animation: stage-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
|
||||||
|
@keyframes stage-in { 0% { opacity: 0; transform: translateY(20px); } 100% { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
|
.card-label { font-size: 12px; color: #94a3b8; margin-bottom: 12px; font-weight: 600; padding-left: 8px; }
|
||||||
|
.chat-inline-mockup { padding: 40px; background: rgba(255, 255, 255, 0.4); border-radius: 40px; border: 1px solid rgba(0,0,0,0.02); backdrop-filter: blur(20px); display: flex; justify-content: center; }
|
||||||
|
|
||||||
|
/* --- Final Business Card Refinement --- */
|
||||||
|
.business-card-final { width: 100%; max-width: 380px; border-radius: 28px; border: 1px solid rgba(17, 24, 39, 0.08); box-shadow: 0 4px 20px rgba(0,0,0,0.02); overflow: hidden; transition: all 0.3s; }
|
||||||
|
.business-card-final:hover { transform: translateY(-4px); box-shadow: 0 12px 40px rgba(15, 23, 42, 0.06); }
|
||||||
|
|
||||||
|
/* Header Sync with Homepage */
|
||||||
|
.card-header-final { padding: 24px 24px 16px; display: flex; justify-content: space-between; align-items: flex-start; }
|
||||||
|
.eyebrow { font-size: 11px; font-weight: 800; color: rgba(30, 41, 59, 0.5); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 6px; }
|
||||||
|
.card-header-final h3 { font-size: 24px; font-weight: 850; color: #1e293b; margin: 0; line-height: 1.1; letter-spacing: -0.02em; }
|
||||||
|
.count-badge { padding: 4px 12px; background: rgba(255, 255, 255, 0.8); border-radius: 100px; font-size: 11px; font-weight: 700; color: #475569; box-shadow: 0 2px 8px rgba(0,0,0,0.02); }
|
||||||
|
|
||||||
|
/* Content List Sync */
|
||||||
|
.task-items-final { padding: 0 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.task-item-final { background: rgba(255, 255, 255, 0.95); border: 1px solid rgba(0,0,0,0.04); border-radius: 18px; padding: 14px 16px; display: flex; gap: 14px; align-items: center; }
|
||||||
|
.check-circle { width: 22px; height: 22px; border-radius: 50%; border: 2px solid #e2e8f0; }
|
||||||
|
|
||||||
|
.item-title { font-size: 15px; font-weight: 700; color: #122033; margin-bottom: 4px; }
|
||||||
|
.item-meta { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.q-pill { font-size: 10px; font-weight: 800; padding: 1px 8px; border-radius: 4px; }
|
||||||
|
.time-pill { font-size: 10px; color: #94a3b8; display: flex; align-items: center; gap: 4px; font-weight: 500; }
|
||||||
|
|
||||||
|
.btn-more-final { width: calc(100% - 32px); margin: 16px 16px 20px; padding: 12px; border: none; background: rgba(255, 255, 255, 0.6); border-radius: 14px; font-size: 13px; font-weight: 800; color: #475569; cursor: pointer; transition: all 0.2s; }
|
||||||
|
.btn-more-final:hover { background: white; }
|
||||||
|
|
||||||
|
/* Receipt Card Refinement */
|
||||||
|
.receipt-inner { padding: 24px; display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.receipt-header-final { display: flex; gap: 14px; align-items: center; }
|
||||||
|
.success-ring-v3 { width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
|
.success-msg { display: flex; flex-direction: column; }
|
||||||
|
.success-msg strong { font-size: 15px; font-weight: 850; color: #0f172a; }
|
||||||
|
.success-msg span { font-size: 12px; color: #64748b; font-weight: 500; }
|
||||||
|
|
||||||
|
.receipt-task-card { background: rgba(255, 255, 255, 0.95); border: 1px solid rgba(0,0,0,0.03); border-radius: 20px; padding: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.01); }
|
||||||
|
.task-card-title { font-size: 17px; font-weight: 800; color: #1e293b; margin-bottom: 12px; line-height: 1.4; }
|
||||||
|
.task-card-footer { display: flex; justify-content: space-between; font-size: 11px; font-weight: 600; color: #94a3b8; border-top: 1px solid #f1f5f9; padding-top: 10px; }
|
||||||
|
|
||||||
|
.receipt-actions-final { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.btn-action-outline { height: 42px; border: 1px solid #e2e8f0; background: white; border-radius: 12px; font-size: 13px; font-weight: 750; color: #475569; cursor: pointer; }
|
||||||
|
.btn-action-fill { height: 42px; border: none; border-radius: 12px; color: white; font-size: 13px; font-weight: 800; cursor: pointer; box-shadow: 0 8px 20px rgba(0,0,0,0.1); }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.demo-wrapper { flex-direction: column; }
|
||||||
|
.demo-sidebar { width: 100%; position: static; gap: 20px; }
|
||||||
|
.tone-btns { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
.chat-inline-mockup { padding: 20px; border-radius: 24px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user