- 标题自定义编辑:HISTORY 详情按 e 编辑标题,UserTitle 持久化到缓存且最高优先级 - AI 摘要质量修复:困惑回复检测词表收紧,消息提取过滤噪音,修复 JSON 包裹困惑回复漏网 - 数据维护命令:--fix-titles 清理低质量摘要,--regen-summaries 并发生成 - 修复代码审查 9 个问题:退格 UTF-8 损坏、中文输入、flush 调度、误杀合法摘要、 按键全局拦截、UserTitle 未同步窗口标题、wt 启动回落、cmd 注入防护、前缀误隐藏会话 - 启动链 wt.exe 回退与 cmd start 标题转义 - gofmt 规范化,新增 launch_test.go
2040 lines
32 KiB
Go
2040 lines
32 KiB
Go
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{
|
||
|
||
"<command", "<system-reminder",
|
||
|
||
"[Request interrupted", "[Image",
|
||
|
||
"[Resuming", "[Resumed",
|
||
|
||
"This session is being continued",
|
||
|
||
"Base directory",
|
||
}
|
||
|
||
for _, p := range trivialPrefixes {
|
||
|
||
if strings.HasPrefix(s, p) {
|
||
|
||
return true
|
||
|
||
}
|
||
|
||
}
|
||
|
||
return false
|
||
|
||
}
|
||
|
||
// extractJSONString 从 " 开头的 JSON 数据中提取字符串值
|
||
|
||
func extractJSONString(data []byte, out *string) bool {
|
||
|
||
if len(data) == 0 || data[0] != '"' {
|
||
|
||
return false
|
||
|
||
}
|
||
|
||
// 找结束引号(处理转义)
|
||
|
||
var s string
|
||
|
||
if json.Unmarshal(data[:findStringEnd(data)], &s) == nil {
|
||
|
||
*out = s
|
||
|
||
return true
|
||
|
||
}
|
||
|
||
return false
|
||
|
||
}
|
||
|
||
// findStringEnd 从 " 开始找到结束引号的位置(含)
|
||
|
||
func findStringEnd(data []byte) int {
|
||
|
||
escaped := false
|
||
|
||
for i := 1; i < len(data); i++ {
|
||
|
||
if escaped {
|
||
|
||
escaped = false
|
||
|
||
continue
|
||
|
||
}
|
||
|
||
if data[i] == '\\' {
|
||
|
||
escaped = true
|
||
|
||
continue
|
||
|
||
}
|
||
|
||
if data[i] == '"' {
|
||
|
||
return i + 1
|
||
|
||
}
|
||
|
||
}
|
||
|
||
return len(data)
|
||
|
||
}
|
||
|
||
// --- 缓存 ---
|
||
|
||
const cacheVersion = 2
|
||
|
||
type cacheFile struct {
|
||
Version int `json:"version"`
|
||
|
||
Sessions map[string]*cacheEntry `json:"sessions"`
|
||
|
||
ProjectTimes map[string]time.Time `json:"projectTimes"` // 项目目录 modTime
|
||
|
||
}
|
||
|
||
type cacheEntry struct {
|
||
ModTime time.Time `json:"modTime"`
|
||
|
||
CustomTitle string `json:"customTitle"`
|
||
|
||
Cwd string `json:"cwd"`
|
||
|
||
StartTime time.Time `json:"startTime"`
|
||
|
||
EndTime time.Time `json:"endTime"`
|
||
|
||
MsgCount int `json:"msgCount"`
|
||
|
||
FirstMsg string `json:"firstMsg"`
|
||
|
||
AwaySummary string `json:"awaySummary"`
|
||
|
||
AISummary string `json:"aiSummary"`
|
||
|
||
Completed []string `json:"completed"`
|
||
|
||
Pending []string `json:"pending"`
|
||
|
||
FilePath string `json:"filePath"`
|
||
|
||
ForkFrom string `json:"forkFrom"`
|
||
|
||
UserTitle string `json:"userTitle"`
|
||
}
|
||
|
||
func (e *cacheEntry) ToSession(id string) *Session {
|
||
|
||
return &Session{
|
||
|
||
ID: id, CustomTitle: e.CustomTitle, Cwd: e.Cwd,
|
||
|
||
StartTime: e.StartTime, EndTime: e.EndTime,
|
||
|
||
MsgCount: e.MsgCount, FirstMsg: e.FirstMsg,
|
||
|
||
AwaySummary: e.AwaySummary, AISummary: e.AISummary,
|
||
|
||
Completed: e.Completed, Pending: e.Pending,
|
||
|
||
FilePath: e.FilePath, ForkFrom: e.ForkFrom, UserTitle: e.UserTitle,
|
||
}
|
||
|
||
}
|
||
|
||
func cacheEntryFrom(s *Session, modTime time.Time) *cacheEntry {
|
||
|
||
return &cacheEntry{
|
||
|
||
ModTime: modTime, CustomTitle: s.CustomTitle, Cwd: s.Cwd,
|
||
|
||
StartTime: s.StartTime, EndTime: s.EndTime,
|
||
|
||
MsgCount: s.MsgCount, FirstMsg: s.FirstMsg,
|
||
|
||
AwaySummary: s.AwaySummary, AISummary: s.AISummary,
|
||
|
||
Completed: s.Completed, Pending: s.Pending,
|
||
|
||
FilePath: s.FilePath, ForkFrom: s.ForkFrom, UserTitle: s.UserTitle,
|
||
}
|
||
|
||
}
|
||
|
||
func cachePath(home string) string {
|
||
|
||
return filepath.Join(home, ".u-tabs", "session-cache.json")
|
||
|
||
}
|
||
|
||
func loadCache(home string) map[string]*cacheEntry {
|
||
|
||
c, _ := loadCacheWithTimes(home)
|
||
|
||
return c
|
||
|
||
}
|
||
|
||
func loadCacheWithTimes(home string) (map[string]*cacheEntry, map[string]time.Time) {
|
||
|
||
data, err := os.ReadFile(cachePath(home))
|
||
|
||
if err != nil {
|
||
|
||
return make(map[string]*cacheEntry), make(map[string]time.Time)
|
||
|
||
}
|
||
|
||
var cf cacheFile
|
||
|
||
if json.Unmarshal(data, &cf) != nil || cf.Version != cacheVersion {
|
||
|
||
return make(map[string]*cacheEntry), make(map[string]time.Time)
|
||
|
||
}
|
||
|
||
projTimes := cf.ProjectTimes
|
||
|
||
if projTimes == nil {
|
||
|
||
projTimes = make(map[string]time.Time)
|
||
|
||
}
|
||
|
||
return cf.Sessions, projTimes
|
||
|
||
}
|
||
|
||
func saveCache(home string, m map[string]*cacheEntry) {
|
||
|
||
// 保留磁盘上已有的 projectTimes:flushCache 在两次扫描之间写回摘要时调用本函数,
|
||
|
||
// 不能用 nil 覆盖,否则磁盘 projectTimes 被清空 → 下次启动所有目录被判为已变更而全量扫描。
|
||
|
||
_, projTimes := loadCacheWithTimes(home)
|
||
|
||
saveCacheWithTimes(home, m, projTimes)
|
||
|
||
}
|
||
|
||
func saveCacheWithTimes(home string, m map[string]*cacheEntry, projTimes map[string]time.Time) {
|
||
|
||
cf := cacheFile{Version: cacheVersion, Sessions: m, ProjectTimes: projTimes}
|
||
|
||
data, err := json.Marshal(cf)
|
||
|
||
if err != nil {
|
||
|
||
return
|
||
|
||
}
|
||
|
||
dir := filepath.Join(home, ".u-tabs")
|
||
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
|
||
log.Printf("[u-tabs] cache mkdir fail: %v", err)
|
||
|
||
return
|
||
|
||
}
|
||
|
||
if err := atomicWriteFile(cachePath(home), data, 0o644); err != nil {
|
||
|
||
log.Printf("[u-tabs] cache write fail: %v", err)
|
||
|
||
}
|
||
|
||
}
|
||
|
||
func truncStr(s string, maxRunes int) string {
|
||
|
||
runes := []rune(s)
|
||
|
||
if len(runes) <= maxRunes {
|
||
|
||
return s
|
||
|
||
}
|
||
|
||
return string(runes[:maxRunes]) + "..."
|
||
|
||
}
|
||
|
||
// --- AI 摘要生成 ---
|
||
|
||
type SummaryResultMsg struct {
|
||
SessionID string
|
||
|
||
Summary string
|
||
|
||
Completed []string
|
||
|
||
Pending []string
|
||
}
|
||
|
||
func generateSummaryCmd(filePath, sessionID string) tea.Cmd {
|
||
|
||
return func() tea.Msg {
|
||
|
||
sum, done, todo := generateAISummary(filePath)
|
||
|
||
return SummaryResultMsg{SessionID: sessionID, Summary: sum, Completed: done, Pending: todo}
|
||
|
||
}
|
||
|
||
}
|
||
|
||
type aiSummaryResult struct {
|
||
Summary string `json:"summary"`
|
||
|
||
Completed []string `json:"completed"`
|
||
|
||
Pending []string `json:"pending"`
|
||
}
|
||
|
||
func generateAISummary(filePath string) (string, []string, []string) {
|
||
|
||
messages := extractMessagesForSummary(filePath)
|
||
|
||
if messages == "" {
|
||
|
||
return "", nil, nil
|
||
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
|
||
defer cancel()
|
||
|
||
prompt := summaryPromptPrefix + `,用JSON输出:
|
||
|
||
{"summary":"30字以内中文标题","completed":["已完成项"],"pending":["待办项"]}
|
||
|
||
要求:summary 30字以内中文标题,突出具体内容避免泛化;completed 已完成工作(每项≤30字);pending 未完成/待办(每项≤30字)。只输出JSON。` + "\n\n" + messages
|
||
|
||
cmd := exec.CommandContext(ctx, "claude", "-p", prompt)
|
||
|
||
output, err := cmd.Output()
|
||
|
||
if err != nil {
|
||
|
||
return "", nil, nil
|
||
|
||
}
|
||
|
||
raw := strings.TrimSpace(string(output))
|
||
|
||
raw = strings.Trim(raw, "`")
|
||
|
||
raw = strings.TrimPrefix(raw, "json")
|
||
|
||
raw = strings.TrimSpace(raw)
|
||
|
||
var result aiSummaryResult
|
||
|
||
if json.Unmarshal([]byte(raw), &result) != nil {
|
||
|
||
// 检测 Claude 因没拿到内容而回复的"没看到对话"等错误信息
|
||
|
||
if isConfusedResponse(raw) {
|
||
|
||
return "", nil, nil
|
||
|
||
}
|
||
|
||
// Fallback: treat whole output as summary
|
||
|
||
s := strings.Trim(raw, "\"'`*")
|
||
|
||
s = strings.TrimPrefix(s, "概括:")
|
||
|
||
s = strings.TrimPrefix(s, "主题:")
|
||
|
||
if len(s) > 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)
|
||
|
||
}
|