Files
smartmate/backend/services/agent/sv/agent_timeline.go
Losita 25a608eaeb Version: 0.9.82.dev.260507
后端:
1. 登录注册补齐极验行为验证与跨域入口:gateway 新增 `/user/captcha/register`,登录/注册先做 GeeTest 初始化与二次校验,再进入 user/auth RPC;补充验证码失败/初始化失败/服务不可用响应码,并新增可配置 CORS middleware 适配分域部署。
2. 容器部署配置入口收口:`bootstrap.LoadConfig` 支持 `SMARTFLOW_CONFIG_FILE` 与环境变量覆盖,`config.example.yaml` / `config.docker.yaml` 补齐 geetest 与容器内服务地址,网关新增配置列表解析,便于 compose 场景直接挂载配置启动。
3. LLM outbox 与助手时间线稳定性修正:`cmd/llm` 显式绑定 llm 自身 topic/group,避免误入 agent consumer group;agent timeline 在 Redis 热缓存未落 MySQL 时改用 `seq` 兜底临时 id,避免前端历史回放撞 key。

前端:
4. 认证页接入极验并补齐提交前校验:新增 GeeTest 脚本加载与实例封装,登录/注册面板支持 challenge 初始化、切换面板重挂载、失败提示与提交前校验,认证 API/types 同步透传 geetest 三元组。
5. 前端部署基址与网关对接收口:Axios `baseURL`、Vue Router `history base` 与 Vite `base/dev proxy` 改为读取环境变量,新增 `frontend/.env.example`,支持子路径部署、容器内反向代理和本地联调共存。
6. 助手与工作台展示细节修正:AssistantPanel 历史重建优先使用真实 timeline id、缺失时退回 `seq` 保证消息主键唯一;首页主面板改为纵向可滚动并补底部留白,避免内容截断。

仓库:
7. 整站容器化交付链路补齐并重写说明文档:新增后端/前端 Dockerfile、`.dockerignore`、前端 Nginx 代理、`docker-compose.full.yml`、`.env.full.example` 与镜像打包/导入脚本,README 改写数据库/路由/部署章节,并新增 `docs/容器化部署说明.md` 说明离线镜像分发方案。
2026-05-07 00:58:27 +08:00

