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 收口并同步接力状态
This commit is contained in:
302
backend/active_scheduler/service/session_bridge.go
Normal file
302
backend/active_scheduler/service/session_bridge.go
Normal file
@@ -0,0 +1,302 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user