554 lines
14 KiB
Go
554 lines
14 KiB
Go
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 生成的摘要 (缓存)
|
||
Completed []string // AI 提取的已完成项
|
||
Pending []string // AI 提取的待办项
|
||
FilePath string // JSONL 文件完整路径
|
||
}
|
||
|
||
// 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.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
|
||
}
|
||
|
||
// IsHistoryTab 判断当前是否在 HISTORY Tab
|
||
func (m *Model) IsHistoryTab() bool {
|
||
return m.activeGroup == len(Groups)
|
||
}
|
||
|
||
// --- 扫描 ---
|
||
|
||
type ScanCompleteMsg struct {
|
||
Projects []*ProjectDir
|
||
UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要
|
||
}
|
||
|
||
func ScanSessionsCmd() tea.Cmd {
|
||
return func() tea.Msg {
|
||
projects, updated := scanAllProjects()
|
||
return ScanCompleteMsg{Projects: projects, UpdatedIDs: updated}
|
||
}
|
||
}
|
||
|
||
func scanAllProjects() ([]*ProjectDir, map[string]bool) {
|
||
home, err := os.UserHomeDir()
|
||
if err != nil {
|
||
return nil, nil
|
||
}
|
||
projectsDir := filepath.Join(home, ".claude", "projects")
|
||
entries, err := os.ReadDir(projectsDir)
|
||
if err != nil {
|
||
return nil, nil
|
||
}
|
||
|
||
cache := loadCache(home)
|
||
dirMap := make(map[string]*ProjectDir)
|
||
updatedIDs := make(map[string]bool)
|
||
|
||
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
|
||
}
|
||
// 清除旧摘要,标记需要重新生成
|
||
session.AISummary = ""
|
||
session.Completed = nil
|
||
session.Pending = nil
|
||
updatedIDs[sessionID] = true
|
||
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, updatedIDs
|
||
}
|
||
|
||
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
|
||
|
||
for scanner.Scan() {
|
||
lineCount++
|
||
line := scanner.Bytes()
|
||
|
||
// 前 20 行内出现 queue-operation → claude -p 会话,跳过
|
||
if lineCount <= 20 && matchType(line, "queue-operation") {
|
||
return nil
|
||
}
|
||
|
||
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 != "" {
|
||
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 != "" && !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+7:], &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 = 2
|
||
|
||
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"`
|
||
Completed []string `json:"completed"`
|
||
Pending []string `json:"pending"`
|
||
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,
|
||
Completed: e.Completed, Pending: e.Pending,
|
||
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,
|
||
Completed: s.Completed, Pending: s.Pending,
|
||
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
|
||
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 := `分析以下Claude Code对话,用JSON输出:
|
||
{"summary":"20字内中文概括","completed":["已完成项1","已完成项2"],"pending":["待办项1"]}
|
||
要求:summary 20字内概括;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 {
|
||
// 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
|
||
}
|
||
|
||
if len(result.Summary) > 60 {
|
||
result.Summary = truncStr(result.Summary, 60)
|
||
}
|
||
return result.Summary, result.Completed, result.Pending
|
||
}
|
||
|
||
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, completed, pending []string) {
|
||
home, err := os.UserHomeDir()
|
||
if err != nil {
|
||
return
|
||
}
|
||
cache := loadCache(home)
|
||
entry, ok := cache[sessionID]
|
||
if !ok {
|
||
return
|
||
}
|
||
entry.AISummary = summary
|
||
entry.Completed = completed
|
||
entry.Pending = pending
|
||
saveCache(home, cache)
|
||
}
|