Files
u-tabs/internal/history.go
T
lxy 95f349bcd7 新增: 历史会话标题自定义编辑与摘要质量修复
- 标题自定义编辑:HISTORY 详情按 e 编辑标题,UserTitle 持久化到缓存且最高优先级
- AI 摘要质量修复:困惑回复检测词表收紧,消息提取过滤噪音,修复 JSON 包裹困惑回复漏网
- 数据维护命令:--fix-titles 清理低质量摘要,--regen-summaries 并发生成
- 修复代码审查 9 个问题:退格 UTF-8 损坏、中文输入、flush 调度、误杀合法摘要、
  按键全局拦截、UserTitle 未同步窗口标题、wt 启动回落、cmd 注入防护、前缀误隐藏会话
- 启动链 wt.exe 回退与 cmd start 标题转义
- gofmt 规范化,新增 launch_test.go
2026-07-31 21:22:17 +08:00

2040 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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)
}