672 lines
22 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 sv
import (
"context"
"encoding/json"
"errors"
"log"
"strings"
"time"
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
eventsvc "github.com/LoveLosita/smartflow/backend/services/runtime/eventsvc"
"github.com/LoveLosita/smartflow/backend/services/runtime/model"
"gorm.io/gorm"
)
// GetConversationTimeline 返回指定会话的统一时间线(正文+卡片)列表。
//
// 职责边界:
// 1. 只读,不修改会话状态;
// 2. 顺序以 seq 为准,保证刷新后可稳定重建;
// 3. 优先读 Redis 时间线缓存,未命中再回源 MySQL。
func (s *AgentService) GetConversationTimeline(ctx context.Context, userID int, chatID string) ([]model.GetConversationTimelineItem, error) {
normalizedChatID := normalizeConversationID(chatID)
if userID <= 0 || strings.TrimSpace(normalizedChatID) == "" {
return nil, gorm.ErrRecordNotFound
}
exists, err := s.repo.IfChatExists(ctx, userID, normalizedChatID)
if err != nil {
return nil, err
}
if !exists {
return nil, gorm.ErrRecordNotFound
}
if s.cacheDAO != nil {
cacheItems, cacheErr := s.cacheDAO.GetConversationTimelineFromCache(ctx, userID, normalizedChatID)
if cacheErr == nil && cacheItems != nil {
return normalizeConversationTimelineItems(cacheItems), nil
}
if cacheErr != nil {
log.Printf("读取会话时间线缓存失败 user=%d chat=%s err=%v", userID, normalizedChatID, cacheErr)
}
}
events, err := s.repo.ListConversationTimelineEvents(ctx, userID, normalizedChatID)
if err != nil {
return nil, err
}
items := buildConversationTimelineItemsFromDB(events)
if s.cacheDAO != nil {
if err := s.cacheDAO.SetConversationTimelineToCache(ctx, userID, normalizedChatID, items); err != nil {
log.Printf("回填会话时间线缓存失败 user=%d chat=%s err=%v", userID, normalizedChatID, err)
}
if len(items) > 0 {
if err := s.cacheDAO.SetConversationTimelineSeq(ctx, userID, normalizedChatID, items[len(items)-1].Seq); err != nil {
log.Printf("回填会话时间线 seq 失败 user=%d chat=%s err=%v", userID, normalizedChatID, err)
}
}
}
return normalizeConversationTimelineItems(items), nil
}
// appendConversationTimelineEvent 统一追加单条时间线事件到 Redis + outbox。
//
// 步骤化说明:
// 1. 先分配同会话内单调递增的 seq优先走 RedisRedis 不可用时回退 DB
// 2. 再把事件同步追加到 Redis timeline cache保证刷新前的用户体验连续
// 3. 最后发布 outbox 事件异步落 MySQL与 chat history 的可靠落库方式对齐;
// 4. 未注入 eventPublisher 时走同步 MySQL fallback方便本地极简环境启动。
func (s *AgentService) appendConversationTimelineEvent(
ctx context.Context,
userID int,
chatID string,
kind string,
role string,
content string,
payload map[string]any,
tokensConsumed int,
) (int64, error) {
if s == nil || s.repo == nil {
return 0, errors.New("agent service is not initialized")
}
if ctx == nil {
ctx = context.Background()
}
normalizedChatID := strings.TrimSpace(chatID)
normalizedRole := strings.TrimSpace(role)
normalizedKind := canonicalizeTimelineKind(kind, normalizedRole)
normalizedContent := strings.TrimSpace(content)
if userID <= 0 || normalizedChatID == "" || normalizedKind == "" {
return 0, errors.New("invalid timeline event identity")
}
normalizedContent, normalizedPayload, shouldPersist := normalizeConversationTimelinePersistMaterial(normalizedKind, normalizedContent, payload)
if !shouldPersist {
return 0, nil
}
seq, err := s.nextConversationTimelineSeq(ctx, userID, normalizedChatID)
if err != nil {
return 0, err
}
persistPayload := (model.ChatTimelinePersistPayload{
UserID: userID,
ConversationID: normalizedChatID,
Seq: seq,
Kind: normalizedKind,
Role: normalizedRole,
Content: normalizedContent,
PayloadJSON: marshalTimelinePayloadJSON(normalizedPayload),
TokensConsumed: tokensConsumed,
}).Normalize()
if s.eventPublisher != nil {
now := time.Now()
// 1. 先写 Redis timeline cache让刷新前的本地态和下一轮上下文都能立即看到这条事件。
// 2. 再发布 outbox 事件,与 chat history 保持相同的“入队成功即返回”语义。
// 3. 若 outbox 发布失败,这里返回 error 交给上层处理,不在本方法里偷偷回退成同步写库。
s.appendConversationTimelineCacheNonBlocking(
ctx,
userID,
normalizedChatID,
buildConversationTimelineCacheItem(0, seq, normalizedKind, normalizedRole, normalizedContent, normalizedPayload, tokensConsumed, &now),
)
if err := eventsvc.PublishAgentTimelinePersistRequested(ctx, s.eventPublisher, persistPayload); err != nil {
return 0, err
}
return seq, nil
}
return s.appendConversationTimelineEventSync(ctx, userID, normalizedChatID, persistPayload, normalizedPayload)
}
// appendConversationTimelineEventSync 在未启用 outbox 时同步写 MySQL。
//
// 步骤化说明:
// 1. 本方法只作为 eventPublisher 为空时的降级路径,保证本地环境不依赖总线;
// 2. 若 seq 唯一键冲突,读取 DB 最大 seq 后补一个新序号,语义与 outbox 消费者保持一致;
// 3. MySQL 写入成功后再追加 Redis cache让缓存拿到数据库生成的 id/created_at。
func (s *AgentService) appendConversationTimelineEventSync(
ctx context.Context,
userID int,
chatID string,
persistPayload model.ChatTimelinePersistPayload,
payload map[string]any,
) (int64, error) {
eventID, eventCreatedAt, err := s.repo.SaveConversationTimelineEvent(ctx, persistPayload)
if err != nil {
// 1. 这里的冲突通常来自 Redis seq key 过期或落后于 DB。
// 2. 由于当前是同步写库链路,可以直接读取 DB 当前最大 seq 并补一个新序号。
// 3. 若重试后仍失败,则把数据库错误原样抛给上层,避免悄悄吞掉真实问题。
if !model.IsTimelineSeqConflictError(err) {
return 0, err
}
maxSeq, seqErr := s.repo.GetConversationTimelineMaxSeq(ctx, userID, chatID)
if seqErr != nil {
return 0, seqErr
}
persistPayload.Seq = maxSeq + 1
eventID, eventCreatedAt, err = s.repo.SaveConversationTimelineEvent(ctx, persistPayload)
if err != nil {
return 0, err
}
if s.cacheDAO != nil {
if setErr := s.cacheDAO.SetConversationTimelineSeq(ctx, userID, chatID, persistPayload.Seq); setErr != nil {
log.Printf("回填时间线 seq 到 Redis 失败 user=%d chat=%s seq=%d err=%v", userID, chatID, persistPayload.Seq, setErr)
}
}
}
s.appendConversationTimelineCacheNonBlocking(
ctx,
userID,
chatID,
buildConversationTimelineCacheItem(
eventID,
persistPayload.Seq,
persistPayload.Kind,
persistPayload.Role,
persistPayload.Content,
payload,
persistPayload.TokensConsumed,
eventCreatedAt,
),
)
return persistPayload.Seq, nil
}
// appendConversationTimelineCacheNonBlocking 尽力把单条 timeline 事件追加到 Redis。
//
// 步骤化说明:
// 1. 缓存失败不能反向影响主链路,因为 MySQL/outbox 才是最终可靠写入;
// 2. 这里统一记录错误日志,方便排查 Redis 不可用或 payload 序列化问题;
// 3. item 由调用方提前标准化,本方法不再二次裁剪业务字段。
func (s *AgentService) appendConversationTimelineCacheNonBlocking(
ctx context.Context,
userID int,
chatID string,
item model.GetConversationTimelineItem,
) {
if s.cacheDAO == nil {
return
}
if err := s.cacheDAO.AppendConversationTimelineEventToCache(ctx, userID, chatID, item); err != nil {
log.Printf("追加时间线缓存失败 user=%d chat=%s seq=%d kind=%s err=%v", userID, chatID, item.Seq, item.Kind, err)
}
}
// nextConversationTimelineSeq 负责分配一条新的 timeline seq。
//
// 步骤化说明:
// 1. 优先走 Redis INCR避免所有事件都串行依赖 MySQL
// 2. 再用 DB MAX(seq) 做一次自检尽量把“Redis key 过期/落后”在写入前提前修正;
// 3. 若 Redis 不可用,则直接回退到 DB MAX(seq)+1并把结果尽力回填回 Redis。
func (s *AgentService) nextConversationTimelineSeq(ctx context.Context, userID int, chatID string) (int64, error) {
if s == nil || s.repo == nil {
return 0, errors.New("agent service is not initialized")
}
if ctx == nil {
ctx = context.Background()
}
normalizedChatID := strings.TrimSpace(chatID)
if userID <= 0 || normalizedChatID == "" {
return 0, errors.New("invalid timeline seq identity")
}
if s.cacheDAO == nil {
return s.nextConversationTimelineSeqFromDB(ctx, userID, normalizedChatID)
}
candidateSeq, err := s.cacheDAO.IncrConversationTimelineSeq(ctx, userID, normalizedChatID)
if err != nil {
log.Printf("分配时间线 seq 时 Redis INCR 失败,回退 DB user=%d chat=%s err=%v", userID, normalizedChatID, err)
return s.nextConversationTimelineSeqFromDB(ctx, userID, normalizedChatID)
}
// 1. Redis key 缺失时INCR 常会从 1 重新开始,容易和已有 DB 记录撞 seq。
// 2. 这里额外对照一次 DB 最大 seq把明显落后的顺序号提前修正降低 outbox 消费时的补 seq 概率。
// 3. 该自检不会看到“尚未消费到 MySQL 的新 outbox 事件”,因此真正的极端并发兜底仍由消费者承担。
maxSeq, err := s.repo.GetConversationTimelineMaxSeq(ctx, userID, normalizedChatID)
if err != nil {
return 0, err
}
if candidateSeq > maxSeq {
return candidateSeq, nil
}
repairedSeq := maxSeq + 1
if err = s.cacheDAO.SetConversationTimelineSeq(ctx, userID, normalizedChatID, repairedSeq); err != nil {
log.Printf("修正时间线 seq 到 Redis 失败 user=%d chat=%s seq=%d err=%v", userID, normalizedChatID, repairedSeq, err)
}
return repairedSeq, nil
}
func (s *AgentService) nextConversationTimelineSeqFromDB(ctx context.Context, userID int, chatID string) (int64, error) {
maxSeq, err := s.repo.GetConversationTimelineMaxSeq(ctx, userID, chatID)
if err != nil {
return 0, err
}
nextSeq := maxSeq + 1
if s.cacheDAO != nil {
if setErr := s.cacheDAO.SetConversationTimelineSeq(ctx, userID, chatID, nextSeq); setErr != nil {
log.Printf("回填时间线 seq 到 Redis 失败 user=%d chat=%s seq=%d err=%v", userID, chatID, nextSeq, setErr)
}
}
return nextSeq, nil
}
// normalizeConversationTimelinePersistMaterial 负责把 timeline 原始输入收敛成“可缓存 + 可持久化”的口径。
//
// 职责边界:
// 1. 对普通事件只做浅拷贝,避免调用方后续继续改 map 影响已入队 payload
// 2. 对 thinking_summary 只保留 detail_summary 与必要 metadata明确剔除 short_summary
// 3. 若 thinking_summary 最终没有 detail_summary则返回 shouldPersist=false仅保留实时 SSE 展示,不进入 timeline。
func normalizeConversationTimelinePersistMaterial(kind string, content string, payload map[string]any) (string, map[string]any, bool) {
normalizedKind := strings.ToLower(strings.TrimSpace(kind))
normalizedContent := strings.TrimSpace(content)
if normalizedKind != model.AgentTimelineKindThinkingSummary {
return normalizedContent, cloneTimelinePayload(payload), true
}
return sanitizeThinkingSummaryPersistMaterial(normalizedContent, payload)
}
func sanitizeThinkingSummaryPersistMaterial(content string, payload map[string]any) (string, map[string]any, bool) {
detailSummary := readTimelinePayloadString(payload, "detail_summary")
if detailSummary == "" {
detailSummary = strings.TrimSpace(content)
}
if detailSummary == "" {
return "", nil, false
}
sanitized := make(map[string]any)
copyTrimmedTimelinePayloadField(payload, sanitized, "stage")
copyTrimmedTimelinePayloadField(payload, sanitized, "block_id")
copyTrimmedTimelinePayloadField(payload, sanitized, "display_mode")
copyTimelinePayloadFieldIfPresent(payload, sanitized, "summary_seq")
copyTimelinePayloadFieldIfPresent(payload, sanitized, "final")
copyTimelinePayloadFieldIfPresent(payload, sanitized, "duration_seconds")
sanitized["detail_summary"] = detailSummary
return detailSummary, sanitized, true
}
func copyTrimmedTimelinePayloadField(src map[string]any, dst map[string]any, key string) {
if len(src) == 0 || dst == nil {
return
}
value, ok := src[key]
if !ok {
return
}
text, ok := value.(string)
if !ok {
return
}
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return
}
dst[key] = trimmed
}
func copyTimelinePayloadFieldIfPresent(src map[string]any, dst map[string]any, key string) {
if len(src) == 0 || dst == nil {
return
}
value, ok := src[key]
if !ok || value == nil {
return
}
dst[key] = value
}
// persistAgentTimelineExtraEvent 把 SSE extra 里的结构化事件写入时间线。
//
// 说明:
// 1. 只持久化刷新后仍需重建的业务事件;
// 2. short_summary 这类临时展示信息会在 appendConversationTimelineEvent 内被过滤掉;
// 3. 失败只记日志,不反向打断当前 SSE 输出。
func (s *AgentService) persistAgentTimelineExtraEvent(
ctx context.Context,
userID int,
chatID string,
extra *agentstream.OpenAIChunkExtra,
) {
kind, ok := mapTimelineKindFromStreamExtra(extra)
if !ok {
return
}
if ctx == nil {
ctx = context.Background()
}
if _, err := s.appendConversationTimelineEvent(
ctx,
userID,
chatID,
kind,
"",
"",
buildTimelinePayloadFromStreamExtra(extra),
0,
); err != nil {
log.Printf("写入 agent 时间线事件失败 user=%d chat=%s kind=%s err=%v", userID, chatID, kind, err)
}
}
func buildConversationTimelineCacheItem(
eventID int64,
seq int64,
kind string,
role string,
content string,
payload map[string]any,
tokensConsumed int,
createdAt *time.Time,
) model.GetConversationTimelineItem {
// 1. Redis 热缓存先于 MySQL 落库写入时eventID 可能暂时为 0。
// 2. 如果把 0 原样透传给前端,历史重建阶段所有 cache-only 事件都会共享同一个“空 id”。
// 3. 前端常把 timeline id 当消息/块的挂载主键,撞 key 后会把不同轮次的 assistant 状态桶错误复用。
// 4. 因此这里在未拿到真实主键时,先退回使用会话内唯一的 seq 作为临时 id待后续从 DB 回源时再自然切换为真实 id。
if eventID <= 0 && seq > 0 {
eventID = seq
}
item := model.GetConversationTimelineItem{
ID: eventID,
Seq: seq,
Kind: kind,
Role: role,
Content: content,
Payload: cloneTimelinePayload(payload),
TokensConsumed: tokensConsumed,
}
if createdAt != nil {
item.CreatedAt = createdAt
}
return item
}
func buildConversationTimelineItemsFromDB(events []model.AgentTimelineEvent) []model.GetConversationTimelineItem {
if len(events) == 0 {
return make([]model.GetConversationTimelineItem, 0)
}
items := make([]model.GetConversationTimelineItem, 0, len(events))
for _, event := range events {
item := model.GetConversationTimelineItem{
ID: event.ID,
Seq: event.Seq,
Kind: strings.TrimSpace(event.Kind),
TokensConsumed: event.TokensConsumed,
CreatedAt: event.CreatedAt,
}
if event.Role != nil {
item.Role = strings.TrimSpace(*event.Role)
}
if event.Content != nil {
item.Content = strings.TrimSpace(*event.Content)
}
if event.Payload != nil {
var payload map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(*event.Payload)), &payload); err == nil && len(payload) > 0 {
item.Payload = payload
}
}
items = append(items, item)
}
return normalizeConversationTimelineItems(items)
}
// normalizeConversationTimelineItems 统一收敛 timeline 的 kind/role 口径,避免前端切分失效。
func normalizeConversationTimelineItems(items []model.GetConversationTimelineItem) []model.GetConversationTimelineItem {
if len(items) == 0 {
return make([]model.GetConversationTimelineItem, 0)
}
normalized := make([]model.GetConversationTimelineItem, 0, len(items))
for _, item := range items {
role := strings.ToLower(strings.TrimSpace(item.Role))
kind := canonicalizeTimelineKind(item.Kind, role)
// kind 缺失时尝试从 role 反推文本类型,保障“用户分段锚点”可用。
if kind == "" {
switch role {
case "user":
kind = model.AgentTimelineKindUserText
case "assistant":
kind = model.AgentTimelineKindAssistantText
}
}
// role 缺失时按文本类型补齐,减少前端额外兼容判断。
if role == "" {
switch kind {
case model.AgentTimelineKindUserText:
role = "user"
case model.AgentTimelineKindAssistantText:
role = "assistant"
}
}
item.Kind = kind
item.Role = role
normalized = append(normalized, item)
}
return normalized
}
// canonicalizeTimelineKind 统一 kind 别名,收敛到文档定义值。
func canonicalizeTimelineKind(kind string, role string) string {
normalizedKind := strings.ToLower(strings.TrimSpace(kind))
normalizedRole := strings.ToLower(strings.TrimSpace(role))
switch normalizedKind {
case model.AgentTimelineKindUserText,
model.AgentTimelineKindAssistantText,
model.AgentTimelineKindToolCall,
model.AgentTimelineKindToolResult,
model.AgentTimelineKindConfirmRequest,
model.AgentTimelineKindBusinessCard,
model.AgentTimelineKindScheduleCompleted,
model.AgentTimelineKindThinkingSummary:
return normalizedKind
case "text", "message", "query":
if normalizedRole == "user" {
return model.AgentTimelineKindUserText
}
if normalizedRole == "assistant" {
return model.AgentTimelineKindAssistantText
}
return normalizedKind
default:
return normalizedKind
}
}
func marshalTimelinePayloadJSON(payload map[string]any) string {
if len(payload) == 0 {
return ""
}
data, err := json.Marshal(payload)
if err != nil {
return ""
}
return string(data)
}
func cloneTimelinePayload(payload map[string]any) map[string]any {
if len(payload) == 0 {
return nil
}
cloned := make(map[string]any, len(payload))
for key, value := range payload {
cloned[key] = value
}
return cloned
}
func mapTimelineKindFromStreamExtra(extra *agentstream.OpenAIChunkExtra) (string, bool) {
if extra == nil {
return "", false
}
if isThinkingSummaryStreamExtra(extra) {
return model.AgentTimelineKindThinkingSummary, true
}
switch extra.Kind {
case agentstream.StreamExtraKindToolCall:
return model.AgentTimelineKindToolCall, true
case agentstream.StreamExtraKindToolResult:
return model.AgentTimelineKindToolResult, true
case agentstream.StreamExtraKindConfirm:
return model.AgentTimelineKindConfirmRequest, true
case agentstream.StreamExtraKindBusinessCard:
return model.AgentTimelineKindBusinessCard, true
case agentstream.StreamExtraKindScheduleCompleted:
return model.AgentTimelineKindScheduleCompleted, true
default:
return "", false
}
}
func buildTimelinePayloadFromStreamExtra(extra *agentstream.OpenAIChunkExtra) map[string]any {
if extra == nil {
return nil
}
if isThinkingSummaryStreamExtra(extra) {
return buildThinkingSummaryTimelinePayload(extra)
}
payload := map[string]any{
"stage": strings.TrimSpace(extra.Stage),
"block_id": strings.TrimSpace(extra.BlockID),
"display_mode": string(extra.DisplayMode),
}
if extra.Tool != nil {
toolPayload := map[string]any{
"name": strings.TrimSpace(extra.Tool.Name),
"status": strings.TrimSpace(extra.Tool.Status),
"summary": strings.TrimSpace(extra.Tool.Summary),
"arguments_preview": strings.TrimSpace(extra.Tool.ArgumentsPreview),
}
if len(extra.Tool.ArgumentView) > 0 {
toolPayload["argument_view"] = cloneTimelinePayload(extra.Tool.ArgumentView)
}
if len(extra.Tool.ResultView) > 0 {
toolPayload["result_view"] = cloneTimelinePayload(extra.Tool.ResultView)
}
payload["tool"] = toolPayload
}
if extra.Confirm != nil {
payload["confirm"] = map[string]any{
"interaction_id": strings.TrimSpace(extra.Confirm.InteractionID),
"title": strings.TrimSpace(extra.Confirm.Title),
"summary": strings.TrimSpace(extra.Confirm.Summary),
}
}
if extra.Interrupt != nil {
payload["interrupt"] = map[string]any{
"interaction_id": strings.TrimSpace(extra.Interrupt.InteractionID),
"type": strings.TrimSpace(extra.Interrupt.Type),
"summary": strings.TrimSpace(extra.Interrupt.Summary),
}
}
if extra.BusinessCard != nil {
payload["business_card"] = cloneStreamBusinessCard(extra.BusinessCard)
}
if len(extra.Meta) > 0 {
payload["meta"] = cloneTimelinePayload(extra.Meta)
}
return payload
}
func isThinkingSummaryStreamExtra(extra *agentstream.OpenAIChunkExtra) bool {
if extra == nil {
return false
}
return strings.EqualFold(strings.TrimSpace(string(extra.Kind)), model.AgentTimelineKindThinkingSummary)
}
func buildThinkingSummaryTimelinePayload(extra *agentstream.OpenAIChunkExtra) map[string]any {
payload := map[string]any{
"stage": strings.TrimSpace(extra.Stage),
"block_id": strings.TrimSpace(extra.BlockID),
"display_mode": string(extra.DisplayMode),
}
if extra.ThinkingSummary != nil {
summary := extra.ThinkingSummary
payload["summary_seq"] = summary.SummarySeq
payload["final"] = summary.Final
payload["duration_seconds"] = summary.DurationSeconds
if detailSummary := strings.TrimSpace(summary.DetailSummary); detailSummary != "" {
payload["detail_summary"] = detailSummary
}
return payload
}
if detailSummary := readTimelineExtraMetaString(extra.Meta, "detail_summary"); detailSummary != "" {
payload["detail_summary"] = detailSummary
}
return payload
}
func readTimelineExtraMetaString(meta map[string]any, key string) string {
if len(meta) == 0 {
return ""
}
raw, ok := meta[key]
if !ok {
return ""
}
text, ok := raw.(string)
if !ok {
return ""
}
return strings.TrimSpace(text)
}
func readTimelinePayloadString(payload map[string]any, key string) string {
if len(payload) == 0 {
return ""
}
raw, ok := payload[key]
if !ok {
return ""
}
text, ok := raw.(string)
if !ok {
return ""
}
return strings.TrimSpace(text)
}
func cloneStreamBusinessCard(card *agentstream.StreamBusinessCardExtra) map[string]any {
if card == nil {
return nil
}
cloned := map[string]any{
"card_type": strings.TrimSpace(card.CardType),
"title": strings.TrimSpace(card.Title),
"summary": strings.TrimSpace(card.Summary),
"source": strings.TrimSpace(card.Source),
}
if len(card.Data) > 0 {
cloned["data"] = cloneTimelinePayload(card.Data)
}
return cloned
}