新增: AI 摘要生成 (claude -p)
扫描完成后自动逐个调用 claude -p 生成会话标题, 摘要缓存到 ~/.u-tabs/session-cache.json, 下次免重复调用
This commit is contained in:
@@ -56,7 +56,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.history.Projects = msg.Projects
|
m.history.Projects = msg.Projects
|
||||||
m.history.Loaded = true
|
m.history.Loaded = true
|
||||||
m.history.Scanning = false
|
m.history.Scanning = false
|
||||||
return m, nil
|
return m, m.nextSummaryCmd()
|
||||||
|
case SummaryResultMsg:
|
||||||
|
m.applySummary(msg.SessionID, msg.Summary)
|
||||||
|
return m, m.nextSummaryCmd()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -222,6 +225,34 @@ func (m *Model) resumeSelected() (*Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- AI 摘要 ---
|
||||||
|
|
||||||
|
func (m *Model) applySummary(sessionID, summary string) {
|
||||||
|
if summary == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, pd := range m.history.Projects {
|
||||||
|
for _, s := range pd.Sessions {
|
||||||
|
if s.ID == sessionID {
|
||||||
|
s.AISummary = summary
|
||||||
|
saveSummaryToCache(sessionID, summary)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) nextSummaryCmd() tea.Cmd {
|
||||||
|
for _, pd := range m.history.Projects {
|
||||||
|
for _, s := range pd.Sessions {
|
||||||
|
if s.AISummary == "" && s.FilePath != "" && s.FirstMsg != "" {
|
||||||
|
return generateSummaryCmd(s.FilePath, s.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
// --- Workspace 光标 ---
|
// --- Workspace 光标 ---
|
||||||
|
|
||||||
func (m *Model) moveCursor(dir int) {
|
func (m *Model) moveCursor(dir int) {
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package internal
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -25,6 +27,7 @@ type Session struct {
|
|||||||
FirstMsg string // 首条用户消息 (截断)
|
FirstMsg string // 首条用户消息 (截断)
|
||||||
AwaySummary string // away_summary 系统摘要
|
AwaySummary string // away_summary 系统摘要
|
||||||
AISummary string // AI 生成的摘要 (缓存)
|
AISummary string // AI 生成的摘要 (缓存)
|
||||||
|
FilePath string // JSONL 文件完整路径
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectDir 按目录分组的会话集合
|
// ProjectDir 按目录分组的会话集合
|
||||||
@@ -167,7 +170,7 @@ func scanSessionFile(path string, sessionID string) *Session {
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
s := &Session{ID: sessionID}
|
s := &Session{ID: sessionID, FilePath: path}
|
||||||
scanner := bufio.NewScanner(f)
|
scanner := bufio.NewScanner(f)
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
|
||||||
@@ -345,6 +348,7 @@ type cacheEntry struct {
|
|||||||
FirstMsg string `json:"firstMsg"`
|
FirstMsg string `json:"firstMsg"`
|
||||||
AwaySummary string `json:"awaySummary"`
|
AwaySummary string `json:"awaySummary"`
|
||||||
AISummary string `json:"aiSummary"`
|
AISummary string `json:"aiSummary"`
|
||||||
|
FilePath string `json:"filePath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *cacheEntry) ToSession(id string) *Session {
|
func (e *cacheEntry) ToSession(id string) *Session {
|
||||||
@@ -353,6 +357,7 @@ func (e *cacheEntry) ToSession(id string) *Session {
|
|||||||
StartTime: e.StartTime, EndTime: e.EndTime,
|
StartTime: e.StartTime, EndTime: e.EndTime,
|
||||||
MsgCount: e.MsgCount, FirstMsg: e.FirstMsg,
|
MsgCount: e.MsgCount, FirstMsg: e.FirstMsg,
|
||||||
AwaySummary: e.AwaySummary, AISummary: e.AISummary,
|
AwaySummary: e.AwaySummary, AISummary: e.AISummary,
|
||||||
|
FilePath: e.FilePath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,6 +367,7 @@ func cacheEntryFrom(s *Session, modTime time.Time) *cacheEntry {
|
|||||||
StartTime: s.StartTime, EndTime: s.EndTime,
|
StartTime: s.StartTime, EndTime: s.EndTime,
|
||||||
MsgCount: s.MsgCount, FirstMsg: s.FirstMsg,
|
MsgCount: s.MsgCount, FirstMsg: s.FirstMsg,
|
||||||
AwaySummary: s.AwaySummary, AISummary: s.AISummary,
|
AwaySummary: s.AwaySummary, AISummary: s.AISummary,
|
||||||
|
FilePath: s.FilePath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,3 +410,88 @@ func truncStr(s string, maxRunes int) string {
|
|||||||
}
|
}
|
||||||
return string(runes[:maxRunes]) + "..."
|
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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user