Version: 0.8.2.dev.260327
后端: 1.修复了消息重试链路的相关问题 2.新增redis乐观写消息机制,即使前端在重试完消息后立刻刷新,也能在redis里面读到数据 前端: 1.修了一些bug
This commit is contained in:
@@ -223,7 +223,10 @@ func (a *AgentDAO) EnsureRetryGroupSeed(ctx context.Context, userID int, chatID,
|
||||
}
|
||||
|
||||
return a.db.WithContext(ctx).
|
||||
Model(&model.ChatHistory{}).
|
||||
Model(&model.ChatHistory{
|
||||
UserID: userID,
|
||||
ChatID: chatID,
|
||||
}).
|
||||
Where("user_id = ? AND chat_id = ? AND id IN ?", userID, chatID, ids).
|
||||
Where("(retry_group_id IS NULL OR retry_group_id = '')").
|
||||
Updates(map[string]any{
|
||||
|
||||
@@ -35,44 +35,48 @@ func (d *CacheDAO) schedulePreviewKey(userID int, conversationID string) string
|
||||
return fmt.Sprintf("smartflow:schedule_preview:u:%d:c:%s", userID, conversationID)
|
||||
}
|
||||
|
||||
// SetBlacklist 鎶?Token 鎵旇繘榛戝悕鍗?
|
||||
func (d *CacheDAO) conversationHistoryKey(userID int, conversationID string) string {
|
||||
return fmt.Sprintf("smartflow:conversation_history:u:%d:c:%s", userID, conversationID)
|
||||
}
|
||||
|
||||
// SetBlacklist 把 Token 写入黑名单。
|
||||
func (d *CacheDAO) SetBlacklist(jti string, expiration time.Duration) error {
|
||||
return d.client.Set(context.Background(), "blacklist:"+jti, "1", expiration).Err()
|
||||
}
|
||||
|
||||
// IsBlacklisted 妫€鏌?Token 鏄惁鍦ㄩ粦鍚嶅崟涓?
|
||||
// IsBlacklisted 检查 Token 是否在黑名单中。
|
||||
func (d *CacheDAO) IsBlacklisted(jti string) (bool, error) {
|
||||
result, err := d.client.Get(context.Background(), "blacklist:"+jti).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return false, nil // 涓嶅湪榛戝悕鍗?
|
||||
return false, nil // 不在黑名单中
|
||||
} else if err != nil {
|
||||
return false, err // 鍏朵粬閿欒
|
||||
return false, err // 其他错误
|
||||
}
|
||||
return result == "1", nil // 鍦ㄩ粦鍚嶅崟
|
||||
return result == "1", nil // 在黑名单中
|
||||
}
|
||||
|
||||
func (d *CacheDAO) AddTaskClassList(ctx context.Context, userID int, list *model.UserGetTaskClassesResponse) error {
|
||||
// 1. 瀹氫箟 Key锛屼娇鐢?userID 闅旂涓嶅悓鐢ㄦ埛鐨勬暟鎹?
|
||||
// 1. 定义 Key,使用 userID 隔离不同用户的数据。
|
||||
key := fmt.Sprintf("smartflow:task_classes:%d", userID)
|
||||
// 2. 搴忓垪鍖栵細灏嗙粨鏋勪綋杞负 []byte
|
||||
// 2. 序列化:将结构体转为 []byte。
|
||||
data, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 3. 瀛樺偍锛氳缃?30 鍒嗛挓杩囨湡锛堟牴鎹笟鍔$伒娲昏皟鏁达級
|
||||
// 3. 存储:设置 30 分钟过期,可按业务需要调整。
|
||||
return d.client.Set(ctx, key, data, 30*time.Minute).Err()
|
||||
}
|
||||
|
||||
func (d *CacheDAO) GetTaskClassList(ctx context.Context, userID int) (*model.UserGetTaskClassesResponse, error) {
|
||||
key := fmt.Sprintf("smartflow:task_classes:%d", userID)
|
||||
var resp model.UserGetTaskClassesResponse
|
||||
// 1. 浠?Redis 鑾峰彇瀛楃涓?
|
||||
// 1. 从 Redis 获取字符串。
|
||||
val, err := d.client.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
// 娉ㄦ剰锛氬鏋滄槸 redis.Nil锛屼氦缁?Service 灞傚鐞嗘煡搴撻€昏緫
|
||||
// 注意:若是 redis.Nil,则交给 Service 层处理回源查询逻辑。
|
||||
return &resp, err
|
||||
}
|
||||
// 2. 鍙嶅簭鍒楀寲锛氬皢 JSON 杩樺師鍥炵粨鏋勪綋
|
||||
// 2. 反序列化:将 JSON 还原回结构体。
|
||||
err = json.Unmarshal([]byte(val), &resp)
|
||||
return &resp, err
|
||||
}
|
||||
@@ -85,9 +89,9 @@ func (d *CacheDAO) DeleteTaskClassList(ctx context.Context, userID int) error {
|
||||
func (d *CacheDAO) GetRecord(ctx context.Context, key string) (string, error) {
|
||||
val, err := d.client.Get(ctx, key).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return "", nil // 姝e父娌″懡涓殑鎯呭喌
|
||||
return "", nil // 正常未命中
|
||||
}
|
||||
return val, err // 鐪熸鐨?Redis 鎶ラ敊
|
||||
return val, err // 真正的 Redis 错误
|
||||
}
|
||||
|
||||
func (d *CacheDAO) SaveRecord(ctx context.Context, key string, val string, ttl time.Duration) error {
|
||||
@@ -118,7 +122,7 @@ func (d *CacheDAO) GetUserTasksFromCache(ctx context.Context, userID int) ([]mod
|
||||
var tasks []model.Task
|
||||
val, err := d.client.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return nil, err // 娉ㄦ剰锛氬鏋滄槸 redis.Nil锛屼氦缁?Service 灞傚鐞嗘煡搴撻€昏緫
|
||||
return nil, err // 注意:若是 redis.Nil,则交给 Service 层处理回源查询逻辑
|
||||
}
|
||||
err = json.Unmarshal([]byte(val), &tasks)
|
||||
return tasks, err
|
||||
@@ -154,7 +158,7 @@ func (d *CacheDAO) GetUserTodayScheduleFromCache(ctx context.Context, userID int
|
||||
var schedules []model.UserTodaySchedule
|
||||
val, err := d.client.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return nil, err // 娉ㄦ剰锛氬鏋滄槸 redis.Nil锛屼氦缁?Service 灞傚鐞嗘煡搴撻€昏緫
|
||||
return nil, err // 注意:若是 redis.Nil,则交给 Service 层处理回源查询逻辑
|
||||
}
|
||||
err = json.Unmarshal([]byte(val), &schedules)
|
||||
return schedules, err
|
||||
@@ -166,7 +170,7 @@ func (d *CacheDAO) SetUserTodayScheduleToCache(ctx context.Context, userID int,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 璁剧疆杩囨湡鏃堕棿涓哄綋澶╁墿浣欑殑鏃堕棿锛岀‘淇濇瘡澶╂洿鏂颁竴娆$紦瀛?
|
||||
// 设置过期时间为“当天剩余时间”,保证每天自然刷新一次缓存。
|
||||
return d.client.Set(ctx, key, data, time.Until(time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day()+1, 0, 0, 0, 0, time.Now().Location()))).Err()
|
||||
}
|
||||
|
||||
@@ -180,7 +184,7 @@ func (d *CacheDAO) GetUserWeeklyScheduleFromCache(ctx context.Context, userID in
|
||||
var schedules model.UserWeekSchedule
|
||||
val, err := d.client.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return nil, err // 娉ㄦ剰锛氬鏋滄槸 redis.Nil锛屼氦缁?Service 灞傚鐞嗘煡搴撻€昏緫
|
||||
return nil, err // 注意:若是 redis.Nil,则交给 Service 层处理回源查询逻辑
|
||||
}
|
||||
err = json.Unmarshal([]byte(val), &schedules)
|
||||
return &schedules, err
|
||||
@@ -192,7 +196,7 @@ func (d *CacheDAO) SetUserWeeklyScheduleToCache(ctx context.Context, userID int,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 璁剧疆杩囨湡鏃堕棿涓轰竴澶?
|
||||
// 设置过期时间为一天。
|
||||
return d.client.Set(ctx, key, data, 24*time.Hour).Err()
|
||||
}
|
||||
|
||||
@@ -206,7 +210,7 @@ func (d *CacheDAO) GetUserRecentCompletedSchedulesFromCache(ctx context.Context,
|
||||
var resp model.UserRecentCompletedScheduleResponse
|
||||
val, err := d.client.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return &resp, err // 娉ㄦ剰锛氬鏋滄槸 redis.Nil锛屼氦缁?Service 灞傚鐞嗘煡搴撻€昏緫
|
||||
return &resp, err // 注意:若是 redis.Nil,则交给 Service 层处理回源查询逻辑
|
||||
}
|
||||
err = json.Unmarshal([]byte(val), &resp)
|
||||
return &resp, err
|
||||
@@ -218,7 +222,7 @@ func (d *CacheDAO) SetUserRecentCompletedSchedulesToCache(ctx context.Context, u
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 璁剧疆杩囨湡鏃堕棿涓?0鍒嗛挓
|
||||
// 设置过期时间为 30 分钟。
|
||||
return d.client.Set(ctx, key, data, 30*time.Minute).Err()
|
||||
}
|
||||
|
||||
@@ -232,7 +236,7 @@ func (d *CacheDAO) DeleteUserRecentCompletedSchedulesFromCache(ctx context.Conte
|
||||
return err
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
// 鐢?UNLINK\(\) 寮傛鍒犻櫎锛岄檷浣庨樆濉為闄╋紱濡傞渶寮轰竴鑷村垹闄ゅ彲鏀圭敤 Del\(\)
|
||||
// 使用 UNLINK() 异步删除,降低阻塞风险;若需要强一致删除可改用 Del()。
|
||||
if err := d.client.Unlink(ctx, keys...).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -250,10 +254,10 @@ func (d *CacheDAO) GetUserOngoingScheduleFromCache(ctx context.Context, userID i
|
||||
var schedule model.OngoingSchedule
|
||||
val, err := d.client.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return &schedule, err // 娉ㄦ剰锛氬鏋滄槸 redis.Nil锛屼氦缁?Service 灞傚鐞嗘煡搴撻€昏緫
|
||||
return &schedule, err // 注意:若是 redis.Nil,则交给 Service 层处理回源查询逻辑
|
||||
}
|
||||
if val == "null" {
|
||||
return nil, nil // 涔嬪墠缂撳瓨杩囨病鏈夋鍦ㄨ繘琛岀殑鏃ョ▼锛岀洿鎺ヨ繑鍥?nil
|
||||
return nil, nil // 之前缓存过“当前没有正在进行的日程”,这里直接返回 nil
|
||||
}
|
||||
err = json.Unmarshal([]byte(val), &schedule)
|
||||
return &schedule, err
|
||||
@@ -261,7 +265,7 @@ func (d *CacheDAO) GetUserOngoingScheduleFromCache(ctx context.Context, userID i
|
||||
|
||||
func (d *CacheDAO) SetUserOngoingScheduleToCache(ctx context.Context, userID int, schedule *model.OngoingSchedule) error {
|
||||
if schedule == nil {
|
||||
// 濡傛灉娌℃湁姝e湪杩涜鐨勬棩绋嬶紝璁剧疆绌哄€煎苟鐭殏杩囨湡锛岄伩鍏嶉绻佹煡搴?
|
||||
// 如果当前没有正在进行的日程,则缓存空值并短暂过期,避免频繁回源查询。
|
||||
key := fmt.Sprintf("smartflow:ongoing_schedule:%d", userID)
|
||||
return d.client.Set(ctx, key, "null", 5*time.Minute).Err()
|
||||
}
|
||||
@@ -270,7 +274,7 @@ func (d *CacheDAO) SetUserOngoingScheduleToCache(ctx context.Context, userID int
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 璁剧疆杩囨湡鏃堕棿涓哄埌 endTime 鐨勫墿浣欐椂闂达紙鑻ュ凡杩囨湡鍒欎笉鍐欏叆缂撳瓨锛?
|
||||
// 设置过期时间为距离 endTime 的剩余时长;若已过期,则不再写入缓存。
|
||||
ttl := time.Until(schedule.EndTime)
|
||||
if ttl <= 0 {
|
||||
return nil
|
||||
@@ -443,3 +447,81 @@ func (d *CacheDAO) DeleteSchedulePlanPreviewFromCache(ctx context.Context, userI
|
||||
}
|
||||
return d.client.Del(ctx, d.schedulePreviewKey(userID, normalizedConversationID)).Err()
|
||||
}
|
||||
|
||||
// SetConversationHistoryToCache 写入“会话历史视图”缓存。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责按 user_id + conversation_id 写入前端历史查询所需的稳定 DTO;
|
||||
// 2. 只负责缓存当前可展示历史,不负责上下文窗口缓存;
|
||||
// 3. 不负责 DB 回源,也不负责重试分组补算。
|
||||
func (d *CacheDAO) SetConversationHistoryToCache(ctx context.Context, userID int, conversationID string, items []model.GetConversationHistoryItem) error {
|
||||
if d == nil || d.client == nil {
|
||||
return errors.New("cache dao is not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return fmt.Errorf("invalid user_id: %d", userID)
|
||||
}
|
||||
normalizedConversationID := strings.TrimSpace(conversationID)
|
||||
if normalizedConversationID == "" {
|
||||
return errors.New("conversation_id is empty")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal conversation history failed: %w", err)
|
||||
}
|
||||
return d.client.Set(ctx, d.conversationHistoryKey(userID, normalizedConversationID), data, 1*time.Hour).Err()
|
||||
}
|
||||
|
||||
// GetConversationHistoryFromCache 读取“会话历史视图”缓存。
|
||||
//
|
||||
// 输入输出语义:
|
||||
// 1. 命中时返回历史 DTO 切片与 nil error;
|
||||
// 2. 未命中时返回 (nil, nil);
|
||||
// 3. Redis 异常或反序列化失败时返回 error。
|
||||
func (d *CacheDAO) GetConversationHistoryFromCache(ctx context.Context, userID int, conversationID string) ([]model.GetConversationHistoryItem, error) {
|
||||
if d == nil || d.client == nil {
|
||||
return nil, errors.New("cache dao is not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, fmt.Errorf("invalid user_id: %d", userID)
|
||||
}
|
||||
normalizedConversationID := strings.TrimSpace(conversationID)
|
||||
if normalizedConversationID == "" {
|
||||
return nil, errors.New("conversation_id is empty")
|
||||
}
|
||||
|
||||
raw, err := d.client.Get(ctx, d.conversationHistoryKey(userID, normalizedConversationID)).Result()
|
||||
if err == redis.Nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var items []model.GetConversationHistoryItem
|
||||
if err = json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal conversation history failed: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// DeleteConversationHistoryFromCache 删除“会话历史视图”缓存。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 删除操作是幂等的,key 不存在也视为成功;
|
||||
// 2. 该方法用于 chat_histories 写入/补种 retry 分组后触发失效;
|
||||
// 3. 这里只处理前端历史视图缓存,不影响 Agent 上下文热缓存。
|
||||
func (d *CacheDAO) DeleteConversationHistoryFromCache(ctx context.Context, userID int, conversationID string) error {
|
||||
if d == nil || d.client == nil {
|
||||
return errors.New("cache dao is not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return fmt.Errorf("invalid user_id: %d", userID)
|
||||
}
|
||||
normalizedConversationID := strings.TrimSpace(conversationID)
|
||||
if normalizedConversationID == "" {
|
||||
return errors.New("conversation_id is empty")
|
||||
}
|
||||
return d.client.Del(ctx, d.conversationHistoryKey(userID, normalizedConversationID)).Err()
|
||||
}
|
||||
|
||||
@@ -65,7 +65,11 @@ func (p *GormCachePlugin) dispatchCacheLogic(modelObj interface{}, db *gorm.DB)
|
||||
p.invalidTaskCache(m.UserID)
|
||||
case model.AgentScheduleState:
|
||||
p.invalidSchedulePlanPreviewCache(m.UserID, m.ConversationID)
|
||||
case model.AgentOutboxMessage, model.ChatHistory, model.AgentChat, model.User:
|
||||
case model.ChatHistory:
|
||||
p.invalidConversationHistoryCache(m.UserID, m.ChatID)
|
||||
case model.AgentChat:
|
||||
p.invalidConversationHistoryCache(m.UserID, m.ChatID)
|
||||
case model.AgentOutboxMessage, model.User:
|
||||
// 这些模型目前没有定义缓存逻辑,先不处理
|
||||
default:
|
||||
// 只有真正没定义的模型才会到这里
|
||||
@@ -124,3 +128,20 @@ func (p *GormCachePlugin) invalidSchedulePlanPreviewCache(userID int, conversati
|
||||
log.Printf("[GORM-Cache] Invalidated schedule preview cache for user %d conversation %s", userID, normalizedConversationID)
|
||||
}()
|
||||
}
|
||||
|
||||
func (p *GormCachePlugin) invalidConversationHistoryCache(userID int, conversationID string) {
|
||||
normalizedConversationID := strings.TrimSpace(conversationID)
|
||||
if userID == 0 || normalizedConversationID == "" {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
// 1. 这里的调用目的:当聊天历史写入或重试补种更新后,删除“前端历史视图缓存”。
|
||||
// 2. 这样下次访问 conversation-history 时会回源 DB,并把最新 retry 版本完整回填缓存。
|
||||
// 3. 注意:这里只删历史视图缓存,不删 Agent 上下文热缓存,避免影响聊天首 token。
|
||||
if err := p.cacheDAO.DeleteConversationHistoryFromCache(context.Background(), userID, normalizedConversationID); err != nil {
|
||||
log.Printf("[GORM-Cache] Failed to invalidate conversation history cache for user %d conversation %s: %v", userID, normalizedConversationID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[GORM-Cache] Invalidated conversation history cache for user %d conversation %s", userID, normalizedConversationID)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -435,6 +435,19 @@ func (s *AgentService) runNormalChatFlow(
|
||||
pushErrNonBlocking(errChan, err)
|
||||
return
|
||||
}
|
||||
s.appendConversationHistoryCacheOptimistically(
|
||||
context.Background(),
|
||||
userID,
|
||||
chatID,
|
||||
buildOptimisticConversationHistoryItem(
|
||||
"user",
|
||||
userMessage,
|
||||
"",
|
||||
0,
|
||||
retryMeta,
|
||||
requestStart,
|
||||
),
|
||||
)
|
||||
|
||||
// 普通聊天链路也需要把助手回复写入 Redis,
|
||||
// 否则会出现“数据库有助手消息,但 Redis 最新会话只有用户消息”的口径不一致。
|
||||
@@ -472,6 +485,20 @@ func (s *AgentService) runNormalChatFlow(
|
||||
TokensConsumed: requestTotalTokens,
|
||||
}); saveErr != nil {
|
||||
pushErrNonBlocking(errChan, saveErr)
|
||||
} else {
|
||||
s.appendConversationHistoryCacheOptimistically(
|
||||
context.Background(),
|
||||
userID,
|
||||
chatID,
|
||||
buildOptimisticConversationHistoryItem(
|
||||
"assistant",
|
||||
fullText,
|
||||
assistantReasoning,
|
||||
reasoningDurationSeconds,
|
||||
retryMeta,
|
||||
time.Now(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 9. 在主回复完成后异步尝试生成会话标题(仅首次、仅标题为空时生效)。
|
||||
|
||||
@@ -2,14 +2,15 @@ package agentsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/conv"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -37,59 +38,91 @@ func (s *AgentService) GetConversationHistory(ctx context.Context, userID int, c
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
// 2. 优先读 Redis:
|
||||
// 2.1 命中时直接返回,复用当前聊天主链路维护的最近消息窗口;
|
||||
// 2.2 失败策略:缓存读取异常只记日志并继续回源 DB,避免缓存抖动导致接口不可用;
|
||||
// 2.3 注意:缓存消息不包含稳定的 DB 主键与创建时间,因此这些字段允许为空。
|
||||
if s.agentCache != nil {
|
||||
history, cacheErr := s.agentCache.GetHistory(ctx, normalizedChatID)
|
||||
// 2. 优先读取“会话历史视图缓存”:
|
||||
// 2.1 这层缓存专门服务 conversation-history,字段口径与前端展示一致;
|
||||
// 2.2 与 Agent 上下文热缓存解耦,避免为了历史多版本而拖慢首 token;
|
||||
// 2.3 若命中则直接返回,miss 再回源 DB。
|
||||
if s.cacheDAO != nil {
|
||||
items, cacheErr := s.cacheDAO.GetConversationHistoryFromCache(ctx, userID, normalizedChatID)
|
||||
if cacheErr != nil {
|
||||
log.Printf("读取会话历史缓存失败 chat_id=%s: %v", normalizedChatID, cacheErr)
|
||||
} else if history != nil && !cacheConversationHistoryHasRetryMetadata(history) {
|
||||
return buildConversationHistoryItemsFromCache(history), nil
|
||||
log.Printf("读取会话历史视图缓存失败 chat_id=%s: %v", normalizedChatID, cacheErr)
|
||||
} else if items != nil {
|
||||
return items, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Redis 未命中时回源 DB:
|
||||
// 3.1 复用现有 GetUserChatHistories 读取最近 N 条历史,保证查询链路和主聊天链路口径一致;
|
||||
// 3.2 失败时直接上抛,由 API 层统一处理;
|
||||
// 3.3 成功后若缓存可用,则顺手回填 Redis,降低后续冷启动成本。
|
||||
// 3. Redis miss 时回源 DB:
|
||||
// 3.1 复用现有 GetUserChatHistories 读取最近 N 条历史,保证“重试版本、落库主键、创建时间”口径稳定;
|
||||
// 3.2 再把 DB 结果转换成接口 DTO,作为历史视图缓存回填;
|
||||
// 3.3 失败时直接上抛,由 API 层统一处理。
|
||||
histories, err := s.repo.GetUserChatHistories(ctx, userID, pkg.HistoryFetchLimitByModel("worker"), normalizedChatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.agentCache != nil {
|
||||
if setErr := s.agentCache.BackfillHistory(ctx, normalizedChatID, conv.ToEinoMessages(histories)); setErr != nil {
|
||||
log.Printf("回填会话历史缓存失败 chat_id=%s: %v", normalizedChatID, setErr)
|
||||
items := buildConversationHistoryItemsFromDB(histories)
|
||||
|
||||
if s.cacheDAO != nil {
|
||||
if setErr := s.cacheDAO.SetConversationHistoryToCache(ctx, userID, normalizedChatID, items); setErr != nil {
|
||||
log.Printf("回填会话历史视图缓存失败 chat_id=%s: %v", normalizedChatID, setErr)
|
||||
}
|
||||
}
|
||||
|
||||
return buildConversationHistoryItemsFromDB(histories), nil
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// buildConversationHistoryItemsFromCache 把 Redis 中的 Eino 消息转换为接口响应。
|
||||
// appendConversationHistoryCacheOptimistically 把“刚生成但尚未完成 DB 持久化确认”的消息追加到历史视图缓存。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做字段映射,不做权限校验或排序调整;
|
||||
// 2. 不补 created_at/id,因为当前缓存模型不承载这两个字段;
|
||||
// 3. role 统一输出为 user / assistant / system,避免前端再感知 schema.RoleType。
|
||||
func buildConversationHistoryItemsFromCache(messages []*schema.Message) []model.GetConversationHistoryItem {
|
||||
items := make([]model.GetConversationHistoryItem, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, model.GetConversationHistoryItem{
|
||||
Role: normalizeConversationHistoryRole(string(msg.Role)),
|
||||
Content: strings.TrimSpace(msg.Content),
|
||||
ReasoningContent: strings.TrimSpace(msg.ReasoningContent),
|
||||
ReasoningDurationSeconds: extractConversationReasoningDurationSeconds(msg),
|
||||
RetryGroupID: extractConversationRetryGroupID(msg),
|
||||
RetryIndex: extractConversationRetryIndex(msg),
|
||||
})
|
||||
// 1. 只服务前端会话历史展示,不参与 Agent 上下文热缓存;
|
||||
// 2. 优先复用现有历史视图缓存,miss 时再用 DB 历史做一次启动兜底;
|
||||
// 3. 不保证最终权威性,最终仍以 DB 落库成功后的缓存失效与回源结果为准。
|
||||
func (s *AgentService) appendConversationHistoryCacheOptimistically(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
chatID string,
|
||||
newItems ...model.GetConversationHistoryItem,
|
||||
) {
|
||||
if s == nil || s.cacheDAO == nil {
|
||||
return
|
||||
}
|
||||
normalizedChatID := strings.TrimSpace(chatID)
|
||||
if userID <= 0 || normalizedChatID == "" || len(newItems) == 0 {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
// 1. 优先取历史视图缓存,避免每轮乐观追加都回源 DB。
|
||||
items, err := s.cacheDAO.GetConversationHistoryFromCache(ctx, userID, normalizedChatID)
|
||||
if err != nil {
|
||||
log.Printf("读取会话历史视图缓存失败 chat_id=%s: %v", normalizedChatID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 缓存 miss 时,用当前 DB 已有历史做一次基线兜底。
|
||||
// 2.1 这样即便本轮是“缓存刚被 retry 补种操作删掉”,也不会只留下最新两条消息;
|
||||
// 2.2 失败策略:DB 兜底失败只记日志并跳过,不阻塞主回复流程。
|
||||
if items == nil {
|
||||
histories, hisErr := s.repo.GetUserChatHistories(ctx, userID, pkg.HistoryFetchLimitByModel("worker"), normalizedChatID)
|
||||
if hisErr != nil {
|
||||
log.Printf("乐观追加历史缓存时回源 DB 失败 chat_id=%s: %v", normalizedChatID, hisErr)
|
||||
return
|
||||
}
|
||||
items = buildConversationHistoryItemsFromDB(histories)
|
||||
}
|
||||
|
||||
merged := append([]model.GetConversationHistoryItem(nil), items...)
|
||||
for _, item := range newItems {
|
||||
merged = appendConversationHistoryItemIfMissing(merged, item)
|
||||
}
|
||||
sortConversationHistoryItems(merged)
|
||||
merged = attachConversationRetryTotals(merged)
|
||||
|
||||
if err = s.cacheDAO.SetConversationHistoryToCache(ctx, userID, normalizedChatID, merged); err != nil {
|
||||
log.Printf("乐观追加会话历史视图缓存失败 chat_id=%s: %v", normalizedChatID, err)
|
||||
}
|
||||
return attachConversationRetryTotals(items)
|
||||
}
|
||||
|
||||
// buildConversationHistoryItemsFromDB 把数据库聊天记录转换为接口响应。
|
||||
@@ -132,84 +165,6 @@ func derefConversationHistoryText(text *string) string {
|
||||
return *text
|
||||
}
|
||||
|
||||
func extractConversationReasoningDurationSeconds(msg *schema.Message) int {
|
||||
if msg == nil || msg.Extra == nil {
|
||||
return 0
|
||||
}
|
||||
raw, ok := msg.Extra["reasoning_duration_seconds"]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int32:
|
||||
return int(v)
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func extractConversationRetryGroupID(msg *schema.Message) *string {
|
||||
if msg == nil || msg.Extra == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := msg.Extra["retry_group_id"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return &text
|
||||
}
|
||||
|
||||
func extractConversationRetryIndex(msg *schema.Message) *int {
|
||||
if msg == nil || msg.Extra == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := msg.Extra["retry_index"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case int:
|
||||
if v <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
case int32:
|
||||
value := int(v)
|
||||
if value <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
case int64:
|
||||
value := int(v)
|
||||
if value <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
case float64:
|
||||
value := int(v)
|
||||
if value <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func attachConversationRetryTotals(items []model.GetConversationHistoryItem) []model.GetConversationHistoryItem {
|
||||
if len(items) == 0 {
|
||||
return items
|
||||
@@ -273,11 +228,89 @@ func normalizeConversationHistoryRole(role string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func cacheConversationHistoryHasRetryMetadata(messages []*schema.Message) bool {
|
||||
for _, msg := range messages {
|
||||
if extractConversationRetryGroupID(msg) != nil {
|
||||
return true
|
||||
func buildOptimisticConversationHistoryItem(
|
||||
role string,
|
||||
content string,
|
||||
reasoningContent string,
|
||||
reasoningDurationSeconds int,
|
||||
retryMeta *chatRetryMeta,
|
||||
createdAt time.Time,
|
||||
) model.GetConversationHistoryItem {
|
||||
item := model.GetConversationHistoryItem{
|
||||
Role: normalizeConversationHistoryRole(role),
|
||||
Content: strings.TrimSpace(content),
|
||||
ReasoningContent: strings.TrimSpace(reasoningContent),
|
||||
ReasoningDurationSeconds: reasoningDurationSeconds,
|
||||
}
|
||||
if !createdAt.IsZero() {
|
||||
t := createdAt
|
||||
item.CreatedAt = &t
|
||||
}
|
||||
if retryMeta != nil {
|
||||
item.RetryGroupID = retryMeta.GroupIDPtr()
|
||||
item.RetryIndex = retryMeta.IndexPtr()
|
||||
item.RetryTotal = retryMeta.IndexPtr()
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func appendConversationHistoryItemIfMissing(
|
||||
items []model.GetConversationHistoryItem,
|
||||
item model.GetConversationHistoryItem,
|
||||
) []model.GetConversationHistoryItem {
|
||||
targetKey := conversationHistoryItemSignature(item)
|
||||
for _, existed := range items {
|
||||
if conversationHistoryItemSignature(existed) == targetKey {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return false
|
||||
return append(items, item)
|
||||
}
|
||||
|
||||
func conversationHistoryItemSignature(item model.GetConversationHistoryItem) string {
|
||||
if item.ID > 0 {
|
||||
return fmt.Sprintf("id:%d", item.ID)
|
||||
}
|
||||
|
||||
groupID := ""
|
||||
if item.RetryGroupID != nil {
|
||||
groupID = strings.TrimSpace(*item.RetryGroupID)
|
||||
}
|
||||
retryIndex := 0
|
||||
if item.RetryIndex != nil {
|
||||
retryIndex = *item.RetryIndex
|
||||
}
|
||||
createdAt := ""
|
||||
if item.CreatedAt != nil {
|
||||
createdAt = item.CreatedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%s|%s|%s|%s|%d|%d|%s",
|
||||
strings.TrimSpace(item.Role),
|
||||
strings.TrimSpace(item.Content),
|
||||
strings.TrimSpace(item.ReasoningContent),
|
||||
groupID,
|
||||
retryIndex,
|
||||
item.ReasoningDurationSeconds,
|
||||
createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
func sortConversationHistoryItems(items []model.GetConversationHistoryItem) {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
left := conversationHistoryTimestamp(items[i])
|
||||
right := conversationHistoryTimestamp(items[j])
|
||||
if left.Equal(right) {
|
||||
return conversationHistoryItemSignature(items[i]) < conversationHistoryItemSignature(items[j])
|
||||
}
|
||||
return left.Before(right)
|
||||
})
|
||||
}
|
||||
|
||||
func conversationHistoryTimestamp(item model.GetConversationHistoryItem) time.Time {
|
||||
if item.CreatedAt == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return *item.CreatedAt
|
||||
}
|
||||
|
||||
@@ -345,6 +345,20 @@ func (s *AgentService) persistChatAfterReply(
|
||||
pushErrNonBlocking(errChan, err)
|
||||
return
|
||||
}
|
||||
userCreatedAt := time.Now()
|
||||
s.appendConversationHistoryCacheOptimistically(
|
||||
context.Background(),
|
||||
userID,
|
||||
chatID,
|
||||
buildOptimisticConversationHistoryItem(
|
||||
"user",
|
||||
userMessage,
|
||||
"",
|
||||
0,
|
||||
retryMeta,
|
||||
userCreatedAt,
|
||||
),
|
||||
)
|
||||
|
||||
// 3. 助手消息同样遵循“Redis 先行 + 可靠持久化补齐”策略。
|
||||
assistantMsg := &schema.Message{Role: schema.Assistant, Content: assistantReply, ReasoningContent: assistantReasoning}
|
||||
@@ -378,5 +392,19 @@ func (s *AgentService) persistChatAfterReply(
|
||||
TokensConsumed: assistantTokens,
|
||||
}); err != nil {
|
||||
pushErrNonBlocking(errChan, err)
|
||||
return
|
||||
}
|
||||
s.appendConversationHistoryCacheOptimistically(
|
||||
context.Background(),
|
||||
userID,
|
||||
chatID,
|
||||
buildOptimisticConversationHistoryItem(
|
||||
"assistant",
|
||||
assistantReply,
|
||||
assistantReasoning,
|
||||
assistantReasoningDurationSeconds,
|
||||
retryMeta,
|
||||
userCreatedAt.Add(time.Millisecond),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,10 +122,6 @@ const isProgrammaticMessageScroll = ref(false)
|
||||
const isStandaloneMode = computed(() => props.viewMode === 'standalone')
|
||||
|
||||
const assistantBodyStyle = computed(() => {
|
||||
if (isStandaloneMode.value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
'--assistant-history-width': `${historyExpanded.value ? historyPanelWidth.value : 68}px`,
|
||||
}
|
||||
@@ -1010,6 +1006,37 @@ function handleHistoryScroll(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
function getHistoryPanelWidthBounds(containerWidth: number) {
|
||||
const standalone = isStandaloneMode.value
|
||||
const minHistoryWidth = standalone ? 196 : 188
|
||||
const minChatWidth = standalone ? 560 : 420
|
||||
const splitterWidth = 8
|
||||
const rawMaxHistoryWidth = standalone
|
||||
? Math.min(320, containerWidth - splitterWidth - minChatWidth)
|
||||
: containerWidth - splitterWidth - minChatWidth
|
||||
|
||||
return {
|
||||
minHistoryWidth,
|
||||
maxHistoryWidth: Math.max(minHistoryWidth, rawMaxHistoryWidth),
|
||||
}
|
||||
}
|
||||
|
||||
function syncHistoryPanelWidthForViewport() {
|
||||
if (!historyExpanded.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const body = assistantBodyRef.value
|
||||
const containerWidth =
|
||||
body?.getBoundingClientRect().width ??
|
||||
Math.max(0, window.innerWidth - (isStandaloneMode.value ? 120 : 0))
|
||||
const bounds = getHistoryPanelWidthBounds(containerWidth)
|
||||
historyPanelWidth.value = Math.min(
|
||||
Math.max(historyPanelWidth.value, bounds.minHistoryWidth),
|
||||
bounds.maxHistoryWidth,
|
||||
)
|
||||
}
|
||||
|
||||
// startResizeHistoryPanel 负责处理会话列表与聊天主区之间的横向拖拽。
|
||||
// 职责边界:
|
||||
// 1. 只负责更新助手面板内部的历史区宽度,不修改外层 Dashboard 的左右二分布局。
|
||||
@@ -1017,21 +1044,27 @@ function handleHistoryScroll(event: Event) {
|
||||
// 3. 拖拽结束后统一解绑事件并清理全局样式,防止页面残留 col-resize 状态。
|
||||
function startResizeHistoryPanel(event: PointerEvent) {
|
||||
const body = assistantBodyRef.value
|
||||
if (isStandaloneMode.value || !body || window.innerWidth <= 960 || !historyExpanded.value) {
|
||||
if (!body || !historyExpanded.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const isStandalone = isStandaloneMode.value
|
||||
const minViewportWidth = isStandalone ? 860 : 960
|
||||
if (window.innerWidth <= minViewportWidth) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = body.getBoundingClientRect()
|
||||
const startX = event.clientX
|
||||
const startWidth = historyPanelWidth.value
|
||||
const bounds = getHistoryPanelWidthBounds(rect.width)
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX
|
||||
const minHistoryWidth = 188
|
||||
const minChatWidth = 420
|
||||
const splitterWidth = 8
|
||||
const maxHistoryWidth = rect.width - splitterWidth - minChatWidth
|
||||
historyPanelWidth.value = Math.min(Math.max(startWidth + deltaX, minHistoryWidth), maxHistoryWidth)
|
||||
historyPanelWidth.value = Math.min(
|
||||
Math.max(startWidth + deltaX, bounds.minHistoryWidth),
|
||||
bounds.maxHistoryWidth,
|
||||
)
|
||||
}
|
||||
|
||||
const stopResize = () => {
|
||||
@@ -1469,7 +1502,10 @@ onMounted(async () => {
|
||||
reasoningTicker = window.setInterval(() => {
|
||||
reasoningDisplayNow.value = Date.now()
|
||||
}, 1000)
|
||||
window.addEventListener('resize', syncHistoryPanelWidthForViewport)
|
||||
syncHistoryPanelWidthForViewport()
|
||||
await loadConversationListData(true)
|
||||
syncHistoryPanelWidthForViewport()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -1483,6 +1519,7 @@ onBeforeUnmount(() => {
|
||||
window.clearInterval(reasoningTicker)
|
||||
reasoningTicker = 0
|
||||
}
|
||||
window.removeEventListener('resize', syncHistoryPanelWidthForViewport)
|
||||
document.body.classList.remove('dashboard-resizing')
|
||||
})
|
||||
</script>
|
||||
@@ -1584,7 +1621,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div
|
||||
class="assistant-splitter"
|
||||
:class="{ 'assistant-splitter--hidden': !historyExpanded || isStandaloneMode }"
|
||||
:class="{ 'assistant-splitter--hidden': !historyExpanded }"
|
||||
role="separator"
|
||||
aria-label="调整会话列表宽度"
|
||||
@pointerdown.prevent="startResizeHistoryPanel"
|
||||
@@ -1994,7 +2031,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.assistant-body--standalone {
|
||||
grid-template-columns: minmax(212px, 1fr) 8px minmax(0, 5fr);
|
||||
grid-template-columns: var(--assistant-history-width) 8px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.assistant-body--standalone.assistant-body--collapsed {
|
||||
@@ -2073,15 +2110,20 @@ onBeforeUnmount(() => {
|
||||
.assistant-history__content {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
padding: 0 10px 14px 12px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.assistant-history__new {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
height: 42px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
@@ -2114,6 +2156,7 @@ onBeforeUnmount(() => {
|
||||
.assistant-history__group {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.assistant-history__group-title {
|
||||
@@ -2127,17 +2170,21 @@ onBeforeUnmount(() => {
|
||||
|
||||
.assistant-history__item {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #1f2937;
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -2148,6 +2195,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.assistant-history__item-title {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: inherit;
|
||||
@@ -2174,6 +2222,7 @@ onBeforeUnmount(() => {
|
||||
.assistant-shell--standalone .assistant-history {
|
||||
background: linear-gradient(180deg, #f8f9fc 0%, #f4f7fb 100%);
|
||||
border-right: 1px solid rgba(15, 23, 42, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history__item--active {
|
||||
@@ -2182,6 +2231,28 @@ onBeforeUnmount(() => {
|
||||
box-shadow: 0 4px 10px rgba(36, 67, 127, 0.08);
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history__toolbar {
|
||||
padding: 10px 9px 8px 10px;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history__content {
|
||||
gap: 10px;
|
||||
padding: 0 8px 12px 10px;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history__new {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history__item {
|
||||
min-height: 54px;
|
||||
padding: 10px 10px 10px 11px;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history__item-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistant-history--collapsed .assistant-history__toolbar {
|
||||
padding-inline: 8px;
|
||||
justify-content: center;
|
||||
@@ -2265,6 +2336,14 @@ onBeforeUnmount(() => {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.assistant-body--standalone .assistant-splitter {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-splitter__line {
|
||||
background: linear-gradient(180deg, rgba(130, 148, 180, 0.18), rgba(78, 110, 168, 0.32), rgba(130, 148, 180, 0.18));
|
||||
}
|
||||
|
||||
.assistant-splitter__line {
|
||||
width: 3px;
|
||||
height: 56px;
|
||||
@@ -3010,6 +3089,57 @@ onBeforeUnmount(() => {
|
||||
padding-right: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.assistant-body--standalone {
|
||||
grid-template-columns: var(--assistant-history-width) 8px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history__content {
|
||||
padding-left: 7px;
|
||||
padding-right: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.assistant-body--standalone {
|
||||
grid-template-columns: var(--assistant-history-width) 8px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history__toolbar {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history__content {
|
||||
gap: 8px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history__item {
|
||||
min-height: 50px;
|
||||
padding: 9px 9px 9px 10px;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history__item-title {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.assistant-body--standalone,
|
||||
.assistant-body--standalone.assistant-body--collapsed {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.assistant-shell--standalone .assistant-history__content {
|
||||
max-height: 260px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
.assistant-model-select-panel.el-popper {
|
||||
|
||||
@@ -197,6 +197,9 @@ function handleSubmit() {
|
||||
.task-class-dialog__body {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
min-height: 0;
|
||||
max-height: min(72vh, 760px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-class-dialog__grid {
|
||||
@@ -230,6 +233,8 @@ function handleSubmit() {
|
||||
.task-class-dialog__items {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.task-class-dialog__items-head {
|
||||
@@ -237,6 +242,8 @@ function handleSubmit() {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-class-dialog__add {
|
||||
@@ -254,6 +261,13 @@ function handleSubmit() {
|
||||
.task-class-dialog__items-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-height: min(44vh, 360px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
scrollbar-gutter: stable;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.task-class-dialog__item {
|
||||
@@ -261,6 +275,7 @@ function handleSubmit() {
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-class-dialog__item-order {
|
||||
@@ -297,9 +312,46 @@ function handleSubmit() {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-height: 900px) {
|
||||
.task-class-dialog__body {
|
||||
max-height: min(76vh, 680px);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.task-class-dialog__items-list {
|
||||
max-height: min(40vh, 300px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 820px) {
|
||||
.task-class-dialog__items-list {
|
||||
max-height: min(34vh, 240px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.task-class-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.task-class-dialog__item {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.task-class-dialog__item-remove {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
.task-class-dialog :deep(.el-dialog) {
|
||||
width: min(720px, calc(100vw - 24px));
|
||||
max-height: calc(100vh - 24px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-class-dialog :deep(.el-dialog__body) {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { TaskClassDetail, TaskClassListItem } from '@/types/schedule'
|
||||
|
||||
@@ -21,6 +21,10 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const taskClassCountLabel = computed(() => `共 ${props.taskClasses.length} 个`)
|
||||
const viewportHeight = ref(typeof window === 'undefined' ? 900 : window.innerHeight)
|
||||
const taskClassListRef = ref<HTMLElement | null>(null)
|
||||
const listViewportHeight = ref(0)
|
||||
let listResizeObserver: ResizeObserver | null = null
|
||||
|
||||
function isExpanded(taskClassId: number) {
|
||||
return props.expandedTaskClassId === taskClassId && !props.taskClassMultiSelectMode
|
||||
@@ -40,6 +44,76 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${month}.${day} ${value.section_from}-${value.section_to}节`
|
||||
}
|
||||
|
||||
function syncViewportHeight() {
|
||||
viewportHeight.value = window.innerHeight
|
||||
}
|
||||
|
||||
function syncTaskClassListViewportHeight() {
|
||||
listViewportHeight.value = taskClassListRef.value?.clientHeight ?? 0
|
||||
}
|
||||
|
||||
function resolveDetailPanelStyle(items: TaskClassDetail['items']) {
|
||||
const count = items.length
|
||||
const itemHeight = viewportHeight.value <= 820 ? 54 : viewportHeight.value <= 900 ? 58 : 62
|
||||
const gap = 6
|
||||
const panelPadding = 14
|
||||
const preferredHeight = count * itemHeight + Math.max(0, count - 1) * gap + panelPadding
|
||||
const maxVisibleItems = viewportHeight.value <= 820 ? 4 : viewportHeight.value <= 900 ? 5 : 6
|
||||
const maxHeightByItemCount =
|
||||
maxVisibleItems * itemHeight + Math.max(0, maxVisibleItems - 1) * gap + panelPadding
|
||||
const maxHeightByContainer = Math.max(
|
||||
180,
|
||||
(listViewportHeight.value || Math.round(viewportHeight.value * 0.6)) - 116,
|
||||
)
|
||||
const finalHeight = Math.min(preferredHeight, maxHeightByItemCount, maxHeightByContainer)
|
||||
|
||||
// 1. 条目少时让卡片自然长高,避免只有两三条时还出现大块留白。
|
||||
// 2. 条目超过“当前屏幕可安全展示的最大条数”后,立即锁住高度并进入内部滚动。
|
||||
// 3. 这样像 8 条 task_item 这类中等长度列表会稳定触发滚动,不会再因为估算过大而失效。
|
||||
return {
|
||||
height: `${finalHeight}px`,
|
||||
maxHeight: `${finalHeight}px`,
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', syncViewportHeight)
|
||||
window.addEventListener('resize', syncTaskClassListViewportHeight)
|
||||
syncTaskClassListViewportHeight()
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
listResizeObserver = new ResizeObserver(() => {
|
||||
syncTaskClassListViewportHeight()
|
||||
})
|
||||
if (taskClassListRef.value) {
|
||||
listResizeObserver.observe(taskClassListRef.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', syncViewportHeight)
|
||||
window.removeEventListener('resize', syncTaskClassListViewportHeight)
|
||||
listResizeObserver?.disconnect()
|
||||
listResizeObserver = null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.expandedTaskClassId,
|
||||
async (expandedId) => {
|
||||
await nextTick()
|
||||
syncTaskClassListViewportHeight()
|
||||
if (!expandedId || !taskClassListRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const expandedCard = taskClassListRef.value.querySelector<HTMLElement>('.task-class-card--expanded')
|
||||
expandedCard?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
})
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -68,7 +142,7 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
<div v-for="index in 4" :key="index" class="task-class-sidebar__skeleton-item" />
|
||||
</div>
|
||||
|
||||
<div v-else class="task-class-sidebar__list">
|
||||
<div v-else ref="taskClassListRef" class="task-class-sidebar__list">
|
||||
<article
|
||||
v-for="taskClass in taskClasses"
|
||||
:key="taskClass.id"
|
||||
@@ -98,7 +172,11 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div v-if="isExpanded(taskClass.id)" class="task-class-card__detail">
|
||||
<div
|
||||
v-if="isExpanded(taskClass.id)"
|
||||
class="task-class-card__detail"
|
||||
:style="expandedTaskClassDetail ? resolveDetailPanelStyle(expandedTaskClassDetail.items) : undefined"
|
||||
>
|
||||
<div v-if="detailLoading" class="task-class-card__detail-loading">正在载入任务块…</div>
|
||||
|
||||
<div v-else-if="expandedTaskClassDetail" class="task-class-card__detail-list">
|
||||
@@ -146,6 +224,7 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
border-right: 1px solid rgba(196, 209, 227, 0.55);
|
||||
background: linear-gradient(180deg, rgba(251, 253, 255, 0.96), rgba(247, 250, 254, 0.98));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-class-sidebar__header {
|
||||
@@ -153,6 +232,7 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
border-bottom: 1px solid rgba(214, 223, 238, 0.68);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-class-sidebar__title-row {
|
||||
@@ -160,6 +240,8 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-class-sidebar__title-wrap {
|
||||
@@ -167,11 +249,13 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #1f2c42;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-class-sidebar__title-wrap strong {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-class-sidebar__title-icon {
|
||||
@@ -213,10 +297,12 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
.task-class-sidebar__skeleton {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.task-class-sidebar__skeleton-item {
|
||||
@@ -228,6 +314,7 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
}
|
||||
|
||||
.task-class-card {
|
||||
min-width: 0;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(216, 225, 238, 0.9);
|
||||
background: linear-gradient(180deg, #fdfefe 0%, #f8fbff 100%);
|
||||
@@ -242,6 +329,7 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
|
||||
.task-class-card__summary {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 18px 20px 18px 18px;
|
||||
@@ -284,6 +372,7 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
font-size: 16px;
|
||||
line-height: 1.35;
|
||||
font-weight: 800;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.task-class-card__content span {
|
||||
@@ -292,6 +381,7 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
}
|
||||
|
||||
.task-class-card__corner {
|
||||
flex: 0 0 auto;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 999px;
|
||||
@@ -304,7 +394,14 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
}
|
||||
|
||||
.task-class-card__detail {
|
||||
min-width: 0;
|
||||
padding: 0 14px 14px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(114, 130, 157, 0.65) transparent;
|
||||
}
|
||||
|
||||
.task-class-card__detail-loading {
|
||||
@@ -313,14 +410,29 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.task-class-card__detail::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.task-class-card__detail::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.task-class-card__detail::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgba(114, 130, 157, 0.55);
|
||||
}
|
||||
|
||||
.task-class-card__detail-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.task-class-card__detail-item {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(197, 209, 226, 0.8);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
@@ -346,11 +458,13 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
}
|
||||
|
||||
.task-class-card__detail-status {
|
||||
max-width: 100%;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #74839a;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-class-card__detail-status--arranged {
|
||||
@@ -376,6 +490,7 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
}
|
||||
|
||||
.task-class-sidebar__create {
|
||||
min-width: 0;
|
||||
min-height: 108px;
|
||||
border: 1px dashed rgba(204, 216, 232, 0.92);
|
||||
border-radius: 24px;
|
||||
@@ -416,4 +531,107 @@ function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_ti
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1520px) {
|
||||
.task-class-sidebar__header,
|
||||
.task-class-sidebar__list,
|
||||
.task-class-sidebar__skeleton {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.task-class-card__summary {
|
||||
padding: 16px 16px 16px 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.task-class-sidebar {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(196, 209, 227, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.task-class-card__detail-item {
|
||||
grid-template-columns: 28px minmax(0, 1fr) 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.task-class-card__detail-status {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.task-class-card__detail-delete {
|
||||
grid-column: 3;
|
||||
grid-row: 1 / span 2;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 900px) {
|
||||
.task-class-sidebar__header {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.task-class-sidebar__list,
|
||||
.task-class-sidebar__skeleton {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.task-class-card {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.task-class-card__summary {
|
||||
padding: 14px 14px 14px 13px;
|
||||
}
|
||||
|
||||
.task-class-card__content {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.task-class-card__content strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.task-class-sidebar__create {
|
||||
min-height: 88px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-height: 820px) {
|
||||
.task-class-sidebar__header,
|
||||
.task-class-sidebar__list,
|
||||
.task-class-sidebar__skeleton {
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.task-class-card__summary {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.task-class-card__corner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.task-class-card__content strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.task-class-card__content span,
|
||||
.task-class-card__detail-text,
|
||||
.task-class-card__detail-status {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -144,12 +144,22 @@ function resolveCellMeta(event?: ScheduleWeekEvent) {
|
||||
|
||||
<style scoped>
|
||||
.planning-board {
|
||||
--planning-grid-padding-x: 24px;
|
||||
--planning-grid-padding-y: 28px;
|
||||
--planning-grid-gap-x: 12px;
|
||||
--planning-grid-gap-y: 10px;
|
||||
--planning-time-column-width: 74px;
|
||||
--planning-day-column-min: 96px;
|
||||
--planning-cell-height: clamp(72px, 9.2vh, 112px);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-radius: 28px;
|
||||
border: 1px solid rgba(214, 223, 236, 0.82);
|
||||
background: linear-gradient(180deg, rgba(252, 253, 255, 0.98), rgba(248, 251, 255, 0.98));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.planning-board__header {
|
||||
@@ -161,10 +171,13 @@ function resolveCellMeta(event?: ScheduleWeekEvent) {
|
||||
|
||||
.planning-board__grid {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 74px repeat(7, minmax(0, 1fr));
|
||||
gap: 10px 12px;
|
||||
padding: 28px 24px 24px;
|
||||
grid-template-columns: var(--planning-time-column-width) repeat(7, minmax(var(--planning-day-column-min), 1fr));
|
||||
gap: var(--planning-grid-gap-y) var(--planning-grid-gap-x);
|
||||
padding: var(--planning-grid-padding-y) var(--planning-grid-padding-x) 24px;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
|
||||
.planning-board__corner {
|
||||
@@ -189,7 +202,7 @@ function resolveCellMeta(event?: ScheduleWeekEvent) {
|
||||
}
|
||||
|
||||
.planning-board__time-cell {
|
||||
min-height: 112px;
|
||||
min-height: var(--planning-cell-height);
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: end;
|
||||
@@ -211,7 +224,7 @@ function resolveCellMeta(event?: ScheduleWeekEvent) {
|
||||
|
||||
.planning-board__cell {
|
||||
position: relative;
|
||||
min-height: 112px;
|
||||
min-height: var(--planning-cell-height);
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(228, 234, 243, 0.92);
|
||||
padding: 18px 14px;
|
||||
@@ -225,6 +238,7 @@ function resolveCellMeta(event?: ScheduleWeekEvent) {
|
||||
.planning-board__cell-main {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.planning-board__cell-main strong {
|
||||
@@ -232,11 +246,15 @@ function resolveCellMeta(event?: ScheduleWeekEvent) {
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.planning-board__cell-main span {
|
||||
color: #9badc5;
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.planning-board__cell--course {
|
||||
@@ -321,7 +339,99 @@ function resolveCellMeta(event?: ScheduleWeekEvent) {
|
||||
|
||||
@media (max-width: 1560px) {
|
||||
.planning-board__grid {
|
||||
grid-template-columns: 64px repeat(7, minmax(118px, 1fr));
|
||||
--planning-time-column-width: 64px;
|
||||
--planning-day-column-min: 92px;
|
||||
--planning-grid-padding-x: 18px;
|
||||
--planning-grid-padding-y: 22px;
|
||||
--planning-grid-gap-x: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.planning-board__header {
|
||||
padding: 16px 20px 14px;
|
||||
}
|
||||
|
||||
.planning-board__grid {
|
||||
--planning-time-column-width: 58px;
|
||||
--planning-day-column-min: 84px;
|
||||
--planning-grid-padding-x: 14px;
|
||||
--planning-grid-padding-y: 18px;
|
||||
--planning-grid-gap-x: 8px;
|
||||
--planning-grid-gap-y: 8px;
|
||||
}
|
||||
|
||||
.planning-board__time-cell,
|
||||
.planning-board__cell {
|
||||
min-height: 98px;
|
||||
}
|
||||
|
||||
.planning-board__cell {
|
||||
padding: 14px 10px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.planning-board__cell-main {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.planning-board__cell-main strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.planning-board__grid {
|
||||
--planning-time-column-width: 56px;
|
||||
--planning-day-column-min: 78px;
|
||||
}
|
||||
|
||||
.planning-board__day-head span {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.planning-board__day-head small,
|
||||
.planning-board__cell-main span {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 900px) {
|
||||
.planning-board {
|
||||
--planning-grid-padding-y: 18px;
|
||||
--planning-cell-height: clamp(66px, 8.2vh, 92px);
|
||||
}
|
||||
|
||||
.planning-board__header {
|
||||
padding-top: 14px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.planning-board__header strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 820px) {
|
||||
.planning-board {
|
||||
--planning-grid-padding-y: 14px;
|
||||
--planning-grid-gap-y: 6px;
|
||||
--planning-cell-height: clamp(58px, 7.2vh, 82px);
|
||||
}
|
||||
|
||||
.planning-board__time-cell strong,
|
||||
.planning-board__cell-main strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.planning-board__time-cell small,
|
||||
.planning-board__day-head small,
|
||||
.planning-board__cell-main span {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.planning-board__cell {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -68,7 +68,7 @@ function handleSidebarNavigate(item: SidebarItem) {
|
||||
<button type="button" class="dashboard-sidebar__settings">设</button>
|
||||
</aside>
|
||||
|
||||
<AssistantPanel class="assistant-view__panel" view-mode="standalone" />
|
||||
<AssistantPanel class="assistant-view__panel" view-mode="standalone" :initial-history-width="248" />
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -605,6 +605,7 @@ onMounted(async () => {
|
||||
display: grid;
|
||||
grid-template-columns: 78px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dashboard-sidebar {
|
||||
@@ -688,12 +689,14 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.schedule-topbar__brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.schedule-topbar__brand-icon {
|
||||
@@ -711,6 +714,7 @@ onMounted(async () => {
|
||||
color: #19263d;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.schedule-topbar__meta {
|
||||
@@ -718,6 +722,8 @@ onMounted(async () => {
|
||||
justify-items: end;
|
||||
gap: 6px;
|
||||
color: #8493aa;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.schedule-topbar__meta strong {
|
||||
@@ -733,7 +739,8 @@ onMounted(async () => {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 400px minmax(0, 1fr);
|
||||
grid-template-columns: clamp(280px, 26vw, 380px) minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.schedule-board-wrap {
|
||||
@@ -743,6 +750,7 @@ onMounted(async () => {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.schedule-board__toolbar {
|
||||
@@ -750,6 +758,8 @@ onMounted(async () => {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.schedule-board__toolbar-left,
|
||||
@@ -757,6 +767,8 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.schedule-board__toolbar-right {
|
||||
@@ -817,7 +829,83 @@ onMounted(async () => {
|
||||
|
||||
@media (max-width: 1520px) {
|
||||
.schedule-main {
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
grid-template-columns: clamp(260px, 24vw, 332px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.schedule-board-wrap {
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.schedule-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.schedule-topbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.schedule-topbar__meta {
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 900px) {
|
||||
.schedule-page {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.schedule-layout {
|
||||
height: calc(100vh - 16px);
|
||||
}
|
||||
|
||||
.schedule-topbar {
|
||||
padding: 12px 18px;
|
||||
}
|
||||
|
||||
.schedule-topbar__brand {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.schedule-topbar__brand-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.schedule-topbar__brand strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.schedule-board-wrap {
|
||||
padding-top: 14px;
|
||||
padding-bottom: 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.schedule-board__toolbar-button,
|
||||
.schedule-board__footer-button {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 820px) {
|
||||
.schedule-topbar {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.schedule-topbar__meta {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.schedule-board-wrap {
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
88
openapi.yaml
88
openapi.yaml
@@ -1,8 +1,8 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: SmartFlow Agent History API
|
||||
title: SmartFlow 智能体历史记录接口
|
||||
version: 0.8.1
|
||||
description: Latest conversation history API spec, including persisted reasoning content.
|
||||
description: 最新的会话历史接口规范,包含持久化后的推理内容。
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8080
|
||||
@@ -11,11 +11,11 @@ paths:
|
||||
/api/v1/agent/chat:
|
||||
post:
|
||||
tags:
|
||||
- Agent
|
||||
summary: Stream chat reply
|
||||
- 智能体
|
||||
summary: 流式聊天回复
|
||||
description: |
|
||||
Starts an Agent chat request.
|
||||
Retry uses the same endpoint and passes retry metadata in `extra`.
|
||||
发起一轮智能体聊天请求。
|
||||
重试仍使用同一个接口,并通过 extra 字段传递重试元数据。
|
||||
security:
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
@@ -28,14 +28,14 @@ paths:
|
||||
normal:
|
||||
value:
|
||||
conversation_id: 8df59142-29a2-4bf6-85b6-c5e3f4e9cb89
|
||||
message: Help me review tomorrow's tasks.
|
||||
message: 帮我看看明天的任务安排。
|
||||
model: worker
|
||||
thinking: true
|
||||
extra: {}
|
||||
retry:
|
||||
value:
|
||||
conversation_id: 8df59142-29a2-4bf6-85b6-c5e3f4e9cb89
|
||||
message: Help me review tomorrow's tasks.
|
||||
message: 帮我看看明天的任务安排。
|
||||
model: worker
|
||||
thinking: true
|
||||
extra:
|
||||
@@ -45,15 +45,15 @@ paths:
|
||||
retry_from_assistant_message_id: 456
|
||||
responses:
|
||||
'200':
|
||||
description: SSE stream response
|
||||
description: SSE 流式响应
|
||||
'400':
|
||||
description: Invalid request
|
||||
description: 请求参数非法
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
description: 未授权
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -62,8 +62,8 @@ paths:
|
||||
/api/v1/agent/conversation-list:
|
||||
get:
|
||||
tags:
|
||||
- Agent
|
||||
summary: Get conversation list
|
||||
- 智能体
|
||||
summary: 获取会话列表
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
@@ -85,7 +85,7 @@ paths:
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
description: Alias of page_size.
|
||||
description: page_size 的别名。
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
@@ -100,19 +100,19 @@ paths:
|
||||
example: active
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ConversationListEnvelope'
|
||||
'400':
|
||||
description: Invalid request
|
||||
description: 请求参数非法
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
description: 未授权
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -121,14 +121,14 @@ paths:
|
||||
/api/v1/agent/conversation-history:
|
||||
get:
|
||||
tags:
|
||||
- Agent
|
||||
summary: Get conversation history
|
||||
- 智能体
|
||||
summary: 获取会话历史
|
||||
description: |
|
||||
Returns chat history for a conversation.
|
||||
The service validates ownership first, then prefers Redis and falls back to MySQL.
|
||||
`reasoning_content` is now returned from both cache hits and DB-backed history when available.
|
||||
`reasoning_duration_seconds` is server-computed and persisted in seconds.
|
||||
Retry groups are returned via `retry_group_id`, `retry_index`, and `retry_total`.
|
||||
返回指定会话的聊天历史。
|
||||
服务会先校验归属关系,再优先读取 Redis,必要时回退到 MySQL。
|
||||
reasoning_content 现已支持在缓存命中和数据库历史两种场景下返回(若可用)。
|
||||
reasoning_duration_seconds 由服务端计算,并以秒为单位持久化。
|
||||
重试分组通过 retry_group_id、retry_index 和 retry_total 返回。
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
@@ -141,7 +141,7 @@ paths:
|
||||
example: 8df59142-29a2-4bf6-85b6-c5e3f4e9cb89
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -150,11 +150,11 @@ paths:
|
||||
success:
|
||||
value:
|
||||
status: "10000"
|
||||
info: success
|
||||
info: 成功
|
||||
data:
|
||||
- id: 101
|
||||
role: user
|
||||
content: Help me review tomorrow's most important task.
|
||||
content: 帮我看看明天最重要的任务是什么。
|
||||
created_at: "2026-03-24T22:15:01+08:00"
|
||||
reasoning_content: ""
|
||||
reasoning_duration_seconds: 0
|
||||
@@ -163,21 +163,21 @@ paths:
|
||||
retry_total: 2
|
||||
- id: 102
|
||||
role: assistant
|
||||
content: Tomorrow you should prioritize the DB integration and API validation.
|
||||
content: 明天你应该优先处理数据库联调和接口校验。
|
||||
created_at: "2026-03-24T22:15:06+08:00"
|
||||
reasoning_content: "Stage: request.accepted\nUser request accepted.\n\nStage: task_query.reflecting\nComparing the retrieved tasks and selecting the tightest deadlines."
|
||||
reasoning_content: "阶段:request.accepted\n用户请求已接收。\n\n阶段:task_query.reflecting\n正在对比检索到的任务并选择截止时间最紧的事项。"
|
||||
reasoning_duration_seconds: 5
|
||||
retry_group_id: retry-group-001
|
||||
retry_index: 2
|
||||
retry_total: 2
|
||||
'400':
|
||||
description: Missing parameter or conversation not found
|
||||
description: 缺少参数或会话不存在
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
description: 未授权
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -218,7 +218,7 @@ components:
|
||||
properties:
|
||||
request_mode:
|
||||
type: string
|
||||
description: Use `retry` when regenerating a previous answer version.
|
||||
description: 当需要重新生成上一版回答时,使用 retry。
|
||||
example: retry
|
||||
retry_group_id:
|
||||
type: string
|
||||
@@ -244,7 +244,7 @@ components:
|
||||
example: "40005"
|
||||
info:
|
||||
type: string
|
||||
example: wrong param type
|
||||
example: 参数类型错误
|
||||
|
||||
ConversationListEnvelope:
|
||||
type: object
|
||||
@@ -258,7 +258,7 @@ components:
|
||||
example: "10000"
|
||||
info:
|
||||
type: string
|
||||
example: success
|
||||
example: 成功
|
||||
data:
|
||||
$ref: '#/components/schemas/ConversationListResponse'
|
||||
|
||||
@@ -307,7 +307,7 @@ components:
|
||||
format: uuid
|
||||
title:
|
||||
type: string
|
||||
example: Tomorrow task review
|
||||
example: 明日任务复盘
|
||||
has_title:
|
||||
type: boolean
|
||||
example: true
|
||||
@@ -338,7 +338,7 @@ components:
|
||||
example: "10000"
|
||||
info:
|
||||
type: string
|
||||
example: success
|
||||
example: 成功
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
@@ -352,7 +352,7 @@ components:
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: Database primary key. May be omitted on pure cache hits.
|
||||
description: 数据库主键。纯缓存命中场景下可能为空。
|
||||
example: 102
|
||||
role:
|
||||
type: string
|
||||
@@ -360,31 +360,31 @@ components:
|
||||
example: assistant
|
||||
content:
|
||||
type: string
|
||||
example: Tomorrow you should prioritize the DB integration and API validation.
|
||||
example: 明天你应该优先处理数据库联调和接口校验。
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
reasoning_content:
|
||||
type: string
|
||||
description: Aggregated reasoning text, including streamed model reasoning or stage-level thinking text.
|
||||
example: "Stage: request.accepted\nUser request accepted."
|
||||
description: 聚合后的推理文本,包含模型流式推理内容或阶段级思考文本。
|
||||
example: "阶段:request.accepted\n用户请求已接收。"
|
||||
reasoning_duration_seconds:
|
||||
type: integer
|
||||
description: Server-computed reasoning duration in seconds.
|
||||
description: 由服务端计算的推理耗时,单位为秒。
|
||||
example: 5
|
||||
retry_group_id:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Retry group identifier shared by the original answer and all retry versions.
|
||||
description: 原始回答与所有重试版本共享的重试分组标识。
|
||||
example: retry-group-001
|
||||
retry_index:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: 1-based version index within the retry group.
|
||||
description: 在重试分组内的版本序号,从 1 开始。
|
||||
example: 2
|
||||
retry_total:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Total number of versions currently in the retry group.
|
||||
description: 当前重试分组内的总版本数。
|
||||
example: 2
|
||||
|
||||
Reference in New Issue
Block a user