package internal import ( "bufio" "bytes" "context" "encoding/json" "log" "os" "os/exec" "path/filepath" "sort" "strings" "sync" "time" tea "charm.land/bubbletea/v2" ) // Session 单个会话的元数据 type Session struct { ID string // UUID (文件名) CustomTitle string // customTitle 字段 Cwd string // 实际工作目录 StartTime time.Time // 首条 timestamp EndTime time.Time // 末条 timestamp MsgCount int // user + assistant 消息数 FirstMsg string // 首条用户消息 (截断) AwaySummary string // away_summary 系统摘要 AISummary string // AI 生成的摘要 (缓存) Completed []string // AI 提取的已完成项 Pending []string // AI 提取的待办项 FilePath string // JSONL 文件完整路径 ForkFrom string // 分叉来源 session ID(空表示非分叉) UserTitle string // 用户手动编辑的自定义标题(最高优先级,持久化到缓存) UserMsgs []string // 全部用户消息(正序),详情面板懒加载,不持久化 userMsgsLoaded bool // 历史消息是否已懒加载,避免空结果反复读盘 } // ProjectDir 按目录分组的会话集合 type ProjectDir struct { Dir string // 完整路径 DirShort string // 显示名 (路径最后一段) Sessions []*Session // 按时间倒序 } // DisplayTitle 返回会话显示标题 func (s *Session) DisplayTitle() string { return cleanText(s.displayTitleRaw()) } func (s *Session) displayTitleRaw() string { if s.UserTitle != "" { return s.UserTitle } if s.AISummary != "" { return s.AISummary } if s.FirstMsg != "" { return s.FirstMsg } if s.CustomTitle != "" { return s.CustomTitle } return s.ID[:8] } // DisplaySummary 返回会话摘要 func (s *Session) DisplaySummary() string { return cleanText(s.displaySummaryRaw()) } func (s *Session) displaySummaryRaw() string { if s.AwaySummary != "" { return s.AwaySummary } return s.FirstMsg } // cleanText 清理显示文本:多空格保留一个,换行转空格 func cleanText(s string) string { s = strings.ReplaceAll(s, "\r\n", " ") s = strings.ReplaceAll(s, "\n", " ") s = strings.ReplaceAll(s, "\t", " ") for strings.Contains(s, " ") { s = strings.ReplaceAll(s, " ", " ") } return strings.TrimSpace(s) } // HistoryState HISTORY Tab 视图状态 type HistoryState struct { Projects []*ProjectDir DirCursor int // 左栏光标 SessCursor int // 中栏光标 FocusPanel int // 0=左栏 1=中栏 Loaded bool Scanning bool Favorites map[string]bool Cache map[string]*cacheEntry // 内存缓存,避免重复 I/O } // IsHistoryTab 判断当前是否在 HISTORY Tab func (m *Model) IsHistoryTab() bool { return m.activeGroup == len(Groups) } // --- 扫描 --- type ScanCompleteMsg struct { Projects []*ProjectDir Cache map[string]*cacheEntry // 更新后的缓存 UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要 } func ScanSessionsCmd() tea.Cmd { return func() tea.Msg { projects, cache, updated := scanAllProjects() return ScanCompleteMsg{Projects: projects, Cache: cache, UpdatedIDs: updated} } } func scanAllProjects() ([]*ProjectDir, map[string]*cacheEntry, map[string]bool) { home, err := os.UserHomeDir() if err != nil { return nil, nil, nil } projectsDir := filepath.Join(home, ".claude", "projects") entries, err := os.ReadDir(projectsDir) if err != nil { return nil, nil, nil } cache, projTimes := loadCacheWithTimes(home) dirMap := make(map[string]*ProjectDir) updatedIDs := make(map[string]bool) seenIDs := make(map[string]bool) scanBuf := make([]byte, 0, 64*1024) // 共享扫描缓冲区 for _, entry := range entries { if !entry.IsDir() { continue } projectPath := filepath.Join(projectsDir, entry.Name()) info, err := entry.Info() if err != nil { continue } // 项目目录 modTime 未变 → 从缓存直接重建该目录下的会话 if prev, ok := projTimes[projectPath]; ok && prev.Equal(info.ModTime()) { for _, id := range rebuildFromCache(cache, dirMap, projectPath) { seenIDs[id] = true } continue } // 目录有变更,记录新 modTime projTimes[projectPath] = info.ModTime() files, err := os.ReadDir(projectPath) if err != nil { continue } for _, f := range files { if f.IsDir() || !strings.HasSuffix(f.Name(), ".jsonl") { continue } sessionID := strings.TrimSuffix(f.Name(), ".jsonl") seenIDs[sessionID] = true filePath := filepath.Join(projectPath, f.Name()) finfo, err := f.Info() if err != nil { continue } if cached, ok := cache[sessionID]; ok && cached.ModTime.Equal(finfo.ModTime()) { addSessionToDir(dirMap, cached.ToSession(sessionID)) continue } session := scanSessionFile(filePath, sessionID, &scanBuf) if session == nil || session.Cwd == "" { continue } // 清除旧摘要,标记需要重新生成 session.AISummary = "" session.Completed = nil session.Pending = nil // 保留旧缓存中的用户自定义标题 if cached, ok := cache[sessionID]; ok && cached.UserTitle != "" { session.UserTitle = cached.UserTitle } updatedIDs[sessionID] = true addSessionToDir(dirMap, session) cache[sessionID] = cacheEntryFrom(session, finfo.ModTime()) } } // 裁剪缓存:删除磁盘已不存在的条目 for id := range cache { if !seenIDs[id] { delete(cache, id) } } // 清理已不存在项目的 projTimes activeProjects := make(map[string]bool, len(entries)) for _, e := range entries { if e.IsDir() { activeProjects[filepath.Join(projectsDir, e.Name())] = true } } for p := range projTimes { if !activeProjects[p] { delete(projTimes, p) } } saveCacheWithTimes(home, cache, projTimes) result := make([]*ProjectDir, 0, len(dirMap)) for _, pd := range dirMap { sort.Slice(pd.Sessions, func(i, j int) bool { return pd.Sessions[i].StartTime.After(pd.Sessions[j].StartTime) }) result = append(result, pd) } sort.Slice(result, func(i, j int) bool { return result[i].Dir < result[j].Dir }) return result, cache, updatedIDs } // rebuildFromCache 从缓存重建指定项目目录下的所有会话,返回匹配的 session ID func rebuildFromCache(cache map[string]*cacheEntry, dirMap map[string]*ProjectDir, projectPath string) []string { prefix := filepath.Base(projectPath) var seen []string for id, e := range cache { if filepath.Base(filepath.Dir(e.FilePath)) == prefix { addSessionToDir(dirMap, e.ToSession(id)) seen = append(seen, id) } } return seen } func addSessionToDir(dirMap map[string]*ProjectDir, s *Session) { cwd := strings.ReplaceAll(s.Cwd, "\\", "/") pd, ok := dirMap[cwd] if !ok { pd = &ProjectDir{Dir: cwd, DirShort: filepath.Base(cwd)} dirMap[cwd] = pd } pd.Sessions = append(pd.Sessions, s) } // --- JSONL 扫描 --- const scanLineLimit = 500 // summaryPromptPrefix u-tabs 调用 claude -p 生成会话摘要时使用的 prompt 前缀。 // 作为判定"摘要会话"的依据:首条 user 消息以此开头即为 -p 摘要会话,扫描时跳过。 // 旧版用"前20行含 queue-operation"判定,但新版交互会话也带 queue-operation,会误杀正常会话。 const summaryPromptPrefix = "分析以下Claude Code对话" func scanSessionFile(path string, sessionID string, buf *[]byte) *Session { f, err := os.Open(path) if err != nil { return nil } defer f.Close() s := &Session{ID: sessionID, FilePath: path} scanner := bufio.NewScanner(f) scanner.Buffer(*buf, 1024*1024) lineCount := 0 firstMsgFound := false for scanner.Scan() { lineCount++ line := scanner.Bytes() if !hasType(line) { continue } // cwd — 统一为正斜杠 if s.Cwd == "" { extractField(line, `"cwd":"`, &s.Cwd) s.Cwd = strings.ReplaceAll(s.Cwd, "\\", "/") } // timestamp var ts string if extractField(line, `"timestamp":"`, &ts) { if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { if s.StartTime.IsZero() || t.Before(s.StartTime) { s.StartTime = t } if t.After(s.EndTime) { s.EndTime = t } } } switch { case matchType(line, "custom-title"): extractField(line, `"customTitle":"`, &s.CustomTitle) case matchSubtype(line, "away_summary"): extractField(line, `"content":"`, &s.AwaySummary) case matchType(line, "user"): if bytes.Contains(line, []byte(`"tool_use_id"`)) || bytes.Contains(line, []byte(`"isMeta":true`)) { continue } s.MsgCount++ if !firstMsgFound { if msg := extractUserMsg(line); msg != "" { // 首条 user 消息是 u-tabs 的 AI 摘要 prompt → claude -p 摘要会话,跳过 // 仅当同时命中 prompt 特征片段才判定为 -p 摘要会话,避免真实会话首条恰以同样文案开头被误隐藏 if strings.HasPrefix(msg, summaryPromptPrefix) && (strings.Contains(msg, "用JSON输出") || strings.Contains(msg, `{"summary"`) || strings.Contains(msg, "completed")) { return nil } s.FirstMsg = truncStr(msg, 80) firstMsgFound = true } } case matchType(line, "assistant"): s.MsgCount++ } if lineCount > scanLineLimit { break } } return s } // matchType 检查 JSON 行是否包含指定 type 值 func matchType(line []byte, typeName string) bool { compact := `"type":"` + typeName + `"` spaced := `"type": "` + typeName + `"` return bytes.Contains(line, []byte(compact)) || bytes.Contains(line, []byte(spaced)) } // matchSubtype 检查 JSON 行是否包含指定 subtype 值 func matchSubtype(line []byte, subName string) bool { compact := `"subtype":"` + subName + `"` spaced := `"subtype": "` + subName + `"` return bytes.Contains(line, []byte(compact)) || bytes.Contains(line, []byte(spaced)) } // hasType 快速过滤:行是否包含 type 字段 func hasType(line []byte) bool { return bytes.Contains(line, []byte(`"type":"`)) || bytes.Contains(line, []byte(`"type": "`)) } // extractField 从 JSON 行中提取 "key":"value" 的值 func extractField(line []byte, prefix string, out *string) bool { idx := bytes.Index(line, []byte(prefix)) if idx < 0 { return false } // prefix 长度 = len("key\":"),data 从引号开始 return extractJSONString(line[idx+len(prefix)-1:], out) } func extractUserMsg(line []byte) string { // string 格式: "content":"xxx" if idx := bytes.Index(line, []byte(`"content":"`)); idx >= 0 { var content string extractJSONString(line[idx+10:], &content) if content != "" && !isTrivialMsg(content) { return content } } // array 格式: [{"type":"text","text":"xxx"}] if idx := bytes.Index(line, []byte(`"type":"text"`)); idx >= 0 { searchStart := max(0, idx-200) region := line[searchStart:] if tidx := bytes.Index(region, []byte(`"text":"`)); tidx >= 0 { var text string if extractJSONString(region[tidx+7:], &text) && text != "" && !isTrivialMsg(text) { return text } } } return "" } // isTrivialMsg 判断是否为无意义的噪音消息(图片、恢复提示、系统指令等) func isTrivialMsg(s string) bool { trivialPrefixes := []string{ " 60 { s = truncStr(s, 60) } return s, nil, nil } // JSON 解析成功,但 summary 仍可能是 Claude 的困惑回复(被包成合法 JSON 返回) if isConfusedResponse(result.Summary) { return "", nil, nil } if len(result.Summary) > 60 { result.Summary = truncStr(result.Summary, 60) } return result.Summary, result.Completed, result.Pending } // isConfusedResponse 检测 claude -p 摘要调用中 Claude 回复"没有收到内容"等错误, // 此类回复不应作为标题使用,应降级为 FirstMsg。 // 两级判定:① 明确短语;② 启发式——回复复述了 prompt(含"对话内容"/"分析以下")且带否定词。 func isConfusedResponse(text string) bool { lower := strings.ToLower(text) // ① 明确的困惑短语 confused := []string{ "没有附上", "没有附带", "没有看到", "没有包含", "没有提供", "没有给出", "没有需要分析", "没有要分析", "没有实际", "消息是空的", "内容是空的", "冒号后面", "冒号后", "只到了", "就结束了", "缺少了", "请求似乎不完整", "只有系统提示", // 精确匹配"只有系统提示",避免裸"系统提示"误杀合法摘要 "nothing to analyze", "no conversation", "didn't receive", } for _, p := range confused { if strings.Contains(lower, p) { return true } } // ② 启发式:复述 prompt 关键词 + 否定/请求词 → 判为困惑回复 echoesPrompt := strings.Contains(lower, "对话内容") || strings.Contains(lower, "分析以下") || strings.Contains(lower, "claude code 对话") || strings.Contains(lower, "claude code对话") if echoesPrompt { negations := []string{"没", "无", "缺", "空", "只有", "似乎"} for _, n := range negations { if strings.Contains(lower, n) { return true } } } return false } // isBadTitle 判断 AI 摘要是否为低质量(需重新生成): // Claude 困惑回复 / 噪音消息 / 过短(≤4 字符) func isBadTitle(s string) bool { s = strings.TrimSpace(s) if s == "" { return false } return isConfusedResponse(s) || isTrivialMsg(s) || len([]rune(s)) <= 3 } // CleanupBadTitles 清理缓存中的低质量 AI 摘要(Claude 困惑回复、噪音、过短)。 // 清空这些条目的 AISummary 并把 ModTime 置零、清空 projectTimes, // 使下次启动时这些会话被重新扫描并重新生成摘要。返回清理条目数。 func CleanupBadTitles() int { home, err := os.UserHomeDir() if err != nil { return 0 } cache, _ := loadCacheWithTimes(home) if len(cache) == 0 { return 0 } cleaned := 0 for _, e := range cache { if e.AISummary == "" { continue } if isBadTitle(e.AISummary) { e.AISummary = "" e.Completed = nil e.Pending = nil e.ModTime = time.Time{} // 置零 → 下次扫描判定为变更 → 重新生成 cleaned++ } } if cleaned == 0 { return 0 } // 清空 projectTimes:否则下次启动走 rebuildFromCache 分支(只看目录 modTime), // 不检查单文件 modTime,坏条目不会被重扫。清空后强制全量重扫目录。 saveCacheWithTimes(home, cache, make(map[string]time.Time)) return cleaned } // RegenerateSummaries 为缓存中所有 AISummary 为空的会话重新生成 AI 摘要。 // 并发调用 claude -p(concurrency 控制并发数),结果实时写回缓存并恢复真实 ModTime。 // 返回 (成功数, 降级数);提取不到有效内容的会话降级为 FirstMsg 兜底,不留空标题。 func RegenerateSummaries(concurrency int) (int, int) { home, err := os.UserHomeDir() if err != nil { return 0, 0 } cache, projTimes := loadCacheWithTimes(home) if len(cache) == 0 { return 0, 0 } if concurrency < 1 { concurrency = 4 } type job struct { id string entry *cacheEntry } var jobs []job for id, e := range cache { if e.AISummary == "" && e.FilePath != "" { jobs = append(jobs, job{id, e}) } } total := len(jobs) if total == 0 { return 0, 0 } log.Printf("[regen] 开始重新生成 %d 个摘要(并发 %d)", total, concurrency) var ( mu sync.Mutex ok int fail int done int wg sync.WaitGroup ) sem := make(chan struct{}, concurrency) for _, j := range jobs { wg.Add(1) go func(j job) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() sum, completed, pending := generateAISummary(j.entry.FilePath) mu.Lock() done++ if sum == "" { // 降级:从文件重新提取首条有效用户消息作为标题。 // cache 里的 FirstMsg 可能是旧版扫描逻辑(无噪音过滤)留下的脏数据, // 所以直接跳过它;极短文本(≤3 字符)也用 ID 兜底,避免循环清理。 fallback := firstValidUserMsg(j.entry.FilePath) if fallback == "" || isTrivialMsg(fallback) || len([]rune(fallback)) <= 3 { fallback = j.id[:8] } j.entry.AISummary = fallback fail++ log.Printf("[regen] [%d/%d] %s 降级 → %s", done, total, j.id[:8], truncStr(j.entry.AISummary, 30)) } else { j.entry.AISummary = sum j.entry.Completed = completed j.entry.Pending = pending ok++ log.Printf("[regen] [%d/%d] %s → %s", done, total, j.id[:8], truncStr(sum, 30)) } // 恢复真实 ModTime,避免下次启动重复重扫 if fi, err := os.Stat(j.entry.FilePath); err == nil { j.entry.ModTime = fi.ModTime() } mu.Unlock() }(j) } wg.Wait() saveCacheWithTimes(home, cache, projTimes) log.Printf("[regen] 完成: 成功 %d, 降级 %d, 共 %d", ok, fail, total) return ok, fail } // firstValidUserMsg 从会话文件提取首条有效用户消息(已过滤噪音/工具回填), // 供摘要降级兜底使用。与 extractMessagesForSummary 不同:不限长度、只取第一条。 func firstValidUserMsg(path string) string { f, err := os.Open(path) if err != nil { return "" } defer f.Close() scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { line := scanner.Bytes() if !hasType(line) || !matchType(line, "user") { continue } if bytes.Contains(line, []byte(`"tool_use_id"`)) || bytes.Contains(line, []byte(`"isMeta":true`)) { continue } if msg := extractUserMsg(line); msg != "" { return truncStr(msg, 80) } } return "" } func extractMessagesForSummary(path string) string { f, err := os.Open(path) if err != nil { return "" } defer f.Close() var rawMsgs []string scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 收集最多 10 条用户消息(比扫描更多,提高命中有效内容的概率) for scanner.Scan() { if len(rawMsgs) >= 10 { break } line := scanner.Bytes() if !hasType(line) || !matchType(line, "user") { continue } if msg := extractUserMsg(line); msg != "" { rawMsgs = append(rawMsgs, msg) } } // 过滤掉短小/噪音消息 var meaningful []string for _, msg := range rawMsgs { msg = strings.TrimSpace(msg) if isTrivialMsg(msg) || len([]rune(msg)) < 10 { continue } meaningful = append(meaningful, "用户: "+msg) } if len(meaningful) < 1 { return "" } // 最多取前 5 条有意义消息,防止过度消耗 if len(meaningful) > 5 { meaningful = meaningful[:5] } return strings.Join(meaningful, "\n") } // maxDetailUserMsgs 历史详情展示的用户消息上限,防极端大文件卡顿 const maxDetailUserMsgs = 200 // extractUserMessages 读取会话的全部用户消息(正序),供历史详情面板展示。 // 与 extractMessagesForSummary 不同:不限 5 条、返回原始消息、不做拼接。 func extractUserMessages(path string) []string { f, err := os.Open(path) if err != nil { return nil } defer f.Close() var msgs []string scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { if len(msgs) >= maxDetailUserMsgs { break } line := scanner.Bytes() if !hasType(line) || !matchType(line, "user") { continue } // 排除工具结果回填与 meta 行 if bytes.Contains(line, []byte(`"tool_use_id"`)) || bytes.Contains(line, []byte(`"isMeta":true`)) { continue } if msg := extractUserMsg(line); msg != "" { msgs = append(msgs, msg) } } return msgs } func updateSummaryInCache(cache map[string]*cacheEntry, sessionID, summary string, completed, pending []string) { if cache == nil { return } entry, ok := cache[sessionID] if !ok { return } entry.AISummary = summary entry.Completed = completed entry.Pending = pending // 持久化由 Model.flushCache() 延迟批量写入 } // cacheFlushMsg 延迟写缓存定时器消息 type cacheFlushMsg struct{} // --- 收藏 --- func favoritesPath(home string) string { return filepath.Join(home, ".u-tabs", "favorites.json") } func loadFavorites() map[string]bool { home, err := os.UserHomeDir() if err != nil { return make(map[string]bool) } data, err := os.ReadFile(favoritesPath(home)) if err != nil { return make(map[string]bool) } var ids []string if json.Unmarshal(data, &ids) != nil { return make(map[string]bool) } m := make(map[string]bool, len(ids)) for _, id := range ids { m[id] = true } return m } func saveFavorites(favs map[string]bool) { home, err := os.UserHomeDir() if err != nil { return } ids := make([]string, 0, len(favs)) for id := range favs { ids = append(ids, id) } data, err := json.Marshal(ids) if err != nil { return } dir := filepath.Join(home, ".u-tabs") if err := os.MkdirAll(dir, 0o755); err != nil { log.Printf("[u-tabs] favorites mkdir fail: %v", err) return } if err := atomicWriteFile(favoritesPath(home), data, 0o644); err != nil { log.Printf("[u-tabs] favorites write fail: %v", err) } } func countFavorites(projects []*ProjectDir, favs map[string]bool) int { n := 0 for _, pd := range projects { for _, s := range pd.Sessions { if favs[s.ID] { n++ } } } return n } func collectFavorites(projects []*ProjectDir, favs map[string]bool) []*Session { var out []*Session for _, pd := range projects { for _, s := range pd.Sessions { if favs[s.ID] { out = append(out, s) } } } return out } // --- Fork 记录 --- func forksPath(home string) string { return filepath.Join(home, ".u-tabs", "forks.json") } // pendingFork 记录一次待绑定的 fork:启动 fork 后新会话 ID 未知, // 等下次扫描时按 cwd + 时间窗口匹配出新建会话再建立关系。 type pendingFork struct { SourceID string Cwd string At time.Time } func loadForks() map[string]string { home, err := os.UserHomeDir() if err != nil { return make(map[string]string) } data, err := os.ReadFile(forksPath(home)) if err != nil { return make(map[string]string) } var m map[string]string if json.Unmarshal(data, &m) != nil { return make(map[string]string) } return m } func recordFork(targetID, sourceID string) { home, err := os.UserHomeDir() if err != nil { return } forks := loadForks() forks[targetID] = sourceID data, err := json.Marshal(forks) if err != nil { return } if err := atomicWriteFile(forksPath(home), data, 0o644); err != nil { log.Printf("[u-tabs] forks write fail: %v", err) } } // resolvePendingForks 处理 executeFork 留下的待绑定项: // 在最新扫描结果中,找出 cwd 匹配且 StartTime 晚于记录时刻的新会话, // 将其作为 fork 目标记录关系到 forks.json。 // 匹配窗口 10 分钟,避免时钟偏差;同一 cwd 只匹配最早的一条新会话。 func resolvePendingForks(pending []pendingFork, projects []*ProjectDir) { if len(pending) == 0 { return } forks := loadForks() changed := false for _, p := range pending { // 已绑过则跳过 matched := "" for _, pd := range projects { if normalizeCwd(pd.Dir) != normalizeCwd(p.Cwd) { continue } for _, s := range pd.Sessions { if s.StartTime.IsZero() { continue } // 新会话起始时间晚于 fork 时刻(容许 2 秒时钟偏差) if !s.StartTime.Before(p.At.Add(-2 * time.Second)) { if s.ID == p.SourceID { continue } if matched == "" || s.StartTime.Before(findSessionStart(projects, matched)) { matched = s.ID } } } } if matched != "" { forks[matched] = p.SourceID changed = true } } if changed { home, err := os.UserHomeDir() if err == nil { data, _ := json.Marshal(forks) atomicWriteFile(forksPath(home), data, 0o644) } } } func normalizeCwd(s string) string { return strings.ReplaceAll(s, "\\", "/") } func findSessionStart(projects []*ProjectDir, id string) time.Time { for _, pd := range projects { for _, s := range pd.Sessions { if s.ID == id { return s.StartTime } } } return time.Time{} } // --- AI 摘要与收藏操作 --- func (m *Model) applySummary(sessionID, summary string, completed, pending []string) { for _, pd := range m.history.Projects { for _, s := range pd.Sessions { if s.ID == sessionID { if summary == "" { // 标记为已尝试,防止 nextSummaryCmd 无限重试 // 用 FirstMsg 作为兜底显示,避免显示空白 s.AISummary = s.FirstMsg if s.AISummary == "" { s.AISummary = s.ID[:8] } } else { s.AISummary = summary s.Completed = completed s.Pending = pending } updateSummaryInCache(m.history.Cache, sessionID, s.AISummary, s.Completed, s.Pending) return } } } } const maxSummaryPerSession = 20 // 每次启动最多生成 N 个摘要 func (m *Model) nextSummaryCmd() tea.Cmd { if m.summaryCount >= maxSummaryPerSession { return nil } for _, pd := range m.history.Projects { for _, s := range pd.Sessions { // 仅对从未生成过摘要的会话生成,避免对已有摘要的会话反复重试 // (fallback 路径已用 FirstMsg/ID 充充 AISummary,标记为已生成) if s.AISummary == "" && s.FirstMsg != "" && s.FilePath != "" { m.summaryCount++ return generateSummaryCmd(s.FilePath, s.ID) } } } return nil } func (m *Model) toggleFavorite() { if m.history.Favorites == nil { m.history.Favorites = loadFavorites() } s := m.currentSession() if s == nil { return } if m.history.Favorites[s.ID] { delete(m.history.Favorites, s.ID) } else { m.history.Favorites[s.ID] = true } saveFavorites(m.history.Favorites) }