Files
u-tabs/internal/history.go
绝尘 e4ef8e02aa 新增: AI 摘要生成 (claude -p)
扫描完成后自动逐个调用 claude -p 生成会话标题,
摘要缓存到 ~/.u-tabs/session-cache.json, 下次免重复调用
2026-05-16 23:33:06 +08:00

498 lines
12 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"
"time"
"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 生成的摘要 (缓存)
FilePath string // JSONL 文件完整路径
}
// ProjectDir 按目录分组的会话集合
type ProjectDir struct {
Dir string // 完整路径
DirShort string // 显示名 (路径最后一段)
Sessions []*Session // 按时间倒序
}
// DisplayTitle 返回会话显示标题
func (s *Session) DisplayTitle() string {
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 {
if s.AwaySummary != "" {
return s.AwaySummary
}
return s.FirstMsg
}
// HistoryState HISTORY Tab 视图状态
type HistoryState struct {
Projects []*ProjectDir
DirCursor int // 左栏光标
SessCursor int // 中栏光标
FocusPanel int // 0=左栏 1=中栏
Loaded bool
Scanning bool
}
// IsHistoryTab 判断当前是否在 HISTORY Tab
func (m *Model) IsHistoryTab() bool {
return m.activeGroup == len(Groups)
}
// --- 扫描 ---
type ScanCompleteMsg struct {
Projects []*ProjectDir
}
func ScanSessionsCmd() tea.Cmd {
return func() tea.Msg {
return ScanCompleteMsg{Projects: scanAllProjects()}
}
}
func scanAllProjects() []*ProjectDir {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
projectsDir := filepath.Join(home, ".claude", "projects")
entries, err := os.ReadDir(projectsDir)
if err != nil {
return nil
}
cache := loadCache(home)
dirMap := make(map[string]*ProjectDir)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
projectPath := filepath.Join(projectsDir, entry.Name())
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")
filePath := filepath.Join(projectPath, f.Name())
info, err := f.Info()
if err != nil {
continue
}
if cached, ok := cache[sessionID]; ok && cached.ModTime.Equal(info.ModTime()) {
addSessionToDir(dirMap, cached.ToSession(sessionID))
continue
}
session := scanSessionFile(filePath, sessionID)
if session == nil || session.Cwd == "" {
continue
}
addSessionToDir(dirMap, session)
cache[sessionID] = cacheEntryFrom(session, info.ModTime())
}
}
saveCache(home, cache)
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
}
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
func scanSessionFile(path string, sessionID string) *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(make([]byte, 0, 64*1024), 1024*1024)
lineCount := 0
firstMsgFound := false
scannedLines := 0 // 有效行(含 type 的)
for scanner.Scan() {
lineCount++
line := scanner.Bytes()
if !hasType(line) {
continue
}
scannedLines++
// 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 != "" {
s.FirstMsg = truncStr(msg, 80)
firstMsgFound = true
}
}
case matchType(line, "assistant"):
s.MsgCount++
}
if lineCount > scanLineLimit {
break
}
}
// 按 scannedLines/lineCount 比例估算总消息数
if lineCount > scanLineLimit && scannedLines > 0 {
ratio := float64(lineCount) / float64(scanLineLimit)
s.MsgCount = int(float64(s.MsgCount) * ratio)
}
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 != "" && !strings.HasPrefix(content, "<command") &&
!strings.HasPrefix(content, "<system-reminder") &&
!strings.HasPrefix(content, "[Request interrupted") {
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+6:], &text) && text != "" &&
!strings.HasPrefix(text, "Base directory") {
return text
}
}
}
return ""
}
// 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 = 1
type cacheFile struct {
Version int `json:"version"`
Sessions map[string]*cacheEntry `json:"sessions"`
}
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"`
FilePath string `json:"filePath"`
}
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,
FilePath: e.FilePath,
}
}
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,
FilePath: s.FilePath,
}
}
func cachePath(home string) string {
return filepath.Join(home, ".u-tabs", "session-cache.json")
}
func loadCache(home string) map[string]*cacheEntry {
data, err := os.ReadFile(cachePath(home))
if err != nil {
return make(map[string]*cacheEntry)
}
var cf cacheFile
if json.Unmarshal(data, &cf) != nil || cf.Version != cacheVersion {
return make(map[string]*cacheEntry)
}
return cf.Sessions
}
func saveCache(home string, m map[string]*cacheEntry) {
cf := cacheFile{Version: cacheVersion, Sessions: m}
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 := os.WriteFile(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
}
func generateSummaryCmd(filePath, sessionID string) tea.Cmd {
return func() tea.Msg {
summary := generateAISummary(filePath)
return SummaryResultMsg{SessionID: sessionID, Summary: summary}
}
}
func generateAISummary(filePath string) string {
messages := extractMessagesForSummary(filePath)
if messages == "" {
return ""
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
prompt := "请用20字以内中文概括以下对话的主题只输出概括文字不加引号、标点或markdown格式\n\n" + messages
cmd := exec.CommandContext(ctx, "claude", "-p", prompt)
output, err := cmd.Output()
if err != nil {
return ""
}
summary := strings.TrimSpace(string(output))
summary = strings.Trim(summary, "\"'`*")
summary = strings.TrimPrefix(summary, "概括:")
summary = strings.TrimPrefix(summary, "主题:")
if len(summary) > 60 {
summary = truncStr(summary, 60)
}
return summary
}
func extractMessagesForSummary(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
var msgs []string
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
userCount := 0
for scanner.Scan() {
if userCount >= 5 {
break
}
line := scanner.Bytes()
if !hasType(line) {
continue
}
if matchType(line, "user") {
if msg := extractUserMsg(line); msg != "" {
msgs = append(msgs, "用户: "+msg)
userCount++
}
}
}
return strings.Join(msgs, "\n")
}
func saveSummaryToCache(sessionID, summary string) {
home, err := os.UserHomeDir()
if err != nil {
return
}
cache := loadCache(home)
entry, ok := cache[sessionID]
if !ok {
return
}
entry.AISummary = summary
saveCache(home, cache)
}