Files
smartmate/backend/active_scheduler/service/session_bridge.go
Losita a3eaa9b2c2 Version: 0.9.61.dev.260501
后端:
1. 主动调度 graph + session bridge 收口——把 dry-run / select / preview / confirm / rerun 串成受限 graph,新增 active_schedule_sessions 缓存与聊天拦截,ready_preview 后释放回自由聊天
2. 会话与通知链路对齐——notification 统一绑定 conversation_id,action_url 指向 /assistant/{conversation_id},会话不存在改回 404 语义,避免 wrong param type 误导排障
3. estimated_sections 写入与主动调度消费链路补齐——任务创建、quick task 与随口记入口都透传估计节数,主动调度只消费落库值

前端:
4. AssistantPanel 最小适配主动调度预览与失败态——复用主动调度卡片/微调弹窗,补历史加载失败可见提示与跨账号会话拦截

文档:
5. 更新主动调度缺口分阶段实施计划和实现方案,标记阶段 0-2 收口并同步接力状态
2026-05-01 20:48:32 +08:00

303 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview"
"github.com/LoveLosita/smartflow/backend/active_scheduler/selection"
"github.com/LoveLosita/smartflow/backend/dao"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/google/uuid"
"gorm.io/gorm"
)
var (
activeScheduleConversationNamespace = uuid.NewSHA1(uuid.NameSpaceURL, []byte("smartflow:active_schedule:conversation"))
activeScheduleSessionNamespace = uuid.NewSHA1(uuid.NameSpaceURL, []byte("smartflow:active_schedule:session"))
)
// WithActiveScheduleSessionBridge 注入主动调度 session 预创建所需的 DAO。
//
// 职责边界:
// 1. 只把 trigger -> notification 前的会话桥接能力接入 workflow
// 2. 不改变 dry-run / preview / notification 的主状态机;
// 3. 为空时保留旧能力,便于局部测试与迁移期回退。
func WithActiveScheduleSessionBridge(agentDAO *dao.AgentDAO, sessionDAO *dao.ActiveScheduleSessionDAO) TriggerWorkflowOption {
return func(s *TriggerWorkflowService) {
if s == nil {
return
}
s.agentDAO = agentDAO
s.sessionDAO = sessionDAO
}
}
// bootstrapActiveScheduleConversationInTx 负责在 notification 发出前预建会话与首屏内容。
//
// 步骤化说明:
// 1. 先生成确定性的 conversation/session ID保证 trigger 重试时不会拆成多条会话;
// 2. 再在同一事务里创建或复用 agent_chats 和 active_schedule_sessions
// 3. 如果是首次落库,则顺手补一条 assistant_text必要时再补一张主动调度卡片
// 4. 任一步失败都直接返回 error让上层事务整体回滚避免“通知已发但会话底稿没落”。
func (s *TriggerWorkflowService) bootstrapActiveScheduleConversationInTx(
ctx context.Context,
tx *gorm.DB,
triggerRow model.ActiveScheduleTrigger,
previewDetail activepreview.ActiveSchedulePreviewDetail,
selectionResult selection.Result,
now time.Time,
) error {
if s == nil {
return errors.New("主动调度会话桥未初始化")
}
if s.agentDAO == nil || s.sessionDAO == nil {
return nil
}
if tx == nil {
return errors.New("gorm tx 不能为空")
}
if triggerRow.ID == "" {
return errors.New("trigger_id 不能为空")
}
conversationID := buildActiveScheduleConversationID(triggerRow.ID)
sessionID := buildActiveScheduleSessionID(triggerRow.ID)
txAgentDAO := s.agentDAO.WithTx(tx)
txSessionDAO := s.sessionDAO.WithTx(tx)
if err := ensureAgentConversationExists(ctx, txAgentDAO, triggerRow.UserID, conversationID); err != nil {
return err
}
baseSeq, err := txAgentDAO.GetConversationTimelineMaxSeq(ctx, triggerRow.UserID, conversationID)
if err != nil {
return err
}
// 1. 只有首次创建会话时才写首屏消息,避免同一 trigger 的重试把时间线重复刷一遍。
// 2. 若 timeline 已存在,说明这段主动调度会话已经被成功预热过,直接复用现成内容即可。
if baseSeq == 0 {
assistantText := resolveInitialActiveScheduleAssistantText(selectionResult, previewDetail)
if assistantText != "" {
if err := txAgentDAO.SaveChatHistoryInTx(ctx, triggerRow.UserID, conversationID, "assistant", assistantText, "", 0, 0); err != nil {
return err
}
if err := saveActiveScheduleTimelineEvent(ctx, txAgentDAO, triggerRow.UserID, conversationID, baseSeq+1, model.AgentTimelineKindAssistantText, "assistant", assistantText, nil); err != nil {
return err
}
baseSeq++
}
if shouldSeedActiveSchedulePreviewCard(selectionResult) {
cardPayload, err := buildActiveScheduleBusinessCardPayload(previewDetail)
if err != nil {
return err
}
if err := saveActiveScheduleTimelineEvent(ctx, txAgentDAO, triggerRow.UserID, conversationID, baseSeq+1, model.AgentTimelineKindBusinessCard, "assistant", assistantText, cardPayload); err != nil {
return err
}
}
}
sessionSnapshot := &model.ActiveScheduleSessionSnapshot{
SessionID: sessionID,
UserID: triggerRow.UserID,
ConversationID: conversationID,
TriggerID: triggerRow.ID,
CurrentPreviewID: strings.TrimSpace(previewDetail.PreviewID),
Status: resolveInitialActiveScheduleSessionStatus(selectionResult),
State: buildInitialActiveScheduleSessionState(selectionResult, previewDetail),
CreatedAt: now,
UpdatedAt: now,
}
return txSessionDAO.UpsertActiveScheduleSession(ctx, sessionSnapshot)
}
func buildActiveScheduleConversationID(triggerID string) string {
normalized := strings.TrimSpace(triggerID)
if normalized == "" {
return uuid.NewString()
}
return uuid.NewSHA1(activeScheduleConversationNamespace, []byte(normalized)).String()
}
func buildActiveScheduleSessionID(triggerID string) string {
normalized := strings.TrimSpace(triggerID)
if normalized == "" {
return uuid.NewString()
}
return uuid.NewSHA1(activeScheduleSessionNamespace, []byte(normalized)).String()
}
func ensureAgentConversationExists(ctx context.Context, agentDAO *dao.AgentDAO, userID int, conversationID string) error {
if agentDAO == nil {
return errors.New("agent dao 不能为空")
}
if userID <= 0 {
return fmt.Errorf("invalid user_id: %d", userID)
}
normalizedConversationID := strings.TrimSpace(conversationID)
if normalizedConversationID == "" {
return errors.New("conversation_id 不能为空")
}
exists, err := agentDAO.IfChatExists(ctx, userID, normalizedConversationID)
if err != nil {
return err
}
if exists {
return nil
}
_, err = agentDAO.CreateNewChat(userID, normalizedConversationID)
return err
}
func resolveInitialActiveScheduleAssistantText(selectionResult selection.Result, previewDetail activepreview.ActiveSchedulePreviewDetail) string {
switch selectionResult.Action {
case selection.ActionAskUser:
return firstNonEmptyString(
selectionResult.AskUserQuestion,
selectionResult.ExplanationText,
previewDetail.Explanation,
previewDetail.Notification,
"请先补充主动调度需要的关键信息。",
)
default:
return firstNonEmptyString(
selectionResult.ExplanationText,
selectionResult.NotificationSummary,
previewDetail.Notification,
previewDetail.Explanation,
"主动调度建议已更新。",
)
}
}
func shouldSeedActiveSchedulePreviewCard(selectionResult selection.Result) bool {
return selectionResult.Action == selection.ActionSelectCandidate
}
func resolveInitialActiveScheduleSessionStatus(selectionResult selection.Result) string {
switch selectionResult.Action {
case selection.ActionAskUser:
return model.ActiveScheduleSessionStatusWaitingUserReply
case selection.ActionSelectCandidate:
return model.ActiveScheduleSessionStatusReadyPreview
default:
return model.ActiveScheduleSessionStatusIgnored
}
}
func buildInitialActiveScheduleSessionState(
selectionResult selection.Result,
previewDetail activepreview.ActiveSchedulePreviewDetail,
) model.ActiveScheduleSessionState {
state := model.ActiveScheduleSessionState{
LastCandidateID: strings.TrimSpace(selectionResult.SelectedCandidateID),
MissingInfo: cloneStringSlice(previewDetail.ContextSummary.MissingInfo),
}
if !previewDetail.ExpiresAt.IsZero() {
expiresAt := previewDetail.ExpiresAt
state.ExpiresAt = &expiresAt
}
switch selectionResult.Action {
case selection.ActionAskUser:
state.PendingQuestion = firstNonEmptyString(
selectionResult.AskUserQuestion,
selectionResult.ExplanationText,
)
case selection.ActionSelectCandidate:
state.PendingQuestion = ""
state.MissingInfo = nil
state.FailedReason = ""
default:
state.PendingQuestion = ""
state.MissingInfo = nil
state.ExpiresAt = nil
}
return state
}
func buildActiveScheduleBusinessCardPayload(detail activepreview.ActiveSchedulePreviewDetail) (map[string]any, error) {
raw, err := json.Marshal(map[string]any{
"business_card": map[string]any{
"card_type": "active_schedule_preview",
"title": "SmartFlow 日程调整建议",
"summary": firstNonEmptyString(detail.Notification, detail.Explanation, detail.SelectedCandidate.Summary),
"data": detail,
},
})
if err != nil {
return nil, err
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, err
}
return payload, nil
}
func saveActiveScheduleTimelineEvent(
ctx context.Context,
agentDAO *dao.AgentDAO,
userID int,
conversationID string,
seq int64,
kind string,
role string,
content string,
payload map[string]any,
) error {
if agentDAO == nil {
return errors.New("agent dao 不能为空")
}
normalizedConversationID := strings.TrimSpace(conversationID)
if userID <= 0 || normalizedConversationID == "" {
return errors.New("时间线事件主键不合法")
}
payloadJSON := ""
if len(payload) > 0 {
raw, err := json.Marshal(payload)
if err != nil {
return err
}
payloadJSON = string(raw)
}
_, _, err := agentDAO.SaveConversationTimelineEvent(ctx, model.ChatTimelinePersistPayload{
UserID: userID,
ConversationID: normalizedConversationID,
Seq: seq,
Kind: kind,
Role: role,
Content: content,
PayloadJSON: payloadJSON,
TokensConsumed: 0,
})
return err
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func cloneStringSlice(values []string) []string {
if len(values) == 0 {
return nil
}
cloned := make([]string, len(values))
copy(cloned, values)
return cloned
}