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:
LoveLosita
2026-04-27 17:35:55 +08:00
parent 736ba0cff3
commit 0b0ed3c61a
23 changed files with 4528 additions and 2966 deletions

File diff suppressed because it is too large Load Diff

View 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
}
}

View 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
}

View 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] + "..."
}

View 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)
}

View 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,
)
}

View 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=%dreason=%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=%dreason=%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
}

View 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 次调用未激活工具,终止执行: %sactive_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):]
}

View 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
}
}

View 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"
}

View 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,
})
}

View 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")
}

View 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
}

View 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)
}
}

View 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)
}
}

View 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. 前端可先按本文把渲染层做完,后端后续按同一协议补发事件

View File

@@ -16,6 +16,45 @@ export interface TimelineConfirmPayload {
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 {
id: number
seq: number
@@ -28,6 +67,7 @@ export interface TimelineEvent {
| 'schedule_completed'
| 'interrupt'
| 'status'
| 'business_card'
role?: 'user' | 'assistant'
content?: string
payload?: {
@@ -37,6 +77,7 @@ export interface TimelineEvent {
display_mode?: 'card'
tool?: TimelineToolPayload
confirm?: TimelineConfirmPayload
business_card?: TimelineBusinessCardPayload
}
tokens_consumed?: number
created_at: string

View File

@@ -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>

View 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>

View 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>

View File

@@ -32,6 +32,8 @@ import ScheduleResultCard from '@/components/assistant/ScheduleResultCard.vue'
import ScheduleFineTuneModal from '@/components/assistant/ScheduleFineTuneModal.vue'
import { formatConversationTime, formatMessageTime } from '@/utils/date'
import { renderMarkdown } from '@/utils/markdown'
import BusinessCardRenderer from '@/components/assistant/cards/BusinessCardRenderer.vue'
import type { TimelineBusinessCardPayload } from '@/api/schedule_agent'
interface StreamDeltaPayload {
content?: string
@@ -147,12 +149,13 @@ interface DisplayMessage {
interface DisplayAssistantBlock {
id: string
type: 'tool' | 'status' | 'reasoning' | 'content' | 'content_indicator' | 'schedule_card'
type: 'tool' | 'status' | 'reasoning' | 'content' | 'content_indicator' | 'schedule_card' | 'business_card'
seq: number
text?: string
event?: ToolTraceEvent
statusEvent?: StatusTraceEvent
schedulePreview?: SchedulePreviewData
businessCard?: TimelineBusinessCardPayload
/** 所属的源消息 ID用于状态查询 */
sourceId?: string
/** 所属的源消息引用,用于渲染辅助信息 */
@@ -228,12 +231,13 @@ const toolTraceExpandedMap = reactive<Record<string, boolean>>({})
const assistantReasoningSeqMap = reactive<Record<string, number>>({})
const assistantContentBlocksMap = 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 conversationContextStatsLoadingMap = reactive<Record<string, boolean>>({})
const conversationContextStatsReadyMap = reactive<Record<string, boolean>>({})
const conversationListItemRevealMap = reactive<Record<string, boolean>>({})
const scheduleResultMap = reactive<Record<string, SchedulePreviewData>>({})
const businessCardEventsMap = reactive<Record<string, TimelineBusinessCardPayload[]>>({})
const isFineTuneModalVisible = ref(false)
const fineTuneLoading = ref(false)
const activeFineTuneData = ref<SchedulePreviewData | null>(null)
@@ -484,6 +488,7 @@ function clearToolTraceState(messageId: string) {
delete assistantContentBlocksMap[messageId]
delete assistantTimelineLastKindMap[messageId]
delete scheduleResultMap[messageId]
delete businessCardEventsMap[messageId]
for (const key of Object.keys(toolTraceExpandedMap)) {
if (key.startsWith(`${messageId}:tool:`)) {
delete toolTraceExpandedMap[key]
@@ -711,6 +716,29 @@ function appendAssistantReasoningChunk(messageId: string, chunk: string) {
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 {
const normalized = `${rawStatus || ''}`.trim().toLowerCase()
if (normalized === 'start' || normalized === 'calling' || normalized === 'called') {
@@ -1284,7 +1312,6 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
source,
})
}
const statusEvents = (statusTraceEventsMap[source.id] || []).slice().sort((left, right) => left.seq - right.seq)
for (const statusEvent of statusEvents) {
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] || []
if (contentBlocks.length > 0) {
hasContentBlock = true
@@ -1803,18 +1842,16 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
// 在刷新恢复场景下,我们只需设置状态即可。
}
break
case 'business_card':
if (event.payload?.business_card) {
appendBusinessCardEvent(mid, event.payload.business_card)
}
break
case 'schedule_completed':
// 1. 标记该消息需要排程卡片。
// 2. 改造点:不在此处立即进行 getSchedulePreview 的异步拉取,
// 避免后端还未完成落库、或者并发过高导致的 'schedule plan preview not found' 404 捕获。
// 3. 这里先存入占位标志,真正的拉取推迟到用户“点击卡片”时。
scheduleResultMap[mid] = {
summary: '智能编排方案已就绪',
conversation_id: conversationId,
hybrid_entries: [],
is_placeholder: true, // 内部临时标记
} as any
case 'business_card':
if (event.payload?.business_card) {
appendBusinessCardEvent(mid, event.payload.business_card, event.seq)
}
break
}
}
@@ -2255,20 +2292,11 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
`${extra.stage || ''}`,
)
}
scheduleScrollMessagesToBottom(true)
}
if (extra.kind === 'schedule_completed') {
// 1. 每当“排程卡片”这种重量级里程碑出现时,刷新统计信息,让用户感知到上下文变动。
void loadConversationContextStats(selectedConversationId.value, true)
// 2. 收到编排完成事件,仅在前端打上占位标记,展示展示卡片。
// 不再并发执行异步 fetch防止后端落库延迟导致的 NotFound。
scheduleResultMap[assistantMessage.id] = {
summary: '智能编排方案已就绪',
conversation_id: selectedConversationId.value,
hybrid_entries: [],
is_placeholder: true,
} as any
if (extra.kind === 'business_card' && extra.business_card) {
appendBusinessCardEvent(assistantMessage.id, extra.business_card)
scheduleScrollMessagesToBottom(true)
}
}
@@ -2798,16 +2826,7 @@ onBeforeUnmount(() => {
<div v-else class="chat-message__assistant-flow">
<TransitionGroup name="inner-fade">
<div v-for="block in getDisplayAssistantBlocks(dm)" :key="block.id">
<div v-if="block.type === 'tool'" class="chat-message__tool-list">
<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',
}"
>
<article v-if="block.type === 'tool'" class="chat-message__tool">
<button
type="button"
class="chat-message__tool-head"
@@ -2831,105 +2850,108 @@ onBeforeUnmount(() => {
{{ block.event.detail }}
</p>
</article>
</div>
<div v-else-if="block.type === 'status'" class="chat-message__status-line">
<span class="chat-message__status-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v4" />
<path d="M12 18v4" />
<path d="M4.93 4.93l2.83 2.83" />
<path d="M16.24 16.24l2.83 2.83" />
<path d="M2 12h4" />
<path d="M18 12h4" />
<path d="M4.93 19.07l2.83-2.83" />
<path d="M16.24 7.76l2.83-2.83" />
</svg>
</span>
<span class="chat-message__status-text">{{ block.statusEvent?.summary }}</span>
</div>
<div v-else-if="block.type === 'reasoning'" class="chat-message__reasoning">
<div class="chat-message__reasoning-head">
<div class="chat-message__reasoning-title">
<span class="chat-message__reasoning-icon">
<svg
class="chat-message__reasoning-icon-svg"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M8.00195 6.64454C8.75029 6.64454 9.35735 7.25169 9.35742 8.00001C9.35742 8.74838 8.75033 9.35548 8.00195 9.35548C7.2537 9.35533 6.64746 8.74829 6.64746 8.00001C6.64753 7.25178 7.25374 6.64468 8.00195 6.64454Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.97168 1.29981C11.5854 0.718916 13.271 0.642197 14.3145 1.68555C15.3578 2.72902 15.2811 4.41466 14.7002 6.02833C14.4708 6.66561 14.1505 7.32937 13.75 8.00001C14.1505 8.67062 14.4708 9.33444 14.7002 9.97169C15.2811 11.5854 15.3579 13.271 14.3145 14.3145C13.271 15.3579 11.5854 15.2811 9.97168 14.7002C9.33443 14.4708 8.67062 14.1505 8 13.75C7.32936 14.1505 6.66561 14.4708 6.02832 14.7002C4.41464 15.2811 2.72902 15.3578 1.68555 14.3145C0.642186 13.271 0.718901 11.5854 1.29981 9.97169C1.52918 9.33454 1.84868 8.67049 2.24902 8.00001C1.84869 7.32953 1.52918 6.66544 1.29981 6.02833C0.718882 4.41459 0.6421 2.729 1.68555 1.68555C2.729 0.642112 4.41459 0.718887 6.02832 1.29981C6.66544 1.52918 7.32953 1.8487 8 2.24903C8.67048 1.84869 9.33454 1.52919 9.97168 1.29981ZM12.9404 9.2129C12.4391 9.893 11.8616 10.5681 11.2148 11.2149C10.5681 11.8616 9.89299 12.4391 9.21289 12.9404C9.62535 13.1579 10.0271 13.338 10.4121 13.4766C11.9146 14.0174 12.9173 13.8738 13.3955 13.3955C13.8737 12.9173 14.0174 11.9146 13.4766 10.4121C13.338 10.0271 13.1579 9.62535 12.9404 9.2129ZM3.05859 9.2129C2.84124 9.62523 2.662 10.0272 2.52344 10.4121C1.98255 11.9146 2.1263 12.9172 2.60449 13.3955C3.08281 13.8737 4.08548 14.0174 5.58789 13.4766C5.97267 13.338 6.37392 13.1577 6.78613 12.9404C6.10627 12.4393 5.43171 11.8614 4.78516 11.2149C4.13826 10.5679 3.55995 9.89313 3.05859 9.2129ZM7.99902 3.792C7.23182 4.31419 6.45309 4.95512 5.7041 5.70411C4.95512 6.45309 4.31418 7.23184 3.79199 7.99903C4.31434 8.76666 4.95474 9.54653 5.7041 10.2959C6.45312 11.0449 7.23274 11.6848 8 12.207C8.76728 11.6848 9.54686 11.0449 10.2959 10.2959C11.0449 9.54686 11.6848 8.76729 12.207 8.00001C11.6848 7.23275 11.0449 6.45312 10.2959 5.70411C9.54653 4.95475 8.76665 4.31434 7.99902 3.792ZM5.58789 2.52344C4.08536 1.98255 3.08275 2.12625 2.60449 2.6045C2.12624 3.08275 1.98255 4.08536 2.52344 5.5879C2.66192 5.97253 2.84143 6.37409 3.05859 6.78614C3.55986 6.10611 4.13843 5.43189 4.78516 4.78516C5.4319 4.13843 6.10609 3.55987 6.78613 3.0586C6.37408 2.84144 5.97252 2.66192 5.58789 2.52344ZM13.3955 2.6045C12.9172 2.12631 11.9146 1.98257 10.4121 2.52344C10.0272 2.66201 9.62522 2.84125 9.21289 3.0586C9.89313 3.55996 10.5679 4.13827 11.2148 4.78516C11.8614 5.43172 12.4392 6.10627 12.9404 6.78614C13.1577 6.37393 13.338 5.97267 13.4766 5.5879C14.0174 4.08549 13.8736 3.08281 13.3955 2.6045Z"
fill="currentColor"
/>
</svg>
</span>
<span class="chat-message__reasoning-status">{{ getReasoningStatusLabel(block) }}</span>
</div>
<button
type="button"
class="chat-message__reasoning-toggle"
:aria-label="isReasoningCollapsed(block.id) ? '展开深度思考' : '折叠深度思考'"
@click="toggleReasoningCollapse(block.id)"
>
<span class="chat-message__reasoning-chevron">
<svg
class="chat-message__reasoning-chevron-icon"
:class="{ 'chat-message__reasoning-chevron-icon--expanded': !isReasoningCollapsed(block.id) }"
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M5.5 2.15137L5.92383 2.57617L8.65137 5.30273C8.90706 5.55843 9.13382 5.78438 9.29785 5.98828C9.46883 6.20088 9.61756 6.44405 9.66602 6.75C9.69222 6.91565 9.69222 7.08435 9.66602 7.25C9.61756 7.55595 9.46883 7.79912 9.29785 8.01172C9.13382 8.21561 8.90706 8.44157 8.65137 8.69727L5.92383 11.4238L5.5 11.8486L4.65137 11L5.07617 10.5762L7.80273 7.84863C8.07732 7.57405 8.24849 7.40124 8.3623 7.25977C8.46904 7.12709 8.47813 7.07728 8.48047 7.0625C8.48703 7.02105 8.48703 6.97895 8.48047 6.9375C8.47813 6.92272 8.46904 6.87291 8.3623 6.74023C8.24848 6.59876 8.07732 6.42595 7.80273 6.15137L5.07617 3.42383L4.65137 3L5.5 2.15137Z"
fill="currentColor"
/>
</svg>
</span>
</button>
<div v-else-if="block.type === 'status'" class="chat-message__status-line">
<span class="chat-message__status-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v4" />
<path d="M12 18v4" />
<path d="M4.93 4.93l2.83 2.83" />
<path d="M16.24 16.24l2.83 2.83" />
<path d="M2 12h4" />
<path d="M18 12h4" />
<path d="M4.93 19.07l2.83-2.83" />
<path d="M16.24 7.76l2.83-2.83" />
</svg>
</span>
<span class="chat-message__status-text">{{ block.statusEvent?.summary }}</span>
</div>
<div v-if="isReasoningCollapsed(block.id) === false" class="chat-message__reasoning-body">
<div
v-if="block.text"
class="chat-message__markdown chat-message__markdown--reasoning"
v-html="renderMessageMarkdown(block.text || '')"
/>
<div v-else class="chat-message__streaming chat-message__streaming--reasoning">
<div class="thinking-indicator">
<span class="thinking-indicator__text">正在思考</span>
<div v-else-if="block.type === 'reasoning'" class="chat-message__reasoning">
<div class="chat-message__reasoning-head">
<div class="chat-message__reasoning-title">
<span class="chat-message__reasoning-icon">
<svg
class="chat-message__reasoning-icon-svg"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M8.00195 6.64454C8.75029 6.64454 9.35735 7.25169 9.35742 8.00001C9.35742 8.74838 8.75033 9.35548 8.00195 9.35548C7.2537 9.35533 6.64746 8.74829 6.64746 8.00001C6.64753 7.25178 7.25374 6.64468 8.00195 6.64454Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.97168 1.29981C11.5854 0.718916 13.271 0.642197 14.3145 1.68555C15.3578 2.72902 15.2811 4.41466 14.7002 6.02833C14.4708 6.66561 14.1505 7.32937 13.75 8.00001C14.1505 8.67062 14.4708 9.33444 14.7002 9.97169C15.2811 11.5854 15.3579 13.271 14.3145 14.3145C13.271 15.3579 11.5854 15.2811 9.97168 14.7002C9.33443 14.4708 8.67062 14.1505 8 13.75C7.32936 14.1505 6.66561 14.4708 6.02832 14.7002C4.41464 15.2811 2.72902 15.3578 1.68555 14.3145C0.642186 13.271 0.718901 11.5854 1.29981 9.97169C1.52918 9.33454 1.84868 8.67049 2.24902 8.00001C1.84869 7.32953 1.52918 6.66544 1.29981 6.02833C0.718882 4.41459 0.6421 2.729 1.68555 1.68555C2.729 0.642112 4.41459 0.718887 6.02832 1.29981C6.66544 1.52918 7.32953 1.8487 8 2.24903C8.67048 1.84869 9.33454 1.52919 9.97168 1.29981ZM12.9404 9.2129C12.4391 9.893 11.8616 10.5681 11.2148 11.2149C10.5681 11.8616 9.89299 12.4391 9.21289 12.9404C9.62535 13.1579 10.0271 13.338 10.4121 13.4766C11.9146 14.0174 12.9173 13.8738 13.3955 13.3955C13.8737 12.9173 14.0174 11.9146 13.4766 10.4121C13.338 10.0271 13.1579 9.62535 12.9404 9.2129ZM3.05859 9.2129C2.84124 9.62523 2.662 10.0272 2.52344 10.4121C1.98255 11.9146 2.1263 12.9172 2.60449 13.3955C3.08281 13.8737 4.08548 14.0174 5.58789 13.4766C5.97267 13.338 6.37392 13.1577 6.78613 12.9404C6.10627 12.4393 5.43171 11.8614 4.78516 11.2149C4.13826 10.5679 3.55995 9.89313 3.05859 9.2129ZM7.99902 3.792C7.23182 4.31419 6.45309 4.95512 5.7041 5.70411C4.95512 6.45309 4.31418 7.23184 3.79199 7.99903C4.31434 8.76666 4.95474 9.54653 5.7041 10.2959C6.45312 11.0449 7.23274 11.6848 8 12.207C8.76728 11.6848 9.54686 11.0449 10.2959 10.2959C11.0449 9.54686 11.6848 8.76729 12.207 8.00001C11.6848 7.23275 11.0449 6.45312 10.2959 5.70411C9.54653 4.95475 8.76665 4.31434 7.99902 3.792ZM5.58789 2.52344C4.08536 1.98255 3.08275 2.12625 2.60449 2.6045C2.12624 3.08275 1.98255 4.08536 2.52344 5.5879C2.66192 5.97253 2.84143 6.37409 3.05859 6.78614C3.55986 6.10611 4.13843 5.43189 4.78516 4.78516C5.4319 4.13843 6.10609 3.55987 6.78613 3.0586C6.37408 2.84144 5.97252 2.66192 5.58789 2.52344ZM13.3955 2.6045C12.9172 2.12631 11.9146 1.98257 10.4121 2.52344C10.0272 2.66201 9.62522 2.84125 9.21289 3.0586C9.89313 3.55996 10.5679 4.13827 11.2148 4.78516C11.8614 5.43172 12.4392 6.10627 12.9404 6.78614C13.1577 6.37393 13.338 5.97267 13.4766 5.5879C14.0174 4.08549 13.8736 3.08281 13.3955 2.6045Z"
fill="currentColor"
/>
</svg>
</span>
<span class="chat-message__reasoning-status">{{ getReasoningStatusLabel(block) }}</span>
</div>
<button
type="button"
class="chat-message__reasoning-toggle"
:aria-label="isReasoningCollapsed(block.id) ? '展开深度思考' : '折叠深度思考'"
@click="toggleReasoningCollapse(block.id)"
>
<span class="chat-message__reasoning-chevron">
<svg
class="chat-message__reasoning-chevron-icon"
:class="{ 'chat-message__reasoning-chevron-icon--expanded': !isReasoningCollapsed(block.id) }"
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M5.5 2.15137L5.92383 2.57617L8.65137 5.30273C8.90706 5.55843 9.13382 5.78438 9.29785 5.98828C9.46883 6.20088 9.61756 6.44405 9.66602 6.75C9.69222 6.91565 9.69222 7.08435 9.66602 7.25C9.61756 7.55595 9.46883 7.79912 9.29785 8.01172C9.13382 8.21561 8.90706 8.44157 8.65137 8.69727L5.92383 11.4238L5.5 11.8486L4.65137 11L5.07617 10.5762L7.80273 7.84863C8.07732 7.57405 8.24849 7.40124 8.3623 7.25977C8.46904 7.12709 8.47813 7.07728 8.48047 7.0625C8.48703 7.02105 8.48703 6.97895 8.48047 6.9375C8.47813 6.92272 8.46904 6.87291 8.3623 6.74023C8.24848 6.59876 8.07732 6.42595 7.80273 6.15137L5.07617 3.42383L4.65137 3L5.5 2.15137Z"
fill="currentColor"
/>
</svg>
</span>
</button>
</div>
<div v-if="isReasoningCollapsed(block.id) === false" class="chat-message__reasoning-body">
<div
v-if="block.text"
class="chat-message__markdown chat-message__markdown--reasoning"
v-html="renderMessageMarkdown(block.text || '')"
/>
<div v-else class="chat-message__streaming chat-message__streaming--reasoning">
<div class="thinking-indicator">
<span class="thinking-indicator__text">正在思考</span>
</div>
</div>
</div>
</div>
</div>
<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>
<template v-else-if="block.type === 'schedule_card' && block.schedulePreview">
<ScheduleResultCard
:summary="block.schedulePreview.summary"
@click="openFineTuneModal(block.schedulePreview)"
/>
</template>
<div v-else-if="block.type === 'content_indicator'" class="assistant-timeline__answering-indicator">
<div class="thinking-indicator">
<span class="thinking-indicator__text">正在思考</span>
<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 class="chat-message__markdown chat-message__markdown--assistant" v-html="renderMessageMarkdown(block.text || '')" />
</div>
<template v-else-if="block.type === 'schedule_card' && block.schedulePreview">
<ScheduleResultCard
:summary="block.schedulePreview.summary"
@click="openFineTuneModal(block.schedulePreview)"
/>
</template>
<div v-else-if="block.type === 'content_indicator'" class="assistant-timeline__answering-indicator">
<div class="thinking-indicator">
<span class="thinking-indicator__text">正在思考</span>
</div>
</div>
</div>
</div>
</TransitionGroup>

View File

@@ -7,6 +7,7 @@ import DashboardView from '@/views/DashboardView.vue'
import ScheduleView from '@/views/ScheduleView.vue'
import ToolTracePrototypeView from '@/views/ToolTracePrototypeView.vue'
import TaskInteractiveDemo from '@/views/TaskInteractiveDemo.vue'
import DesignDemo from '@/views/DesignDemo.vue'
const router = createRouter({
history: createWebHistory(),
@@ -57,6 +58,11 @@ const router = createRouter({
name: 'tool-trace-prototype',
component: ToolTracePrototypeView,
},
{
path: '/design-demo',
name: 'design-demo',
component: DesignDemo,
},
],
})

View 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>